From 24d39d2a6f8f5359956b47d8ee112cdf7cbbb62e Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Wed, 1 Jul 2026 18:02:18 +0200 Subject: [PATCH] new: add subtitle preferences --- .../kotlin/fr/ajaury/gwenedeg/di/Modules.kt | 3 + .../gwenedeg/player/ui/PlayerControl.kt | 3 +- .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 29 ++++++ .../ajaury/gwenedeg/player/ui/SubtitleList.kt | 24 +++-- .../player/ui/viewmodel/PlayerUiState.kt | 2 + .../player/ui/viewmodel/PlayerViewModel.kt | 14 ++- .../data/PreferencesRepositoryImpl.kt | 21 +++++ .../domain/PreferencesRepository.kt | 12 +++ .../preferences/model/SubtitlePreferences.kt | 12 +++ .../preferences/ui/PreferenceToggle.kt | 92 +++++++++++++++++++ .../ui/SubtitlePreferencesBottomSheet.kt | 88 ++++++++++++++++++ 11 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/PreferenceToggle.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/SubtitlePreferencesBottomSheet.kt diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt index 4402e22..77436ef 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt @@ -4,6 +4,8 @@ import fr.ajaury.gwenedeg.core.logging.di.loggingModule import fr.ajaury.gwenedeg.player.data.PlaybackRepositoryImpl import fr.ajaury.gwenedeg.player.domain.PlaybackRepository import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel +import fr.ajaury.gwenedeg.preferences.data.PreferencesRepositoryImpl +import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository import fr.ajaury.gwenedeg.records.data.InMemoryRecordRepository import fr.ajaury.gwenedeg.records.domain.RecordRepository import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel @@ -19,6 +21,7 @@ val sharedModule = module { includes(loggingModule) factoryOf(::InMemoryRecordRepository) { bind() } singleOf(::PlaybackRepositoryImpl) { bind() } + singleOf(::PreferencesRepositoryImpl) { bind() } viewModelOf(::RecordsViewModel) viewModelOf(::PlayerViewModel) factoryOf(::ComposeResourceReader) { bind() } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt index c3c4f40..9dc3944 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt @@ -32,6 +32,7 @@ import fr.ajaury.gwenedeg.theme.GwenedegTheme @Composable fun PlayerControl( + modifier: Modifier = Modifier, playbackState: PlaybackState, playbackTiming: PlaybackTiming, onSeek: (Float) -> Unit = {}, @@ -40,7 +41,7 @@ fun PlayerControl( onNextSentenceClicked: () -> Unit = {}, ) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { 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 117e075..7b66abf 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -16,6 +17,9 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -28,6 +32,7 @@ import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.player.model.PlayerState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel +import fr.ajaury.gwenedeg.preferences.ui.SubtitlePreferencesBottomSheet import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine import fr.ajaury.gwenedeg.theme.GwenedegTheme import org.koin.compose.viewmodel.koinViewModel @@ -57,6 +62,8 @@ fun PlayerScreen( onSeekToSentence = viewModel::seekToSentence, onPreviousSentenceClicked = viewModel::goToPreviousSentence, onNextSentenceClicked = viewModel::goToNextSentence, + onShowTranscriptionChange = viewModel::setShowTranscription, + onShowTranslationChange = viewModel::setShowTranslation, onBackClicked = onBackClicked, ) } @@ -70,8 +77,12 @@ fun PlayerScreen( onSeekToSentence: (position: Int) -> Unit = {}, onPreviousSentenceClicked: () -> Unit = {}, onNextSentenceClicked: () -> Unit = {}, + onShowTranscriptionChange: (Boolean) -> Unit = {}, + onShowTranslationChange: (Boolean) -> Unit = {}, onBackClicked: () -> Unit = {}, ) { + var showPreferences by remember { mutableStateOf(false) } + Scaffold( topBar = { TopAppBar( @@ -102,6 +113,14 @@ fun PlayerScreen( ) } }, + actions = { + IconButton(onClick = { showPreferences = true }) { + Icon( + imageVector = Icons.Filled.Subtitles, + contentDescription = "Préférences de sous-titrage", + ) + } + }, ) }, ) { innerPadding -> @@ -115,6 +134,7 @@ fun PlayerScreen( SubtitleList( lines = uiState.playerState.subtitleLines, currentIndex = uiState.playerState.currentSubtitleIndex, + subtitlePreferences = uiState.subtitlePreferences, onSeek = onSeekToSentence, modifier = Modifier .weight(1f) @@ -130,6 +150,15 @@ fun PlayerScreen( onNextSentenceClicked = onNextSentenceClicked, ) } + + if (showPreferences) { + SubtitlePreferencesBottomSheet( + subtitlePreferences = uiState.subtitlePreferences, + onShowTranscriptionChange = onShowTranscriptionChange, + onShowTranslationChange = onShowTranslationChange, + 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 cc91e4c..98dee03 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import fr.ajaury.gwenedeg.core.model.Phrase +import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine import fr.ajaury.gwenedeg.theme.GwenedegTheme import kotlin.math.abs @@ -38,6 +39,7 @@ fun SubtitleList( modifier: Modifier = Modifier, lines: List, currentIndex: Int?, + subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), onSeek: (position: Int) -> Unit = {}, ) { val listState = rememberLazyListState() @@ -98,6 +100,7 @@ fun SubtitleList( Subtitle( line = line, isCurrent = isCurrent, + subtitlePreferences = subtitlePreferences, ) } } @@ -121,6 +124,7 @@ private suspend fun LazyListState.centerItem(index: Int) { private fun Subtitle( line: SubtitleLine, isCurrent: Boolean, + subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), ) { val contentColor = if (isCurrent) { Color.Unspecified @@ -133,18 +137,22 @@ private fun Subtitle( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp), ) { - TranscriptionLine( - text = line.phrase.transcription, - isCurrent = isCurrent, - contentColor = contentColor, - ) - line.phrase.translation?.let { translation -> - TranslationLine( - text = translation, + if (subtitlePreferences.showTranscription) { + TranscriptionLine( + text = line.phrase.transcription, isCurrent = isCurrent, contentColor = contentColor, ) } + line.phrase.translation + ?.takeIf { subtitlePreferences.showTranslation } + ?.let { translation -> + TranslationLine( + text = translation, + isCurrent = isCurrent, + contentColor = contentColor, + ) + } } } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt index c6fdf37..dc1074e 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt @@ -2,8 +2,10 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel import fr.ajaury.gwenedeg.core.model.Phrase import fr.ajaury.gwenedeg.player.model.PlayerState +import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences data class PlayerUiState( val recordTitle: Phrase = Phrase(transcription = ""), val playerState: PlayerState = PlayerState(), + val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), ) 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 049caa5..9b250ad 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 @@ -8,6 +8,7 @@ import fr.ajaury.gwenedeg.core.model.Record import fr.ajaury.gwenedeg.player.domain.AudioSessionManager 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.records.domain.RecordRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -23,6 +24,7 @@ class PlayerViewModel( private val recordId: Int, private val recordRepository: RecordRepository, private val playbackRepository: PlaybackRepository, + private val preferencesRepository: PreferencesRepository, private val audioSessionManager: AudioSessionManager, private val logger: Logger, ) : ViewModel() { @@ -31,10 +33,12 @@ class PlayerViewModel( val uiState: StateFlow = combine( record, playbackRepository.playerState, - ) { record, playerState -> + preferencesRepository.subtitlePreferences, + ) { record, playerState, subtitlePreferences -> PlayerUiState( recordTitle = record?.title ?: Phrase(transcription = ""), playerState = playerState, + subtitlePreferences = subtitlePreferences, ) }.stateIn( scope = viewModelScope, @@ -93,6 +97,14 @@ class PlayerViewModel( playbackRepository.goToNextSentence() } + fun setShowTranscription(enabled: Boolean) { + preferencesRepository.setShowTranscription(enabled) + } + + fun setShowTranslation(enabled: Boolean) { + preferencesRepository.setShowTranslation(enabled) + } + fun stop() { playbackRepository.stop() } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt new file mode 100644 index 0000000..3c7a48e --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt @@ -0,0 +1,21 @@ +package fr.ajaury.gwenedeg.preferences.data + +import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository +import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +internal class PreferencesRepositoryImpl : PreferencesRepository { + private val state = MutableStateFlow(SubtitlePreferences()) + override val subtitlePreferences: StateFlow = state.asStateFlow() + + override fun setShowTranscription(enabled: Boolean) { + state.update { it.copy(showTranscription = enabled) } + } + + override fun setShowTranslation(enabled: Boolean) { + state.update { it.copy(showTranslation = enabled) } + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt new file mode 100644 index 0000000..52ad041 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt @@ -0,0 +1,12 @@ +package fr.ajaury.gwenedeg.preferences.domain + +import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences +import kotlinx.coroutines.flow.StateFlow + +interface PreferencesRepository { + val subtitlePreferences: StateFlow + + fun setShowTranscription(enabled: Boolean) + + fun setShowTranslation(enabled: Boolean) +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt new file mode 100644 index 0000000..5785d2a --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/SubtitlePreferences.kt @@ -0,0 +1,12 @@ +package fr.ajaury.gwenedeg.preferences.model + +/** + * User preferences controlling which subtitle lines are displayed on the player. + * + * @property showTranscription whether the Breton (BZH) transcription line is shown. + * @property showTranslation whether the French (FR) translation line is shown. + */ +data class SubtitlePreferences( + val showTranscription: Boolean = true, + val showTranslation: Boolean = true, +) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/PreferenceToggle.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/PreferenceToggle.kt new file mode 100644 index 0000000..e3fc1b9 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/PreferenceToggle.kt @@ -0,0 +1,92 @@ +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.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences +import fr.ajaury.gwenedeg.theme.GwenedegTheme + + +@Composable +fun PreferenceToggle( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit = {}, + icon: ImageVector? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon(imageVector = icon, contentDescription = null) + } + + Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Switch( + checked = checked, + onCheckedChange = null, + ) + } +} + +@Preview +@Composable +private fun PreferenceTogglePreview() { + GwenedegTheme { + PreferenceToggle( + title = "Subtitle", + subtitle = "Subtitle", + checked = true, + icon = null, + ) + } +} + +@Preview +@Composable +private fun PreferenceToggleWithIconPreview() { + GwenedegTheme { + PreferenceToggle( + title = "Subtitle", + subtitle = "Subtitle", + checked = true, + icon = Icons.Default.Subtitles, + ) + } +} 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 new file mode 100644 index 0000000..92b3104 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/ui/SubtitlePreferencesBottomSheet.kt @@ -0,0 +1,88 @@ +package fr.ajaury.gwenedeg.preferences.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +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.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.theme.GwenedegTheme + +/** + * Bottom sheet letting the user toggle the display of the Breton transcription (BZH) and the + * French translation (FR) on the player. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubtitlePreferencesBottomSheet( + subtitlePreferences: SubtitlePreferences, + onShowTranscriptionChange: (Boolean) -> Unit = {}, + onShowTranslationChange: (Boolean) -> Unit = {}, + onDismiss: () -> Unit = {}, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + SubtitlePreferencesContent( + subtitlePreferences = subtitlePreferences, + onShowTranscriptionChange = onShowTranscriptionChange, + onShowTranslationChange = onShowTranslationChange, + ) + } +} + +@Composable +private fun SubtitlePreferencesContent( + subtitlePreferences: SubtitlePreferences, + onShowTranscriptionChange: (Boolean) -> Unit = {}, + onShowTranslationChange: (Boolean) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Sous-titres", + style = MaterialTheme.typography.titleMedium, + ) + PreferenceToggle( + title = "Transcription (BZH)", + subtitle = "Afficher la transcription bretonne", + checked = subtitlePreferences.showTranscription, + onCheckedChange = onShowTranscriptionChange, + ) + PreferenceToggle( + title = "Traduction (FR)", + subtitle = "Afficher la traduction française", + checked = subtitlePreferences.showTranslation, + onCheckedChange = onShowTranslationChange, + ) + } +} + +@Preview +@Composable +private fun SubtitlePreferencesContentPreview() { + GwenedegTheme { + SubtitlePreferencesContent( + subtitlePreferences = SubtitlePreferences( + showTranscription = true, + showTranslation = false, + ), + ) + } +}