From b6e7a6a665828403b3f3ef9e74bd86399e1c6115 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 3 Jul 2026 17:30:20 +0200 Subject: [PATCH] new: add adjustable subtitle text size in player and preferences UI --- .../data/InMemoryPreferencesRepository.kt | 4 ++ .../domain/PreferencesRepository.kt | 2 + .../preferences/model/SubtitlePreferences.kt | 22 +++++- .../data/DataStorePreferencesRepository.kt | 4 ++ .../composeResources/values-br/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 6 ++ .../ajaury/gwenedeg/player/ui/SubtitleList.kt | 44 ++++++++---- .../player/ui/viewmodel/PlayerViewModel.kt | 17 +++++ .../ui/SubtitlePreferencesBottomSheet.kt | 71 +++++++++++++++++++ 10 files changed, 159 insertions(+), 17 deletions(-) diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt index a960cc6..f52b08f 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt @@ -27,6 +27,10 @@ internal class InMemoryPreferencesRepository : PreferencesRepository { subtitleState.update { it.copy(showTranslation = enabled) } } + override suspend fun setSubtitleTextScale(scale: Float) { + subtitleState.update { it.copy(textScale = scale) } + } + override suspend fun setPlaybackSpeed(speed: Float) { playbackState.update { it.copy(speed = speed) } } diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt index 8a452c8..7bf8a01 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt @@ -13,5 +13,7 @@ interface PreferencesRepository { suspend fun setShowTranslation(enabled: Boolean) + suspend fun setSubtitleTextScale(scale: Float) + suspend fun setPlaybackSpeed(speed: Float) } diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt index 5785d2a..a63050c 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt @@ -1,12 +1,30 @@ package fr.ajaury.gwenedeg.preferences.model /** - * User preferences controlling which subtitle lines are displayed on the player. + * 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]). */ data class SubtitlePreferences( val showTranscription: Boolean = true, val showTranslation: Boolean = true, -) + val textScale: Float = DEFAULT_TEXT_SCALE, +) { + companion object { + const val DEFAULT_TEXT_SCALE = 1f + + /** Allowed text-scale steps: 0.1 increments up to x1, then 0.2 increments up to x2. */ + val TEXT_SCALE_STEPS: List = + 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() + + /** 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() + } +} diff --git a/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt index f74a676..6b6a3c0 100644 --- a/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt +++ b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt @@ -32,6 +32,8 @@ internal class DataStorePreferencesRepository( override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled } + override suspend fun setSubtitleTextScale(scale: Float) = edit { it[SubtitleTextScaleKey] = scale } + override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed } private suspend fun edit(transform: (MutablePreferences) -> Unit) { @@ -43,6 +45,7 @@ internal class DataStorePreferencesRepository( return SubtitlePreferences( showTranscription = this[ShowTranscriptionKey] ?: defaults.showTranscription, showTranslation = this[ShowTranslationKey] ?: defaults.showTranslation, + textScale = this[SubtitleTextScaleKey] ?: defaults.textScale, ) } @@ -54,6 +57,7 @@ internal class DataStorePreferencesRepository( private companion object { val ShowTranscriptionKey = booleanPreferencesKey("show_transcription") val ShowTranslationKey = booleanPreferencesKey("show_translation") + val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale") val PlaybackSpeedKey = floatPreferencesKey("playback_speed") } } diff --git a/data/resources/src/commonMain/composeResources/values-br/strings.xml b/data/resources/src/commonMain/composeResources/values-br/strings.xml index 1cd378b..829c53e 100644 --- a/data/resources/src/commonMain/composeResources/values-br/strings.xml +++ b/data/resources/src/commonMain/composeResources/values-br/strings.xml @@ -8,6 +8,9 @@ Diskouez an treuzskrivadur brezhonek Troidigezh (FR) Diskouez an droidigezh c\'hallek + Ment an destenn + Bihanaat ment an destenn + Brasaat ment an destenn Tizh al lenn diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 837dbb4..5da01d5 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -8,6 +8,9 @@ Afficher la transcription bretonne Traduction (FR) Afficher la traduction française + Taille du texte + Réduire la taille du texte + Augmenter la taille du texte Vitesse de lecture diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt index 097f39d..a08586e 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt @@ -72,6 +72,8 @@ fun PlayerScreen( onNextSentenceClicked = viewModel::goToNextSentence, onShowTranscriptionChange = viewModel::setShowTranscription, onShowTranslationChange = viewModel::setShowTranslation, + onTextSizeDecrease = viewModel::decreaseSubtitleTextSize, + onTextSizeIncrease = viewModel::increaseSubtitleTextSize, onSpeedChange = viewModel::setPlaybackSpeed, onBackClicked = onBackClicked, ) @@ -88,6 +90,8 @@ fun PlayerScreen( onNextSentenceClicked: () -> Unit = {}, onShowTranscriptionChange: (Boolean) -> Unit = {}, onShowTranslationChange: (Boolean) -> Unit = {}, + onTextSizeDecrease: () -> Unit = {}, + onTextSizeIncrease: () -> Unit = {}, onSpeedChange: (Float) -> Unit = {}, onBackClicked: () -> Unit = {}, ) { @@ -184,6 +188,8 @@ fun PlayerScreen( subtitlePreferences = uiState.subtitlePreferences, onShowTranscriptionChange = onShowTranscriptionChange, onShowTranslationChange = onShowTranslationChange, + onTextSizeDecrease = onTextSizeDecrease, + onTextSizeIncrease = onTextSizeIncrease, onDismiss = { showPreferences = false }, ) } 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 650f593..3c646c7 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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -27,9 +28,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +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 @@ -90,23 +93,34 @@ fun SubtitleList( } } + val density = LocalDensity.current BoxWithConstraints(modifier = modifier) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - // Half-height padding so the first and last lines can reach the vertical center. - contentPadding = PaddingValues(vertical = maxHeight / 2), + // Half-height padding so the first and last lines can reach the vertical center. Captured here + // because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda. + val verticalPadding = maxHeight / 2 + // Scale only the subtitle fonts by overriding the density's fontScale (dp layout is untouched). + CompositionLocalProvider( + LocalDensity provides Density( + density = density.density, + fontScale = density.fontScale * subtitlePreferences.textScale, + ), ) { - itemsIndexed(lines) { index, line -> - val isCurrent = index == centeredIndex - Subtitle( - line = line, - isCurrent = isCurrent, - subtitlePreferences = subtitlePreferences, - onClick = { onSeek(index) }, - ) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = verticalPadding), + ) { + itemsIndexed(lines) { index, line -> + val isCurrent = index == centeredIndex + Subtitle( + line = line, + isCurrent = isCurrent, + subtitlePreferences = subtitlePreferences, + onClick = { onSeek(index) }, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt index 4cef80f..98151d7 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt @@ -9,6 +9,7 @@ import fr.ajaury.gwenedeg.player.domain.PlaybackRepository import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences +import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.records.domain.RecordRepository import fr.ajaury.gwenedeg.records.model.Record import kotlinx.coroutines.flow.MutableStateFlow @@ -125,6 +126,22 @@ class PlayerViewModel( } } + fun increaseSubtitleTextSize() { + val current = uiState.value.subtitlePreferences.textScale + setSubtitleTextScale(SubtitlePreferences.nextTextScale(current)) + } + + fun decreaseSubtitleTextSize() { + val current = uiState.value.subtitlePreferences.textScale + setSubtitleTextScale(SubtitlePreferences.previousTextScale(current)) + } + + private fun setSubtitleTextScale(scale: Float) { + viewModelScope.launch { + preferencesRepository.setSubtitleTextScale(scale) + } + } + fun stop() { playbackRepository.stop() } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/SubtitlePreferencesBottomSheet.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/SubtitlePreferencesBottomSheet.kt index ace8fc6..2d064b5 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/SubtitlePreferencesBottomSheet.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/SubtitlePreferencesBottomSheet.kt @@ -2,27 +2,38 @@ package fr.ajaury.gwenedeg.preferences.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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_preferences_title +import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_text_size_decrease +import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_text_size_increase +import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_text_size_label import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_transcription_description import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_transcription_label import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_translation_description import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_translation_label import fr.ajaury.gwenedeg.theme.ChomBevTheme import org.jetbrains.compose.resources.stringResource +import kotlin.math.roundToInt /** * Bottom sheet letting the user toggle the display of the Breton transcription (BZH) and the @@ -34,6 +45,8 @@ fun SubtitlePreferencesBottomSheet( subtitlePreferences: SubtitlePreferences, onShowTranscriptionChange: (Boolean) -> Unit = {}, onShowTranslationChange: (Boolean) -> Unit = {}, + onTextSizeDecrease: () -> Unit = {}, + onTextSizeIncrease: () -> Unit = {}, onDismiss: () -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState(), ) { @@ -45,6 +58,8 @@ fun SubtitlePreferencesBottomSheet( subtitlePreferences = subtitlePreferences, onShowTranscriptionChange = onShowTranscriptionChange, onShowTranslationChange = onShowTranslationChange, + onTextSizeDecrease = onTextSizeDecrease, + onTextSizeIncrease = onTextSizeIncrease, ) } } @@ -54,6 +69,8 @@ private fun SubtitlePreferencesContent( subtitlePreferences: SubtitlePreferences, onShowTranscriptionChange: (Boolean) -> Unit = {}, onShowTranslationChange: (Boolean) -> Unit = {}, + onTextSizeDecrease: () -> Unit = {}, + onTextSizeIncrease: () -> Unit = {}, ) { Column( modifier = Modifier @@ -78,9 +95,63 @@ private fun SubtitlePreferencesContent( checked = subtitlePreferences.showTranslation, onCheckedChange = onShowTranslationChange, ) + SubtitleTextSizeControl( + textScale = subtitlePreferences.textScale, + onDecrease = onTextSizeDecrease, + onIncrease = onTextSizeIncrease, + ) } } +@Composable +private fun SubtitleTextSizeControl( + textScale: Float, + onDecrease: () -> Unit = {}, + onIncrease: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.subtitle_text_size_label), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onDecrease, + enabled = textScale > SubtitlePreferences.TEXT_SCALE_STEPS.first(), + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = stringResource(Res.string.subtitle_text_size_decrease), + ) + } + Text( + text = formatTextScale(textScale), + style = MaterialTheme.typography.titleMedium, + ) + IconButton( + onClick = onIncrease, + enabled = textScale < SubtitlePreferences.TEXT_SCALE_STEPS.last(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.subtitle_text_size_increase), + ) + } + } +} + +/** Formats a text-scale multiplier as a short label, e.g. 1.0 -> "x1", 1.2 -> "x1.2". */ +private fun formatTextScale(scale: Float): String { + val tenths = (scale * 10).roundToInt() + return if (tenths % 10 == 0) "x${tenths / 10}" else "x${tenths / 10}.${tenths % 10}" +} + @Preview @Composable private fun SubtitlePreferencesContentPreview() {