new: add phonetic marker toggle to subtitles preferences

This commit is contained in:
2026-07-06 16:44:18 +02:00
parent 9dc5d27c19
commit 743697202e
11 changed files with 77 additions and 9 deletions
@@ -32,6 +32,10 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
subtitleState.update { it.copy(textScale = scale) }
}
override suspend fun setShowPhoneticMarkers(enabled: Boolean) {
subtitleState.update { it.copy(showPhoneticMarkers = enabled) }
}
override suspend fun setPlaybackSpeed(speed: Float) {
playbackState.update { it.copy(speed = speed) }
}
@@ -16,6 +16,8 @@ interface PreferencesRepository {
suspend fun setSubtitleTextScale(scale: Float)
suspend fun setShowPhoneticMarkers(enabled: Boolean)
suspend fun setPlaybackSpeed(speed: Float)
suspend fun setBreakBetweenPhrases(duration: Duration)
@@ -1,16 +1,23 @@
package fr.ajaury.gwenedeg.preferences.model
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences.Companion.TEXT_SCALE_STEPS
/**
* User preferences controlling the subtitle display on the player.
*
* @property showTranscription whether the Breton (BZH) transcription line is shown.
* @property showTranslation whether the French (FR) translation line is shown.
* @property textScale multiplier applied to the subtitle font size (see [TEXT_SCALE_STEPS]).
* @property showPhoneticMarkers whether the muted `{x}` and liaison `[x]` letters are rendered with
* their pronunciation marks. When off, muted letters read as normal letters and liaison letters are
* replaced by a space.
*/
data class SubtitlePreferences(
val showTranscription: Boolean = true,
val showTranslation: Boolean = true,
val textScale: Float = DEFAULT_TEXT_SCALE,
val showPhoneticMarkers: Boolean = true,
) {
companion object {
const val DEFAULT_TEXT_SCALE = 1f
@@ -20,11 +27,9 @@ data class SubtitlePreferences(
listOf(0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f, 1.2f, 1.4f, 1.6f, 1.8f, 2.0f)
/** The next allowed step above [scale], or the maximum when already at the top. */
fun nextTextScale(scale: Float): Float =
TEXT_SCALE_STEPS.firstOrNull { it > scale } ?: TEXT_SCALE_STEPS.last()
fun nextTextScale(scale: Float): Float = TEXT_SCALE_STEPS.firstOrNull { it > scale } ?: TEXT_SCALE_STEPS.last()
/** The previous allowed step below [scale], or the minimum when already at the bottom. */
fun previousTextScale(scale: Float): Float =
TEXT_SCALE_STEPS.lastOrNull { it < scale } ?: TEXT_SCALE_STEPS.first()
fun previousTextScale(scale: Float): Float = TEXT_SCALE_STEPS.lastOrNull { it < scale } ?: TEXT_SCALE_STEPS.first()
}
}
@@ -37,6 +37,8 @@ internal class DataStorePreferencesRepository(
override suspend fun setSubtitleTextScale(scale: Float) = edit { it[SubtitleTextScaleKey] = scale }
override suspend fun setShowPhoneticMarkers(enabled: Boolean) = edit { it[ShowPhoneticMarkersKey] = enabled }
override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed }
override suspend fun setBreakBetweenPhrases(duration: Duration) =
@@ -52,6 +54,7 @@ internal class DataStorePreferencesRepository(
showTranscription = this[ShowTranscriptionKey] ?: defaults.showTranscription,
showTranslation = this[ShowTranslationKey] ?: defaults.showTranslation,
textScale = this[SubtitleTextScaleKey] ?: defaults.textScale,
showPhoneticMarkers = this[ShowPhoneticMarkersKey] ?: defaults.showPhoneticMarkers,
)
}
@@ -66,6 +69,7 @@ internal class DataStorePreferencesRepository(
val ShowTranscriptionKey = booleanPreferencesKey("show_transcription")
val ShowTranslationKey = booleanPreferencesKey("show_translation")
val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale")
val ShowPhoneticMarkersKey = booleanPreferencesKey("show_phonetic_marker")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds")
}
@@ -11,6 +11,8 @@
<string name="subtitle_text_size_label">Ment an destenn</string>
<string name="subtitle_text_size_decrease">Bihanaat ment an destenn</string>
<string name="subtitle_text_size_increase">Brasaat ment an destenn</string>
<string name="subtitle_marked_letters_label">Merkoù distagañ</string>
<string name="subtitle_marked_letters_description">Diskouez ar c\'hensonennoù mut ha al liammoù</string>
<string name="playback_speed_title">Tizh al lenn</string>
<string name="playback_settings_action">Arventennoù al lenn</string>
@@ -11,6 +11,8 @@
<string name="subtitle_text_size_label">Taille du texte</string>
<string name="subtitle_text_size_decrease">Réduire la taille du texte</string>
<string name="subtitle_text_size_increase">Augmenter la taille du texte</string>
<string name="subtitle_marked_letters_label">Marques de prononciation</string>
<string name="subtitle_marked_letters_description">Afficher les consonnes muettes et les liaisons</string>
<string name="playback_speed_title">Vitesse de lecture</string>
<string name="playback_settings_action">Réglages de lecture</string>
@@ -72,6 +72,7 @@ fun PlayerScreen(
onNextSentenceClicked = viewModel::goToNextSentence,
onShowTranscriptionChange = viewModel::setShowTranscription,
onShowTranslationChange = viewModel::setShowTranslation,
onShowPhoneticMarkerChange = viewModel::setShowPhoneticMarkers,
onTextSizeDecrease = viewModel::decreaseSubtitleTextSize,
onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed,
@@ -91,6 +92,7 @@ fun PlayerScreen(
onNextSentenceClicked: () -> Unit = {},
onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {},
onShowPhoneticMarkerChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {},
@@ -190,6 +192,7 @@ fun PlayerScreen(
subtitlePreferences = uiState.subtitlePreferences,
onShowTranscriptionChange = onShowTranscriptionChange,
onShowTranslationChange = onShowTranslationChange,
onShowPhoneticMarkerChange = onShowPhoneticMarkerChange,
onTextSizeDecrease = onTextSizeDecrease,
onTextSizeIncrease = onTextSizeIncrease,
onDismiss = { showPreferences = false },
@@ -177,6 +177,7 @@ private fun Subtitle(
TranscriptionLine(
text = line.phrase.transcription,
contentColor = contentColor,
showPhoneticMarkers = subtitlePreferences.showPhoneticMarkers,
)
}
line.phrase.translation
@@ -194,6 +195,7 @@ private fun Subtitle(
private fun TranscriptionLine(
text: String,
contentColor: Color,
showPhoneticMarkers: Boolean = true,
) {
TranscriptionText(
text = text,
@@ -203,6 +205,7 @@ private fun TranscriptionLine(
lineHeight = 32.sp,
),
textAlign = TextAlign.Center,
showPhoneticMarkers = showPhoneticMarkers,
)
}
@@ -38,7 +38,7 @@ import fr.ajaury.gwenedeg.theme.ChomBevTheme
*
* The captured group is the bare letter, without its surrounding delimiters.
*/
private val MARKED_LETTER_REGEX = Regex("""\(([A-Za-z])\)|\[([A-Za-z])\]""")
private val MARKED_LETTER_REGEX = Regex("""\{([A-Za-z])\}|\[([A-Za-z])\]""")
/** Diacritic drawn above a parenthesised letter to mark that it is muted. */
private const val MUTED_MARK = "ø"
@@ -51,12 +51,15 @@ private const val WORD_JOINER = "\u2060"
/**
* Renders a transcription, giving special treatment to single letters written between delimiters:
* - `(x)` — a liaison consonant: the parentheses are dropped and the letter stays on the line, in
* - `{x}` — a liaison consonant: the parentheses are dropped and the letter stays on the line, in
* muted grey, topped with a small [MUTED_MARK].
* - `[x]` — a linking consonant: the brackets are dropped and the letter is raised above the line,
* in muted grey, with a small [LIAISON_MARK] underneath it.
*
* Marked letters are emitted as inline content so the line still wraps like normal text.
*
* When [showPhoneticMarkers] is `false` the pronunciation marks are dropped entirely: a muted
* curved-bracketed letter reads as a normal letter, and a bracketed liaison letter becomes a space.
*/
@Composable
fun TranscriptionText(
@@ -65,12 +68,18 @@ fun TranscriptionText(
style: TextStyle,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Center,
showPhoneticMarkers: Boolean = true,
) {
val matches = remember(text) { MARKED_LETTER_REGEX.findAll(text).toList() }
val matches = remember(text, showPhoneticMarkers) {
if (showPhoneticMarkers) MARKED_LETTER_REGEX.findAll(text).toList() else emptyList()
}
if (matches.isEmpty()) {
val plainText = remember(text, showPhoneticMarkers) {
if (showPhoneticMarkers) text else text.withoutMarkedLetters()
}
Text(
text = text,
text = plainText,
color = color,
style = style,
textAlign = textAlign,
@@ -211,6 +220,24 @@ private val MatchResult.letter: String
private val MatchResult.isBracketed: Boolean
get() = groupValues[1].isEmpty()
/**
* Strips pronunciation marks from a transcription: a muted parenthesised letter becomes a plain
* letter, while a bracketed liaison letter is dropped and replaced by a space — except when it
* directly follows an apostrophe (e.g. `d'[t]ober`), where it is removed without leaving a space.
*/
private fun String.withoutMarkedLetters(): String =
replace(MARKED_LETTER_REGEX) { match ->
val precedingChar = getOrNull(match.range.first - 1)
when {
!match.isBracketed -> match.letter
precedingChar != null && precedingChar in APOSTROPHES -> ""
else -> " "
}
}
/** Apostrophe characters after which a dropped liaison letter should not leave a space. */
private val APOSTROPHES = setOf('\'', '')
private fun inlineId(index: Int): String = "marked-letter-$index"
@Preview(backgroundColor = 0xFFFFFFFF)
@@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@@ -147,6 +146,12 @@ class PlayerViewModel(
}
}
fun setShowPhoneticMarkers(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setShowPhoneticMarkers(enabled)
}
}
fun increaseSubtitleTextSize() {
val current = uiState.value.subtitlePreferences.textScale
setSubtitleTextScale(SubtitlePreferences.nextTextScale(current))
@@ -23,6 +23,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import fr.ajaury.gwenedeg.resources.generated.resources.Res
import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_marked_letters_description
import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_marked_letters_label
import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_preferences_title
import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_text_size_decrease
import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_text_size_increase
@@ -45,6 +47,7 @@ fun SubtitlePreferencesBottomSheet(
subtitlePreferences: SubtitlePreferences,
onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {},
onShowPhoneticMarkerChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onDismiss: () -> Unit = {},
@@ -58,6 +61,7 @@ fun SubtitlePreferencesBottomSheet(
subtitlePreferences = subtitlePreferences,
onShowTranscriptionChange = onShowTranscriptionChange,
onShowTranslationChange = onShowTranslationChange,
onShowMarkedLettersChange = onShowPhoneticMarkerChange,
onTextSizeDecrease = onTextSizeDecrease,
onTextSizeIncrease = onTextSizeIncrease,
)
@@ -69,6 +73,7 @@ private fun SubtitlePreferencesContent(
subtitlePreferences: SubtitlePreferences,
onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {},
onShowMarkedLettersChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
) {
@@ -95,6 +100,12 @@ private fun SubtitlePreferencesContent(
checked = subtitlePreferences.showTranslation,
onCheckedChange = onShowTranslationChange,
)
PreferenceToggle(
title = stringResource(Res.string.subtitle_marked_letters_label),
subtitle = stringResource(Res.string.subtitle_marked_letters_description),
checked = subtitlePreferences.showPhoneticMarkers,
onCheckedChange = onShowMarkedLettersChange,
)
SubtitleTextSizeControl(
textScale = subtitlePreferences.textScale,
onDecrease = onTextSizeDecrease,