new: add subtitle preferences

This commit is contained in:
2026-07-01 18:02:18 +02:00
parent c9b0b03ab9
commit 24d39d2a6f
11 changed files with 290 additions and 10 deletions
@@ -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<RecordRepository>() }
singleOf(::PlaybackRepositoryImpl) { bind<PlaybackRepository>() }
singleOf(::PreferencesRepositoryImpl) { bind<PreferencesRepository>() }
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
factoryOf(::ComposeResourceReader) { bind<ResourceReader>() }
@@ -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),
) {
@@ -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 },
)
}
}
}
@@ -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<SubtitleLine>,
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,
)
}
}
}
@@ -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(),
)
@@ -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<PlayerUiState> = 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()
}
@@ -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<SubtitlePreferences> = state.asStateFlow()
override fun setShowTranscription(enabled: Boolean) {
state.update { it.copy(showTranscription = enabled) }
}
override fun setShowTranslation(enabled: Boolean) {
state.update { it.copy(showTranslation = enabled) }
}
}
@@ -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<SubtitlePreferences>
fun setShowTranscription(enabled: Boolean)
fun setShowTranslation(enabled: Boolean)
}
@@ -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,
)
@@ -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,
)
}
}
@@ -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,
),
)
}
}