diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt index 3c646c7..56da381 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import fr.ajaury.gwenedeg.core.model.Phrase +import fr.ajaury.gwenedeg.player.ui.components.TranscriptionText import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine import fr.ajaury.gwenedeg.theme.ChomBevTheme @@ -194,10 +195,10 @@ private fun TranscriptionLine( text: String, contentColor: Color, ) { - Text( + TranscriptionText( text = text, - style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), color = contentColor, + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), textAlign = TextAlign.Center, ) } @@ -234,8 +235,8 @@ private val previewSubtitleLines = SubtitleLine( startTime = 7350.milliseconds, phrase = Phrase( - transcription = "Kenavo emberr !", - translation = "À ce soir !", + transcription = "Tomm eo ma(n) divskouarn", + translation = "J'ai chaud aux oreilles", ), ), ) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/TranscriptionText.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/TranscriptionText.kt new file mode 100644 index 0000000..37e7683 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/TranscriptionText.kt @@ -0,0 +1,159 @@ +package fr.ajaury.gwenedeg.player.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import fr.ajaury.gwenedeg.theme.ChomBevTheme + +/** + * Matches a liaison/optional letter written between parentheses in a transcription, e.g. the `n` + * in `ma(n)`. The captured group is the letter itself, without the surrounding parentheses. + */ +private val MUTED_LETTER_REGEX = Regex("""\(([A-Za-z])\)""") + +/** Diacritic drawn above an optional letter to mark that it is only sounded on liaison. */ +private const val MUTED_LETTER_MARK = "ø" + +/** + * Renders a transcription, giving special treatment to letters written between parentheses + * (liaison consonants such as the `n` in `ma(n)`): the parentheses are dropped, the letter is + * shown in a muted grey and topped with a small [MUTED_LETTER_MARK]. + * + * Optional letters are emitted as inline content so the line still wraps like normal text. + */ +@Composable +fun TranscriptionText( + text: String, + color: Color, + style: TextStyle, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Center, +) { + val matches = remember(text) { MUTED_LETTER_REGEX.findAll(text).toList() } + + if (matches.isEmpty()) { + Text( + text = text, + color = color, + style = style, + textAlign = textAlign, + modifier = modifier, + ) + return + } + + // Muted grey that still follows the surrounding line's dimming (current vs. inactive line). + val markColor = color.copy(alpha = color.alpha * 0.6f) + + val annotatedText = buildAnnotatedString { + var cursor = 0 + matches.forEachIndexed { index, match -> + append(text.substring(cursor, match.range.first)) + appendInlineContent(id = inlineId(index), alternateText = match.groupValues[1]) + cursor = match.range.last + 1 + } + append(text.substring(cursor)) + } + + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + + val inlineContent = matches + .mapIndexed { index, match -> + val letter = match.groupValues[1] + // Reserve exactly the letter's advance width (in em, so it is font-scale independent); the + // taller placeholder leaves room for the mark above. Aligning to the text bottom keeps the + // letter's baseline in step with the rest of the line. + val letterWidthEm = remember(letter, style) { + val letterWidthPx = textMeasurer.measure(text = letter, style = style).size.width + val fontSizePx = with(density) { style.fontSize.toPx() } + letterWidthPx / fontSizePx + } + + inlineId(index) to InlineTextContent( + placeholder = Placeholder( + width = letterWidthEm.em, + height = 1.7.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, + ), + ) { + OptionalLetter(letter = letter, color = markColor, style = style) + } + }.toMap() + + Text( + text = annotatedText, + color = color, + style = style, + textAlign = textAlign, + inlineContent = inlineContent, + modifier = modifier, + ) +} + +@Composable +private fun OptionalLetter( + letter: String, + color: Color, + style: TextStyle, +) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = MUTED_LETTER_MARK, + color = color, + style = style.copy(fontSize = style.fontSize * 0.75f, lineHeight = 10.sp), + modifier = Modifier.align(Alignment.TopCenter), + ) + Text( + text = letter, + color = color, + // Tight line height so the glyph's baseline lands where the surrounding line expects it. + style = style.copy(lineHeight = style.fontSize), + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +private fun inlineId(index: Int): String = "optional-letter-$index" + +@Preview(backgroundColor = 0xFFFFFFFF) +@Composable +private fun TranscriptionTextPreview() { + ChomBevTheme { + TranscriptionText( + text = "Tomm eo ma(n) divskouarn, penn ma fri", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), + ) + } +} + +@Preview(backgroundColor = 0xFFFFFFFF) +@Composable +private fun TranscriptionTextNoOptionalLetterPreview() { + ChomBevTheme { + TranscriptionText( + text = "Demat deoc'h !", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), + ) + } +}