new: add adjustable subtitle text size in player and preferences UI

This commit is contained in:
2026-07-03 17:30:20 +02:00
parent f3ade9483d
commit b6e7a6a665
10 changed files with 159 additions and 17 deletions
@@ -27,6 +27,10 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
subtitleState.update { it.copy(showTranslation = enabled) } subtitleState.update { it.copy(showTranslation = enabled) }
} }
override suspend fun setSubtitleTextScale(scale: Float) {
subtitleState.update { it.copy(textScale = scale) }
}
override suspend fun setPlaybackSpeed(speed: Float) { override suspend fun setPlaybackSpeed(speed: Float) {
playbackState.update { it.copy(speed = speed) } playbackState.update { it.copy(speed = speed) }
} }
@@ -13,5 +13,7 @@ interface PreferencesRepository {
suspend fun setShowTranslation(enabled: Boolean) suspend fun setShowTranslation(enabled: Boolean)
suspend fun setSubtitleTextScale(scale: Float)
suspend fun setPlaybackSpeed(speed: Float) suspend fun setPlaybackSpeed(speed: Float)
} }
@@ -1,12 +1,30 @@
package fr.ajaury.gwenedeg.preferences.model 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 showTranscription whether the Breton (BZH) transcription line is shown.
* @property showTranslation whether the French (FR) translation 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( data class SubtitlePreferences(
val showTranscription: Boolean = true, val showTranscription: Boolean = true,
val showTranslation: 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<Float> =
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()
}
}
@@ -32,6 +32,8 @@ internal class DataStorePreferencesRepository(
override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled } 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 } override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed }
private suspend fun edit(transform: (MutablePreferences) -> Unit) { private suspend fun edit(transform: (MutablePreferences) -> Unit) {
@@ -43,6 +45,7 @@ internal class DataStorePreferencesRepository(
return SubtitlePreferences( return SubtitlePreferences(
showTranscription = this[ShowTranscriptionKey] ?: defaults.showTranscription, showTranscription = this[ShowTranscriptionKey] ?: defaults.showTranscription,
showTranslation = this[ShowTranslationKey] ?: defaults.showTranslation, showTranslation = this[ShowTranslationKey] ?: defaults.showTranslation,
textScale = this[SubtitleTextScaleKey] ?: defaults.textScale,
) )
} }
@@ -54,6 +57,7 @@ internal class DataStorePreferencesRepository(
private companion object { private companion object {
val ShowTranscriptionKey = booleanPreferencesKey("show_transcription") val ShowTranscriptionKey = booleanPreferencesKey("show_transcription")
val ShowTranslationKey = booleanPreferencesKey("show_translation") val ShowTranslationKey = booleanPreferencesKey("show_translation")
val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed") val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
} }
} }
@@ -8,6 +8,9 @@
<string name="subtitle_transcription_description">Diskouez an treuzskrivadur brezhonek</string> <string name="subtitle_transcription_description">Diskouez an treuzskrivadur brezhonek</string>
<string name="subtitle_translation_label">Troidigezh (FR)</string> <string name="subtitle_translation_label">Troidigezh (FR)</string>
<string name="subtitle_translation_description">Diskouez an droidigezh c\'hallek</string> <string name="subtitle_translation_description">Diskouez an droidigezh c\'hallek</string>
<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="playback_speed_title">Tizh al lenn</string> <string name="playback_speed_title">Tizh al lenn</string>
@@ -8,6 +8,9 @@
<string name="subtitle_transcription_description">Afficher la transcription bretonne</string> <string name="subtitle_transcription_description">Afficher la transcription bretonne</string>
<string name="subtitle_translation_label">Traduction (FR)</string> <string name="subtitle_translation_label">Traduction (FR)</string>
<string name="subtitle_translation_description">Afficher la traduction française</string> <string name="subtitle_translation_description">Afficher la traduction française</string>
<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="playback_speed_title">Vitesse de lecture</string> <string name="playback_speed_title">Vitesse de lecture</string>
@@ -72,6 +72,8 @@ fun PlayerScreen(
onNextSentenceClicked = viewModel::goToNextSentence, onNextSentenceClicked = viewModel::goToNextSentence,
onShowTranscriptionChange = viewModel::setShowTranscription, onShowTranscriptionChange = viewModel::setShowTranscription,
onShowTranslationChange = viewModel::setShowTranslation, onShowTranslationChange = viewModel::setShowTranslation,
onTextSizeDecrease = viewModel::decreaseSubtitleTextSize,
onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed, onSpeedChange = viewModel::setPlaybackSpeed,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
) )
@@ -88,6 +90,8 @@ fun PlayerScreen(
onNextSentenceClicked: () -> Unit = {}, onNextSentenceClicked: () -> Unit = {},
onShowTranscriptionChange: (Boolean) -> Unit = {}, onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {}, onShowTranslationChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBackClicked: () -> Unit = {}, onBackClicked: () -> Unit = {},
) { ) {
@@ -184,6 +188,8 @@ fun PlayerScreen(
subtitlePreferences = uiState.subtitlePreferences, subtitlePreferences = uiState.subtitlePreferences,
onShowTranscriptionChange = onShowTranscriptionChange, onShowTranscriptionChange = onShowTranscriptionChange,
onShowTranslationChange = onShowTranslationChange, onShowTranslationChange = onShowTranslationChange,
onTextSizeDecrease = onTextSizeDecrease,
onTextSizeIncrease = onTextSizeIncrease,
onDismiss = { showPreferences = false }, onDismiss = { showPreferences = false },
) )
} }
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -27,9 +28,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import fr.ajaury.gwenedeg.core.model.Phrase import fr.ajaury.gwenedeg.core.model.Phrase
@@ -90,23 +93,34 @@ fun SubtitleList(
} }
} }
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier) { BoxWithConstraints(modifier = modifier) {
LazyColumn( // Half-height padding so the first and last lines can reach the vertical center. Captured here
state = listState, // because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda.
modifier = Modifier.fillMaxSize(), val verticalPadding = maxHeight / 2
horizontalAlignment = Alignment.CenterHorizontally, // Scale only the subtitle fonts by overriding the density's fontScale (dp layout is untouched).
verticalArrangement = Arrangement.spacedBy(4.dp), CompositionLocalProvider(
// Half-height padding so the first and last lines can reach the vertical center. LocalDensity provides Density(
contentPadding = PaddingValues(vertical = maxHeight / 2), density = density.density,
fontScale = density.fontScale * subtitlePreferences.textScale,
),
) { ) {
itemsIndexed(lines) { index, line -> LazyColumn(
val isCurrent = index == centeredIndex state = listState,
Subtitle( modifier = Modifier.fillMaxSize(),
line = line, horizontalAlignment = Alignment.CenterHorizontally,
isCurrent = isCurrent, verticalArrangement = Arrangement.spacedBy(4.dp),
subtitlePreferences = subtitlePreferences, contentPadding = PaddingValues(vertical = verticalPadding),
onClick = { onSeek(index) }, ) {
) itemsIndexed(lines) { index, line ->
val isCurrent = index == centeredIndex
Subtitle(
line = line,
isCurrent = isCurrent,
subtitlePreferences = subtitlePreferences,
onClick = { onSeek(index) },
)
}
} }
} }
} }
@@ -9,6 +9,7 @@ import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences 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.domain.RecordRepository
import fr.ajaury.gwenedeg.records.model.Record import fr.ajaury.gwenedeg.records.model.Record
import kotlinx.coroutines.flow.MutableStateFlow 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() { fun stop() {
playbackRepository.stop() playbackRepository.stop()
} }
@@ -2,27 +2,38 @@ package fr.ajaury.gwenedeg.preferences.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import fr.ajaury.gwenedeg.resources.generated.resources.Res 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_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_description
import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_transcription_label 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_description
import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_translation_label import fr.ajaury.gwenedeg.resources.generated.resources.subtitle_translation_label
import fr.ajaury.gwenedeg.theme.ChomBevTheme import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource 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 * Bottom sheet letting the user toggle the display of the Breton transcription (BZH) and the
@@ -34,6 +45,8 @@ fun SubtitlePreferencesBottomSheet(
subtitlePreferences: SubtitlePreferences, subtitlePreferences: SubtitlePreferences,
onShowTranscriptionChange: (Boolean) -> Unit = {}, onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {}, onShowTranslationChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberModalBottomSheetState(),
) { ) {
@@ -45,6 +58,8 @@ fun SubtitlePreferencesBottomSheet(
subtitlePreferences = subtitlePreferences, subtitlePreferences = subtitlePreferences,
onShowTranscriptionChange = onShowTranscriptionChange, onShowTranscriptionChange = onShowTranscriptionChange,
onShowTranslationChange = onShowTranslationChange, onShowTranslationChange = onShowTranslationChange,
onTextSizeDecrease = onTextSizeDecrease,
onTextSizeIncrease = onTextSizeIncrease,
) )
} }
} }
@@ -54,6 +69,8 @@ private fun SubtitlePreferencesContent(
subtitlePreferences: SubtitlePreferences, subtitlePreferences: SubtitlePreferences,
onShowTranscriptionChange: (Boolean) -> Unit = {}, onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {}, onShowTranslationChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -78,9 +95,63 @@ private fun SubtitlePreferencesContent(
checked = subtitlePreferences.showTranslation, checked = subtitlePreferences.showTranslation,
onCheckedChange = onShowTranslationChange, 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 @Preview
@Composable @Composable
private fun SubtitlePreferencesContentPreview() { private fun SubtitlePreferencesContentPreview() {