new: extract Records feature module

This commit is contained in:
2026-07-08 14:44:55 +02:00
parent 0151d92b6a
commit 6a10c0c510
33 changed files with 138 additions and 81 deletions
+1 -5
View File
@@ -33,19 +33,15 @@ kotlin {
}
commonMain.dependencies {
// Core modules
implementation(projects.core.audioplayer)
implementation(projects.core.coroutines)
implementation(projects.core.logging)
implementation(projects.core.model)
// Data modules
implementation(projects.data.playback)
implementation(projects.data.preferences)
implementation(projects.data.records)
implementation(projects.data.resources)
implementation(projects.data.subtitle)
// Feature modules
implementation(projects.feature.records)
implementation(projects.feature.webview)
implementation(libs.compose.runtime)
@@ -17,10 +17,8 @@ import bzh.ajaury.chombev.navigation.MainBottomBar
import bzh.ajaury.chombev.navigation.MainTopBar
import bzh.ajaury.chombev.navigation.Route
import bzh.ajaury.chombev.navigation.WebPages
import bzh.ajaury.chombev.player.di.audioPlayerModule
import bzh.ajaury.chombev.player.ui.PlayerScreen
import bzh.ajaury.chombev.records.ui.RecordsScreen
import bzh.ajaury.chombev.subtitle.di.subtitleModule
import bzh.ajaury.chombev.theme.ChomBevTheme
import bzh.ajaury.chombev.web.model.WebPage
import bzh.ajaury.chombev.web.ui.WebViewScreen
@@ -32,9 +30,7 @@ val appModule = module {
includes(
loggingModule,
sharedModule,
audioPlayerModule,
coroutinesModule,
subtitleModule,
)
}
@@ -1,23 +1,12 @@
package bzh.ajaury.chombev.di
import bzh.ajaury.chombev.core.logging.di.loggingModule
import bzh.ajaury.chombev.player.di.playbackModule
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
import bzh.ajaury.chombev.preferences.di.preferencesModule
import bzh.ajaury.chombev.records.di.recordsModule
import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel
import bzh.ajaury.chombev.resources.di.resourcesModule
import bzh.ajaury.chombev.records.ui.di.recordsFeatureModule
import bzh.ajaury.chombev.web.di.webviewModule
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val sharedModule = module {
includes(loggingModule)
includes(playbackModule)
includes(preferencesModule)
includes(recordsModule)
includes(resourcesModule)
includes(recordsFeatureModule)
includes(webviewModule)
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
}
@@ -1,111 +0,0 @@
package bzh.ajaury.chombev.player.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.ModalBottomSheet
import androidx.compose.material3.SheetState
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 bzh.ajaury.chombev.player.ui.components.LabeledSlider
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_title
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
/**
* Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent
* pause inserted between phrases.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaybackSpeedBottomSheet(
speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(),
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
PlaybackSpeedContent(
speed = speed,
breakSeconds = breakSeconds,
onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
)
}
}
@Composable
private fun PlaybackSpeedContent(
speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
) {
// Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4).
val speedSteps = with(PlaybackPreferences) {
(((MAX_SPEED - MIN_SPEED) / SPEED_STEP).roundToInt() - 1).coerceAtLeast(0)
}
val maxBreakSeconds = PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds
val breakStepSeconds = PlaybackPreferences.BREAK_STEP.inWholeSeconds
val breakSteps = ((maxBreakSeconds / breakStepSeconds).toInt() - 1).coerceAtLeast(0)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
LabeledSlider(
title = stringResource(Res.string.playback_speed_title),
valueLabel = formatPlaybackSpeed(speed),
value = speed,
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
steps = speedSteps,
onValueChange = onSpeedChange,
)
LabeledSlider(
title = stringResource(Res.string.break_between_phrases_title),
valueLabel = stringResource(
Res.string.break_between_phrases_value,
breakSeconds.roundToInt().toString(),
),
value = breakSeconds,
valueRange = 0f..maxBreakSeconds.toFloat(),
steps = breakSteps,
onValueChange = onBreakChange,
)
}
}
/** Formats a speed multiplier as a short label, e.g. 1.0 → "x1.0", 0.7 → "x0.7". */
internal fun formatPlaybackSpeed(speed: Float): String {
val tenths = (speed * 10).roundToInt()
return "x${tenths / 10}.${tenths % 10}"
}
@Preview
@Composable
private fun PlaybackSpeedContentPreview() {
ChomBevTheme {
PlaybackSpeedContent(
speed = 0.7f,
breakSeconds = 2f,
)
}
}
@@ -1,151 +0,0 @@
package bzh.ajaury.chombev.player.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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 bzh.ajaury.chombev.player.model.PlaybackState
import bzh.ajaury.chombev.player.model.PlaybackTiming
import bzh.ajaury.chombev.player.ui.components.NextButton
import bzh.ajaury.chombev.player.ui.components.PlayButton
import bzh.ajaury.chombev.player.ui.components.PlaybackProgress
import bzh.ajaury.chombev.player.ui.components.PreviousButton
import bzh.ajaury.chombev.player.ui.components.SpeedPreferenceButton
import bzh.ajaury.chombev.theme.ChomBevTheme
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlayerControl(
modifier: Modifier = Modifier,
playbackState: PlaybackState,
playbackTiming: PlaybackTiming,
breakBetweenPhrases: Duration,
canGoToPreviousSentence: Boolean,
canGoToNextSentence: Boolean,
isPlaybackModified: Boolean,
onSeek: (Duration) -> Unit = {},
onMainPlayActionButtonClicked: () -> Unit = {},
onPreviousSentenceClicked: () -> Unit = {},
onNextSentenceClicked: () -> Unit = {},
onSpeedClicked: () -> Unit = {},
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PlaybackProgress(
playbackTiming = playbackTiming,
onSeek = onSeek,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// Left region: the speed button is centered in the space left of the transport group.
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
SpeedPreferenceButton(
isActive = isPlaybackModified,
onSpeedClicked = onSpeedClicked,
)
}
// Transport group: centered in the full width by the equal-weight cells on either side.
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PreviousButton(
canGoToPreviousSentence = canGoToPreviousSentence,
onPreviousSentenceClicked = onPreviousSentenceClicked,
)
PlayButton(
playbackState = playbackState,
breakDuration = breakBetweenPhrases,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
)
NextButton(
canGoToNextSentence = canGoToNextSentence,
onNextSentenceClicked = onNextSentenceClicked,
)
}
// Right region: mirrors the left weight so the transport group stays centered.
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Preview
@Composable
private fun PlayerControlPreview() {
ChomBevTheme {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = true,
isPlaybackModified = false,
)
}
}
@Preview
@Composable
private fun PlayerControlFirstSentencePreview() {
ChomBevTheme {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = false,
canGoToNextSentence = true,
isPlaybackModified = false,
)
}
}
@Preview
@Composable
private fun PlayerControlActiveSpeedPreview() {
ChomBevTheme {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = true,
isPlaybackModified = true,
)
}
}
@Preview
@Composable
private fun PlayerControlLastSentencePreview() {
ChomBevTheme {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = false,
isPlaybackModified = false,
)
}
}
@@ -1,312 +0,0 @@
package bzh.ajaury.chombev.player.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.player.model.PlaybackState
import bzh.ajaury.chombev.player.model.PlaybackTiming
import bzh.ajaury.chombev.player.model.PlayerState
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerUiState
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
import bzh.ajaury.chombev.preferences.ui.SubtitlePreferencesBottomSheet
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.player_back
import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_action
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlayerScreen(
recordId: Int,
onBackClicked: () -> Unit = {},
viewModel: PlayerViewModel = koinViewModel(key = "$recordId") { parametersOf(recordId) },
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DisposableEffect(Unit) {
viewModel.loadRecord()
onDispose {
viewModel.stop()
}
}
PlayerScreen(
uiState = uiState,
onMainPlayActionButtonClicked = viewModel::performMainPlayAction,
onSeekToTimePart = viewModel::seekToTimePart,
onSeekToSentence = viewModel::seekToSentence,
onPreviousSentenceClicked = viewModel::goToPreviousSentence,
onNextSentenceClicked = viewModel::goToNextSentence,
onShowTranscriptionChange = viewModel::setShowTranscription,
onShowTranslationChange = viewModel::setShowTranslation,
onShowPhoneticMarkerChange = viewModel::setShowPhoneticMarkers,
onTextSizeDecrease = viewModel::decreaseSubtitleTextSize,
onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed,
onBreakChange = viewModel::setBreakBetweenPhrases,
onBackClicked = onBackClicked,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayerScreen(
uiState: PlayerUiState,
onMainPlayActionButtonClicked: () -> Unit = {},
onSeekToTimePart: (progress: Duration) -> Unit = {},
onSeekToSentence: (position: Int) -> Unit = {},
onPreviousSentenceClicked: () -> Unit = {},
onNextSentenceClicked: () -> Unit = {},
onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {},
onShowPhoneticMarkerChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onBackClicked: () -> Unit = {},
) {
var showPreferences by remember { mutableStateOf(false) }
var showSpeed by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = uiState.recordEmoji,
style = MaterialTheme.typography.headlineSmall,
)
Column {
Text(
text = uiState.recordTitle.transcription,
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
uiState.recordTitle.translation?.let { translation ->
Text(
text = translation,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.player_back),
)
}
},
actions = {
IconButton(onClick = { showPreferences = true }) {
Icon(
imageVector = Icons.Filled.Subtitles,
contentDescription = stringResource(Res.string.subtitle_preferences_action),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(vertical = 16.dp)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SubtitleList(
lines = uiState.playerState.subtitleLines,
currentIndex = uiState.playerState.currentSubtitleIndex,
subtitlePreferences = uiState.subtitlePreferences,
onSeek = onSeekToSentence,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
PlayerControl(
modifier = Modifier.padding(horizontal = 8.dp),
playbackState = uiState.playerState.playbackState,
playbackTiming = uiState.playerState.playbackTiming,
breakBetweenPhrases = uiState.breakBetweenPhrases,
canGoToPreviousSentence = uiState.canGoToPreviousSentence,
canGoToNextSentence = uiState.canGoToNextSentence,
isPlaybackModified = uiState.isPlaybackModified,
onSeek = onSeekToTimePart,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
onPreviousSentenceClicked = onPreviousSentenceClicked,
onNextSentenceClicked = onNextSentenceClicked,
onSpeedClicked = { showSpeed = true },
)
}
if (showPreferences) {
SubtitlePreferencesBottomSheet(
subtitlePreferences = uiState.subtitlePreferences,
onShowTranscriptionChange = onShowTranscriptionChange,
onShowTranslationChange = onShowTranslationChange,
onShowPhoneticMarkerChange = onShowPhoneticMarkerChange,
onTextSizeDecrease = onTextSizeDecrease,
onTextSizeIncrease = onTextSizeIncrease,
onDismiss = { showPreferences = false },
)
}
if (showSpeed) {
PlaybackSpeedBottomSheet(
speed = uiState.playbackSpeed,
breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(),
onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
onDismiss = { showSpeed = false },
)
}
}
}
private val previewSubtitleLines =
listOf(
SubtitleLine(
startTime = 0.milliseconds,
phrase = Phrase(
transcription = "Demat deoc'h !",
translation = "Bonjour !",
),
),
SubtitleLine(
startTime = 4310.milliseconds,
phrase = Phrase(
transcription = "Kenavo !",
translation = "Au revoir !",
),
),
SubtitleLine(
startTime = 7350.milliseconds,
phrase = Phrase(
transcription = "Kenavo emberr !",
translation = "À ce soir !",
),
),
)
@Preview
@Composable
private fun PlayerScreenPlayingPreview() {
ChomBevTheme {
PlayerScreen(
uiState = PlayerUiState(
recordEmoji = "🙏",
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 1,
),
),
onMainPlayActionButtonClicked = {},
onSeekToSentence = {},
onBackClicked = {},
)
}
}
@Preview
@Composable
private fun PlayerScreenPausedPreview() {
ChomBevTheme {
PlayerScreen(
uiState = PlayerUiState(
recordEmoji = "🙏",
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState(
playbackState = PlaybackState.PAUSED,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 1,
),
),
onMainPlayActionButtonClicked = {},
onSeekToSentence = {},
onBackClicked = {},
)
}
}
@Preview
@Composable
private fun PlayerScreenEndedPreview() {
ChomBevTheme {
PlayerScreen(
uiState = PlayerUiState(
recordEmoji = "🙏",
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState(
playbackState = PlaybackState.ENDED,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 2,
),
),
onMainPlayActionButtonClicked = {},
onSeekToSentence = {},
onBackClicked = {},
)
}
}
@@ -1,259 +0,0 @@
package bzh.ajaury.chombev.player.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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
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.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 bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.player.ui.components.TranscriptionText
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
import bzh.ajaury.chombev.theme.ChomBevTheme
import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun SubtitleList(
modifier: Modifier = Modifier,
lines: List<SubtitleLine>,
currentIndex: Int?,
subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
onSeek: (position: Int) -> Unit = {},
) {
val listState = rememberLazyListState()
val isDragged by listState.interactionSource.collectIsDraggedAsState()
var targetIndex by remember { mutableStateOf(0) }
var isDragging by remember { mutableStateOf(false) }
// Detect beginning of dragging.
LaunchedEffect(isDragged) {
if (isDragged) {
isDragging = true
}
}
val centeredIndex by remember(currentIndex, isDragged) {
derivedStateOf {
if (isDragging) {
listState.getCenteredIndex().also {
targetIndex = it
}
} else {
currentIndex ?: -1
}
}
}
// Auto-follow: re-center the playing line when it changes, unless the user is scrolling.
LaunchedEffect(currentIndex) {
if (currentIndex != null && !listState.isScrollInProgress) {
listState.centerItem(currentIndex)
}
}
// Snap to the centered line and seek to it once the user finishes scrolling.
LaunchedEffect(isDragged) {
if (isDragging && !isDragged) {
if (targetIndex in lines.indices) {
listState.centerItem(targetIndex)
onSeek(targetIndex)
}
isDragging = false
}
}
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier) {
// 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,
),
) {
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) },
)
}
}
}
}
}
private fun LazyListState.getCenteredIndex(): Int {
val info = this.layoutInfo
return if (info.visibleItemsInfo.isEmpty()) {
-1
} else {
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f
info.visibleItemsInfo
.minByOrNull {
abs((it.offset + it.size / 2f) - viewportCenter)
}?.index
?: -1
}
}
private suspend fun LazyListState.centerItem(index: Int) {
if (index < 0 || index >= this.layoutInfo.totalItemsCount) return
val info = this.layoutInfo
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2
val item = info.visibleItemsInfo.firstOrNull { it.index == index }
if (item != null) {
this.animateScrollBy((item.offset + item.size / 2 - viewportCenter).toFloat())
} else {
this.animateScrollToItem(index)
}
}
@Composable
private fun Subtitle(
line: SubtitleLine,
isCurrent: Boolean,
subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
onClick: () -> Unit = {},
) {
val textAlpha = if (isCurrent) 1f else 0.35f
val contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha)
Column(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (subtitlePreferences.showTranscription) {
TranscriptionLine(
text = line.phrase.transcription,
contentColor = contentColor,
showPhoneticMarkers = subtitlePreferences.showPhoneticMarkers,
)
}
line.phrase.translation
?.takeIf { subtitlePreferences.showTranslation }
?.let { translation ->
TranslationLine(
text = translation,
contentColor = contentColor,
)
}
}
}
@Composable
private fun TranscriptionLine(
text: String,
contentColor: Color,
showPhoneticMarkers: Boolean = true,
) {
TranscriptionText(
text = text,
color = contentColor,
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp,
lineHeight = 32.sp,
),
textAlign = TextAlign.Center,
showPhoneticMarkers = showPhoneticMarkers,
)
}
@Composable
private fun TranslationLine(
text: String,
contentColor: Color,
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
color = contentColor,
textAlign = TextAlign.Center,
)
}
private val previewSubtitleLines =
listOf(
SubtitleLine(
startTime = 0.milliseconds,
phrase = Phrase(
transcription = "Demat deoc'h !",
translation = "Bonjour !",
),
),
SubtitleLine(
startTime = 4310.milliseconds,
phrase = Phrase(
transcription = "Kenavo !",
translation = "Au revoir !",
),
),
SubtitleLine(
startTime = 7350.milliseconds,
phrase = Phrase(
transcription = "Tomm eo ma(n) divskouarn",
translation = "J'ai chaud aux oreilles",
),
),
)
@Preview
@Composable
private fun SubtitleListPreview() {
ChomBevTheme {
SubtitleList(
lines = previewSubtitleLines,
currentIndex = 1,
)
}
}
@@ -1,63 +0,0 @@
package bzh.ajaury.chombev.player.ui.components
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.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
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 bzh.ajaury.chombev.theme.ChomBevTheme
@Composable
fun LabeledSlider(
title: String,
valueLabel: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
onValueChange: (Float) -> Unit = {},
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = valueLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
)
}
}
@Preview
@Composable
private fun LabeledSliderPreview() {
ChomBevTheme {
LabeledSlider(
title = "Playback speed",
valueLabel = "x0.7",
value = 0.7f,
valueRange = 0.5f..2f,
steps = 10,
)
}
}
@@ -1,52 +0,0 @@
package bzh.ajaury.chombev.player.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
@Composable
fun NextButton(
canGoToNextSentence: Boolean,
onNextSentenceClicked: () -> Unit = {},
) {
IconButton(
modifier = Modifier.size(60.dp),
enabled = canGoToNextSentence,
onClick = onNextSentenceClicked,
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = stringResource(Res.string.player_next_sentence),
)
}
}
@Preview
@Composable
private fun NextButtonCanGoToNextSentencePreview() {
ChomBevTheme {
NextButton(
canGoToNextSentence = true,
)
}
}
@Preview
@Composable
private fun NextButtonCanNotGoToNextSentencePreview() {
ChomBevTheme {
NextButton(
canGoToNextSentence = false,
)
}
}
@@ -1,152 +0,0 @@
package bzh.ajaury.chombev.player.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.player.model.PlaybackState
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.player_pause
import bzh.ajaury.chombev.resources.generated.resources.player_play
import bzh.ajaury.chombev.resources.generated.resources.player_replay
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private val ButtonSize = 60.dp
private val BreakRingSize = 68.dp
@Composable
fun PlayButton(
playbackState: PlaybackState,
breakDuration: Duration = Duration.ZERO,
onMainPlayActionButtonClicked: () -> Unit = {},
) {
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
val breakProgress = remember { Animatable(0f) }
LaunchedEffect(isBreaking, breakDuration) {
if (isBreaking) {
breakProgress.snapTo(0f)
breakProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = breakDuration.inWholeMilliseconds.toInt(),
easing = LinearEasing,
),
)
} else {
breakProgress.snapTo(0f)
}
}
// The ring reserves its footprint at all times so the transport row does not jump when a break
// starts; the indicator itself is only drawn while breaking.
Box(
modifier = Modifier.size(BreakRingSize),
contentAlignment = Alignment.Center,
) {
if (isBreaking) {
CircularProgressIndicator(
progress = { breakProgress.value },
modifier = Modifier.size(BreakRingSize),
strokeWidth = 3.dp,
)
}
Button(
modifier = Modifier.size(ButtonSize),
contentPadding = PaddingValues(0.dp),
onClick = onMainPlayActionButtonClicked,
) {
Icon(
imageVector = playbackState.toActionIcon(),
contentDescription = playbackState.toActionContentDescription(),
)
}
}
}
private fun PlaybackState.toActionIcon(): ImageVector =
when (this) {
PlaybackState.PLAYING, PlaybackState.BREAKING -> Icons.Default.Pause
PlaybackState.ENDED -> Icons.Default.Replay
PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow
}
@Composable
private fun PlaybackState.toActionContentDescription(): String =
when (this) {
PlaybackState.PLAYING, PlaybackState.BREAKING -> stringResource(Res.string.player_pause)
PlaybackState.ENDED -> stringResource(Res.string.player_replay)
PlaybackState.PAUSED, PlaybackState.IDLE -> stringResource(Res.string.player_play)
}
@Preview
@Composable
private fun PlayButtonPlayingPreview() {
ChomBevTheme {
PlayButton(
playbackState = PlaybackState.PLAYING,
)
}
}
@Preview
@Composable
private fun PlayButtonBreakingPreview() {
ChomBevTheme {
PlayButton(
playbackState = PlaybackState.BREAKING,
breakDuration = 3.seconds,
)
}
}
@Preview
@Composable
private fun PlayButtonPausedPreview() {
ChomBevTheme {
PlayButton(
playbackState = PlaybackState.PAUSED,
)
}
}
@Preview
@Composable
private fun PlayButtonEndedPreview() {
ChomBevTheme {
PlayButton(
playbackState = PlaybackState.ENDED,
)
}
}
@Preview
@Composable
private fun PlayButtonIdlePreview() {
ChomBevTheme {
PlayButton(
playbackState = PlaybackState.IDLE,
)
}
}
@@ -1,68 +0,0 @@
package bzh.ajaury.chombev.player.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import bzh.ajaury.chombev.player.model.PlaybackTiming
import bzh.ajaury.chombev.theme.ChomBevTheme
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackProgress(
playbackTiming: PlaybackTiming,
onSeek: (Duration) -> Unit = {},
) {
var seekProgress by remember { mutableStateOf<Float?>(null) }
Slider(
value = seekProgress ?: playbackTiming.position.inWholeMilliseconds.toFloat(),
valueRange = 0f..playbackTiming.duration.inWholeMilliseconds.toFloat(),
onValueChange = { seekProgress = it },
onValueChangeFinished = {
seekProgress?.let { fraction ->
if (playbackTiming.duration.isPositive()) {
onSeek(fraction.toInt().milliseconds)
}
}
seekProgress = null
},
modifier = Modifier.fillMaxWidth(),
)
}
@Preview
@Composable
private fun PlaybackProgressPreview() {
ChomBevTheme {
PlaybackProgress(
playbackTiming = PlaybackTiming(0.seconds, 0.seconds),
)
}
}
@Preview
@Composable
private fun PlaybackProgressInProgressPreview() {
ChomBevTheme {
PlaybackProgress(
playbackTiming = PlaybackTiming(7.seconds, 10.seconds),
)
}
}
@Preview
@Composable
private fun PlaybackProgressEndedPreview() {
ChomBevTheme {
PlaybackProgress(
playbackTiming = PlaybackTiming(10.seconds, 10.seconds),
)
}
}
@@ -1,52 +0,0 @@
package bzh.ajaury.chombev.player.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
@Composable
fun PreviousButton(
canGoToPreviousSentence: Boolean,
onPreviousSentenceClicked: () -> Unit = {},
) {
IconButton(
modifier = Modifier.size(60.dp),
enabled = canGoToPreviousSentence,
onClick = onPreviousSentenceClicked,
) {
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = stringResource(Res.string.player_previous_sentence),
)
}
}
@Preview
@Composable
private fun PreviousButtonCanGoToPreviousSentencePreview() {
ChomBevTheme {
PreviousButton(
canGoToPreviousSentence = true,
)
}
}
@Preview
@Composable
private fun PreviousButtonCanNotGoToPreviousSentencePreview() {
ChomBevTheme {
PreviousButton(
canGoToPreviousSentence = false,
)
}
}
@@ -1,58 +0,0 @@
package bzh.ajaury.chombev.player.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.playback_settings_action
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
/**
* Opens the playback settings sheet. Tinted with the primary color when a playback setting (speed or
* break between phrases) differs from its default.
*/
@Composable
fun SpeedPreferenceButton(
isActive: Boolean,
onSpeedClicked: () -> Unit = {},
) {
IconButton(
modifier = Modifier.size(60.dp),
onClick = onSpeedClicked,
) {
Icon(
imageVector = Icons.Default.Speed,
contentDescription = stringResource(Res.string.playback_settings_action),
tint = if (isActive) MaterialTheme.colorScheme.primary else LocalContentColor.current,
)
}
}
@Preview
@Composable
private fun SpeedPreferenceButtonDefaultPreview() {
ChomBevTheme {
SpeedPreferenceButton(
isActive = false,
)
}
}
@Preview
@Composable
private fun SpeedPreferenceButtonActivePreview() {
ChomBevTheme {
SpeedPreferenceButton(
isActive = true,
)
}
}
@@ -1,277 +0,0 @@
package bzh.ajaury.chombev.player.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import bzh.ajaury.chombev.theme.ChomBevTheme
/**
* Matches a single letter marked in a transcription, either:
* - between parentheses, e.g. the `n` in `ma(n)` (a muted consonant), captured in group 1, or
* - between square brackets, e.g. the `t` in `[t]din` (a linking consonant), captured in
* group 2.
*
* The captured group is the bare letter, without its surrounding delimiters.
*/
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 = "ø"
/**
* Zero-width, no-break character placed on both sides of a marked letter so that line wrapping never
* separates it from the word it is glued to — breaks still happen at spaces, exactly as for words.
*/
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
* 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(
text: String,
color: Color,
style: TextStyle,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Center,
showPhoneticMarkers: Boolean = true,
) {
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 = plainText,
color = color,
style = style,
textAlign = textAlign,
modifier = modifier,
)
return
}
// Muted grey that still follows the surrounding line's dimming (current vs. inactive line).
val markColor = color.copy(alpha = color.alpha * 0.7f)
val annotatedText = buildAnnotatedString {
var cursor = 0
matches.forEachIndexed { index, match ->
append(text.substring(cursor, match.range.first))
// Glue the marked letter to its neighbouring characters so a line never breaks onto it.
append(WORD_JOINER)
appendInlineContent(id = inlineId(index), alternateText = match.letter)
append(WORD_JOINER)
cursor = match.range.last + 1
}
append(text.substring(cursor))
}
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val inlineContent = matches
.mapIndexed { index, match ->
val letter = match.letter
// Reserve exactly the letter's advance width (in em, so it is font-scale independent); the
// taller placeholder leaves room for the mark. Aligning to the text bottom keeps the
// baseline in step with the rest of the line.
val letterWidthEm = remember(letter, style) {
val letterWidthPx = textMeasurer.measure(text = letter, style = style).size.width
val fontSizePx = with(density) { style.fontSize.toPx() }
letterWidthPx / fontSizePx
}
inlineId(index) to InlineTextContent(
placeholder = Placeholder(
width = letterWidthEm.em,
height = 1.7.em,
placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom,
),
) {
if (match.isBracketed) {
LiaisonLetter(
letter = letter,
color = markColor,
style = style,
)
} else {
MutedLetter(
letter = letter,
color = markColor,
style = style,
)
}
}
}.toMap()
Text(
text = annotatedText,
color = color,
style = style,
textAlign = textAlign,
inlineContent = inlineContent,
modifier = modifier,
)
}
@Composable
private fun MutedLetter(
letter: String,
color: Color,
style: TextStyle,
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = MUTED_MARK,
color = color,
style = style.copy(
fontSize = style.fontSize * 0.75f,
lineHeight = style.fontSize * 0.75f,
),
modifier = Modifier.align(Alignment.TopCenter),
)
Text(
text = letter,
color = color,
// Tight line height so a bottom glyph's baseline lands where the surrounding line expects it.
style = style.copy(fontSize = style.fontSize, lineHeight = style.fontSize),
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@Composable
private fun LiaisonLetter(
letter: String,
color: Color,
style: TextStyle,
) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 1.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = letter,
color = color,
letterSpacing = 1.sp,
// Tight line height so a bottom glyph's baseline lands where the surrounding line expects it.
style = style.copy(
fontSize = style.fontSize * 0.75f,
lineHeight = style.fontSize * 0.75f,
),
)
Canvas(
modifier = Modifier
.width(100.dp)
.height(3.dp),
) {
drawArc(
color = color,
startAngle = 180f,
sweepAngle = 180f,
useCenter = false,
style = Stroke(width = 2f),
)
}
}
}
private val MatchResult.letter: String
get() = groupValues[1].ifEmpty { groupValues[2] }
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)
@Composable
private fun TranscriptionTextLiaisonPreview() {
ChomBevTheme {
TranscriptionText(
text = "Tomm eo ma(n) divskouarn, penn ma fri",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
)
}
}
@Preview(backgroundColor = 0xFFFFFFFF)
@Composable
private fun TranscriptionTextBracketPreview() {
ChomBevTheme {
TranscriptionText(
text = "Lârit[t] din, mar plij[k] geneoc'h.",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
)
}
}
@Preview(backgroundColor = 0xFFFFFFFF)
@Composable
private fun LiaisonLetterPreview() {
ChomBevTheme {
LiaisonLetter(
letter = "D",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
)
}
}
@@ -1,28 +0,0 @@
package bzh.ajaury.chombev.player.ui.viewmodel
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.player.model.PlayerState
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class PlayerUiState(
val recordEmoji: String = "",
val recordTitle: Phrase = Phrase(transcription = ""),
val playerState: PlayerState = PlayerState(),
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
) {
val canGoToPreviousSentence: Boolean
get() = playerState.playbackTiming.position > 2.seconds
val canGoToNextSentence: Boolean
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
/** True when either playback setting differs from its default, used to highlight the button. */
val isPlaybackModified: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED ||
breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES
}
@@ -1,182 +0,0 @@
package bzh.ajaury.chombev.player.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.core.logging.domain.Logger
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.player.domain.AudioSessionManager
import bzh.ajaury.chombev.player.domain.PlaybackRepository
import bzh.ajaury.chombev.player.model.PlaybackState
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import bzh.ajaury.chombev.records.domain.RecordRepository
import bzh.ajaury.chombev.records.model.Record
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
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() {
private val record = MutableStateFlow<Record?>(null)
val uiState: StateFlow<PlayerUiState> = combine(
record,
playbackRepository.playerState,
preferencesRepository.subtitlePreferences,
preferencesRepository.playbackPreferences,
) { record, playerState, subtitlePreferences, playbackPreferences ->
PlayerUiState(
recordEmoji = record?.emoji ?: "",
recordTitle = record?.title ?: Phrase(transcription = ""),
playerState = playerState,
subtitlePreferences = subtitlePreferences,
playbackSpeed = playbackPreferences.speed,
breakBetweenPhrases = playbackPreferences.breakBetweenPhrases,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = PlayerUiState(),
)
init {
audioSessionManager.activate()
}
fun loadRecord() {
viewModelScope.launch {
logger.debug("Loading record $recordId")
val alreadyLoadedRecord = record.value
if (alreadyLoadedRecord != null) {
logger.debug("Record $recordId already loaded")
playbackRepository.load(record = alreadyLoadedRecord)
return@launch
}
val loadedRecord = recordRepository.getRecord(id = recordId)
if (loadedRecord == null) {
logger.error(message = "Record not found: $recordId")
return@launch
}
playbackRepository.load(record = loadedRecord)
record.value = loadedRecord
}
}
fun performMainPlayAction() {
viewModelScope.launch {
when (uiState.value.playerState.playbackState) {
PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause()
PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
}
}
}
fun seekToTimePart(progress: Duration) {
viewModelScope.launch {
playbackRepository.seekTo(progress)
}
}
fun seekToSentence(sentenceIndex: Int) {
viewModelScope.launch {
playbackRepository.seekToSentence(sentenceIndex)
}
}
fun goToPreviousSentence() {
viewModelScope.launch {
playbackRepository.goToPreviousSentence()
}
}
fun goToNextSentence() {
viewModelScope.launch {
playbackRepository.goToNextSentence()
}
}
fun setPlaybackSpeed(speed: Float) {
val steppedSpeed = (speed * 10).roundToInt() / 10f
val clampedSpeed = steppedSpeed.coerceIn(
PlaybackPreferences.MIN_SPEED,
PlaybackPreferences.MAX_SPEED,
)
viewModelScope.launch {
preferencesRepository.setPlaybackSpeed(clampedSpeed)
}
}
fun setBreakBetweenPhrases(seconds: Float) {
val clampedSeconds = seconds
.roundToInt()
.coerceIn(0, PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds.toInt())
viewModelScope.launch {
preferencesRepository.setBreakBetweenPhrases(clampedSeconds.seconds)
}
}
fun setShowTranscription(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setShowTranscription(enabled)
}
}
fun setShowTranslation(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setShowTranslation(enabled)
}
}
fun setShowPhoneticMarkers(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setShowPhoneticMarkers(enabled)
}
}
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() {
viewModelScope.launch {
playbackRepository.stop()
}
}
override fun onCleared() {
super.onCleared()
audioSessionManager.deactivate()
playbackRepository.release()
}
}
@@ -1,90 +0,0 @@
package bzh.ajaury.chombev.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 bzh.ajaury.chombev.theme.ChomBevTheme
@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() {
ChomBevTheme {
PreferenceToggle(
title = "Subtitle",
subtitle = "Subtitle",
checked = true,
icon = null,
)
}
}
@Preview
@Composable
private fun PreferenceToggleWithIconPreview() {
ChomBevTheme {
PreferenceToggle(
title = "Subtitle",
subtitle = "Subtitle",
checked = true,
icon = Icons.Default.Subtitles,
)
}
}
@@ -1,177 +0,0 @@
package bzh.ajaury.chombev.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 bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.subtitle_marked_letters_description
import bzh.ajaury.chombev.resources.generated.resources.subtitle_marked_letters_label
import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_title
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_decrease
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_increase
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_label
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_description
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_label
import bzh.ajaury.chombev.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
* French translation (FR) on the player.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubtitlePreferencesBottomSheet(
subtitlePreferences: SubtitlePreferences,
onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {},
onShowPhoneticMarkerChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(),
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
SubtitlePreferencesContent(
subtitlePreferences = subtitlePreferences,
onShowTranscriptionChange = onShowTranscriptionChange,
onShowTranslationChange = onShowTranslationChange,
onShowMarkedLettersChange = onShowPhoneticMarkerChange,
onTextSizeDecrease = onTextSizeDecrease,
onTextSizeIncrease = onTextSizeIncrease,
)
}
}
@Composable
private fun SubtitlePreferencesContent(
subtitlePreferences: SubtitlePreferences,
onShowTranscriptionChange: (Boolean) -> Unit = {},
onShowTranslationChange: (Boolean) -> Unit = {},
onShowMarkedLettersChange: (Boolean) -> Unit = {},
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(Res.string.subtitle_preferences_title),
style = MaterialTheme.typography.titleMedium,
)
PreferenceToggle(
title = stringResource(Res.string.subtitle_transcription_label),
subtitle = stringResource(Res.string.subtitle_transcription_description),
checked = subtitlePreferences.showTranscription,
onCheckedChange = onShowTranscriptionChange,
)
PreferenceToggle(
title = stringResource(Res.string.subtitle_translation_label),
subtitle = stringResource(Res.string.subtitle_translation_description),
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,
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.0", 1.2 -> "x1.2". */
private fun formatTextScale(scale: Float): String {
val tenths = (scale * 10).roundToInt()
return "x${tenths / 10}.${tenths % 10}"
}
@Preview
@Composable
private fun SubtitlePreferencesContentPreview() {
ChomBevTheme {
SubtitlePreferencesContent(
subtitlePreferences = SubtitlePreferences(
showTranscription = true,
showTranslation = false,
),
)
}
}
@@ -1,72 +0,0 @@
package bzh.ajaury.chombev.records.ui
import androidx.compose.foundation.clickable
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.records.model.Record
@Composable
fun RecordView(
record: Record,
onClick: () -> Unit = {},
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = record.index.toString().padStart(2),
fontFamily = FontFamily.Monospace,
)
Text(text = record.emoji)
Column {
Text(text = record.title.transcription)
record.title.translation?.let { translation ->
Text(
text = translation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Preview
@Composable
private fun RecordViewPreview() {
RecordView(
record =
Record(
id = 1,
index = 1,
emoji = "🙏",
title = Phrase(
transcription = "Sample Record",
translation = "Exemple d'enregistrement",
),
audioResourcePath = "files/Chom_bev_01.ogg",
subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc",
),
onClick = {},
)
}
@@ -1,95 +0,0 @@
package bzh.ajaury.chombev.records.ui
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.records.model.Record
import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun RecordsScreen(
contentPadding: PaddingValues,
onRecordClick: (Record) -> Unit,
viewModel: RecordsViewModel = koinViewModel(),
) {
val records by viewModel.uiState.collectAsStateWithLifecycle()
RecordsScreen(
records = records,
contentPadding = contentPadding,
onRecordClick = onRecordClick,
)
}
@Composable
fun RecordsScreen(
records: List<Record>,
contentPadding: PaddingValues,
onRecordClick: (Record) -> Unit,
) {
LazyColumn(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
) {
itemsIndexed(
items = records,
key = { _, item -> item.id },
) { index, item ->
RecordView(
record = item,
onClick = { onRecordClick(item) },
)
if (index < records.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
}
}
@Preview
@Composable
private fun RecordsScreenPreview() {
ChomBevTheme {
RecordsScreen(
records = listOf(
Record(
id = 1,
index = 1,
emoji = "🙏",
title = Phrase(transcription = "An onestiz", translation = "La politesse"),
audioResourcePath = "files/Chom_bev_01.m4a",
subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc",
),
Record(
id = 2,
index = 2,
emoji = "👋",
title = Phrase(transcription = "En em brezantiñ", translation = "Se présenter"),
audioResourcePath = "files/Chom_bev_02.m4a",
subtitleResourcePath = "files/Chom_bev_02_st_bzh.lrc",
),
),
contentPadding = PaddingValues(),
onRecordClick = {},
)
}
}
@@ -1,21 +0,0 @@
package bzh.ajaury.chombev.records.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.records.domain.RecordRepository
import bzh.ajaury.chombev.records.model.Record
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
class RecordsViewModel(
recordRepository: RecordRepository,
) : ViewModel() {
val uiState: StateFlow<List<Record>> = recordRepository
.getRecords()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList(),
)
}