From 5e3dc3059bf721c4956c74a6672560f2e8a7063a Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 22 Jun 2026 13:18:53 +0200 Subject: [PATCH] refactor: improve player package structure --- .../gwenedeg/player/model/PlaybackTiming.kt | 9 + .../gwenedeg/player/ui/PlayerControl.kt | 79 +++++++ .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 193 +----------------- .../ajaury/gwenedeg/player/ui/SubtitleList.kt | 149 ++++++++++++++ .../player/ui/viewmodel/PlayerUiState.kt | 9 +- .../player/ui/viewmodel/PlayerViewModel.kt | 9 +- 6 files changed, 253 insertions(+), 195 deletions(-) create mode 100644 core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackTiming.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt diff --git a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackTiming.kt b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackTiming.kt new file mode 100644 index 0000000..23ad9f7 --- /dev/null +++ b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackTiming.kt @@ -0,0 +1,9 @@ +package fr.ajaury.gwenedeg.player.model + +data class PlaybackTiming( + val positionMs: Long = 0L, + val durationMs: Long = 0L, +) { + val progress: Float + get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f +} 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 new file mode 100644 index 0000000..c8d3f5f --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt @@ -0,0 +1,79 @@ +package fr.ajaury.gwenedeg.player.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +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.Icon +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import fr.ajaury.gwenedeg.player.model.PlaybackState +import fr.ajaury.gwenedeg.player.model.PlaybackTiming + +@Composable +fun PlayerControl( + playbackState: PlaybackState, + playbackTiming: PlaybackTiming, + onSeek: (Float) -> Unit = {}, + onMainPlayActionButtonClicked: () -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + var seekProgress by remember { mutableStateOf(null) } + Slider( + value = seekProgress ?: playbackTiming.progress, + onValueChange = { seekProgress = it }, + onValueChangeFinished = { + seekProgress?.let { fraction -> + if (playbackTiming.durationMs > 0) { + onSeek(fraction) + } + } + seekProgress = null + }, + modifier = Modifier.fillMaxWidth(), + ) + + Button( + modifier = Modifier.size(60.dp), + contentPadding = PaddingValues(0.dp), + onClick = onMainPlayActionButtonClicked, + ) { + Icon( + imageVector = playbackState.toActionIcon(), + contentDescription = playbackState.toActionContentDescription(), + ) + } + } +} + +private fun PlaybackState.toActionIcon(): ImageVector = + when (this) { + PlaybackState.PLAYING -> Icons.Default.Pause + PlaybackState.ENDED -> Icons.Default.Replay + PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow + } + +private fun PlaybackState.toActionContentDescription(): String = + when (this) { + PlaybackState.PLAYING -> "Pause" + PlaybackState.ENDED -> "Replay" + PlaybackState.PAUSED, PlaybackState.IDLE -> "Play" + } 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 a152983..e3c8a1c 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 @@ -1,57 +1,33 @@ package fr.ajaury.gwenedeg.player.ui -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.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -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.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.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -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.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import fr.ajaury.gwenedeg.player.model.PlaybackState +import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import fr.ajaury.gwenedeg.theme.GwenedegTheme import org.koin.compose.viewmodel.koinViewModel -import kotlin.math.abs @Composable fun PlayerScreen( @@ -127,8 +103,9 @@ fun PlayerScreen( .fillMaxWidth(), ) - PlayerState( - uiState = uiState, + PlayerControl( + playbackState = uiState.playbackState, + playbackTiming = uiState.playbackTiming, onSeek = onSeekToTimePart, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, ) @@ -136,159 +113,6 @@ fun PlayerScreen( } } -@Composable -private fun SubtitleList( - lines: List, - currentIndex: Int?, - onSeek: (position: Int) -> Unit, - modifier: Modifier = Modifier, -) { - val listState = rememberLazyListState() - val isDragged by listState.interactionSource.collectIsDraggedAsState() - - val centeredIndex by remember { - derivedStateOf { - val info = listState.layoutInfo - if (info.visibleItemsInfo.isEmpty()) { - -1 - } else { - val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f - info.visibleItemsInfo.minByOrNull { abs((it.offset + it.size / 2f) - viewportCenter) }!!.index - } - } - } - - // 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(listState, lines) { - var wasDragged = false - snapshotFlow { isDragged to listState.isScrollInProgress } - .collect { (dragged, scrolling) -> - if (dragged) { - wasDragged = true - } else if (wasDragged && !scrolling) { - wasDragged = false - val targetIndex = centeredIndex - if (targetIndex in lines.indices) { - listState.centerItem(targetIndex) - onSeek(targetIndex) - } - } - } - } - - BoxWithConstraints(modifier = modifier) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - // Half-height padding so the first and last lines can reach the vertical center. - contentPadding = PaddingValues(vertical = maxHeight / 2), - ) { - itemsIndexed(lines) { index, line -> - val isCurrent = index == centeredIndex - Subtitle( - line = line, - isCurrent = isCurrent, - ) - } - } - } -} - -private suspend fun LazyListState.centerItem(index: Int) { - 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, -) { - Text( - text = line.text, - style = if (isCurrent) { - MaterialTheme.typography.headlineSmall - } else { - MaterialTheme.typography.bodyLarge - }, - color = if (isCurrent) { - Color.Unspecified - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - }, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) -} - -@Composable -private fun PlayerState( - uiState: PlayerUiState, - onSeek: (Float) -> Unit, - onMainPlayActionButtonClicked: () -> Unit, -) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - var seekProgress by remember { mutableStateOf(null) } - Slider( - value = seekProgress ?: uiState.progress, - onValueChange = { seekProgress = it }, - onValueChangeFinished = { - seekProgress?.let { fraction -> - if (uiState.durationMs > 0) { - onSeek(fraction) - } - } - seekProgress = null - }, - modifier = Modifier.fillMaxWidth(), - ) - - Button( - modifier = Modifier.size(60.dp), - contentPadding = PaddingValues(0.dp), - onClick = onMainPlayActionButtonClicked, - ) { - Icon( - imageVector = uiState.playbackState.toActionIcon(), - contentDescription = uiState.playbackState.toActionContentDescription(), - ) - } - } -} - -private fun PlaybackState.toActionIcon(): ImageVector = - when (this) { - PlaybackState.PLAYING -> Icons.Default.Pause - PlaybackState.ENDED -> Icons.Default.Replay - PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow - } - -private fun PlaybackState.toActionContentDescription(): String = - when (this) { - PlaybackState.PLAYING -> "Pause" - PlaybackState.ENDED -> "Replay" - PlaybackState.PAUSED, PlaybackState.IDLE -> "Play" - } - private val previewSubtitleLines = listOf( SubtitleLine(startMs = 0, text = "Demat deoc'h !"), @@ -304,8 +128,7 @@ private fun PlayerScreenPlayingPreview() { recordTitle = "Demat", uiState = PlayerUiState( playbackState = PlaybackState.PLAYING, - positionMs = 1234, - durationMs = 5000, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), subtitleLines = previewSubtitleLines, currentSubtitleIndex = 1, ), @@ -324,8 +147,7 @@ private fun PlayerScreenPausedPreview() { recordTitle = "Demat", uiState = PlayerUiState( playbackState = PlaybackState.PAUSED, - positionMs = 1234, - durationMs = 5000, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), subtitleLines = previewSubtitleLines, currentSubtitleIndex = 1, ), @@ -344,8 +166,7 @@ private fun PlayerScreenEndedPreview() { recordTitle = "Demat", uiState = PlayerUiState( playbackState = PlaybackState.ENDED, - positionMs = 1234, - durationMs = 5000, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), subtitleLines = previewSubtitleLines, currentSubtitleIndex = 2, ), 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 new file mode 100644 index 0000000..3951873 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt @@ -0,0 +1,149 @@ +package fr.ajaury.gwenedeg.player.ui + +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine +import fr.ajaury.gwenedeg.theme.GwenedegTheme +import kotlin.math.abs + + +@Composable +fun SubtitleList( + modifier: Modifier = Modifier, + lines: List, + currentIndex: Int?, + onSeek: (position: Int) -> Unit = {}, +) { + val listState = rememberLazyListState() + val isDragged by listState.interactionSource.collectIsDraggedAsState() + + val centeredIndex by remember { + derivedStateOf { + val info = listState.layoutInfo + if (info.visibleItemsInfo.isEmpty()) { + -1 + } else { + val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f + info.visibleItemsInfo.minByOrNull { abs((it.offset + it.size / 2f) - viewportCenter) }!!.index + } + } + } + + // 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(listState, lines) { + var wasDragged = false + snapshotFlow { isDragged to listState.isScrollInProgress } + .collect { (dragged, scrolling) -> + if (dragged) { + wasDragged = true + } else if (wasDragged && !scrolling) { + wasDragged = false + val targetIndex = centeredIndex + if (targetIndex in lines.indices) { + listState.centerItem(targetIndex) + onSeek(targetIndex) + } + } + } + } + + BoxWithConstraints(modifier = modifier) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + // Half-height padding so the first and last lines can reach the vertical center. + contentPadding = PaddingValues(vertical = maxHeight / 2), + ) { + itemsIndexed(lines) { index, line -> + val isCurrent = index == centeredIndex + Subtitle( + line = line, + isCurrent = isCurrent, + ) + } + } + } +} + +private suspend fun LazyListState.centerItem(index: Int) { + 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, +) { + Text( + text = line.text, + style = if (isCurrent) { + MaterialTheme.typography.headlineSmall + } else { + MaterialTheme.typography.bodyLarge + }, + color = if (isCurrent) { + Color.Unspecified + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + }, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +private val previewSubtitleLines = + listOf( + SubtitleLine(startMs = 0, text = "Demat deoc'h !"), + SubtitleLine(startMs = 4310, text = "Kenavo !"), + SubtitleLine(startMs = 7350, text = "Kenavo emberr !"), + ) + +@Preview +@Composable +private fun SubtitleListPreview() { + GwenedegTheme { + SubtitleList( + lines = previewSubtitleLines, + currentIndex = 1, + ) + } +} \ No newline at end of file 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 e2f9b8e..d156e9c 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 @@ -1,15 +1,12 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel import fr.ajaury.gwenedeg.player.model.PlaybackState +import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine data class PlayerUiState( val playbackState: PlaybackState = PlaybackState.IDLE, - val positionMs: Long = 0L, - val durationMs: Long = 0L, + val playbackTiming: PlaybackTiming = PlaybackTiming(), val subtitleLines: List = emptyList(), val currentSubtitleIndex: Int? = null, -) { - val progress: Float - get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f -} +) 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 09413a6..d4f1e3e 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 @@ -6,6 +6,7 @@ import fr.ajaury.gwenedeg.core.logging.domain.Logger import fr.ajaury.gwenedeg.player.domain.AudioPlayer import fr.ajaury.gwenedeg.player.domain.AudioSessionManager import fr.ajaury.gwenedeg.player.model.PlaybackState +import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase import fr.ajaury.gwenedeg.subtitle.domain.Subtitle @@ -62,8 +63,10 @@ class PlayerViewModel( ) { playbackState, currentPosition, subtitle -> PlayerUiState( playbackState = playbackState, - positionMs = currentPosition.inWholeMilliseconds, - durationMs = audioPlayer.duration.inWholeMilliseconds, + playbackTiming = PlaybackTiming( + positionMs = currentPosition.inWholeMilliseconds, + durationMs = audioPlayer.duration.inWholeMilliseconds, + ), subtitleLines = subtitle.lines, currentSubtitleIndex = getCurrentSubtitleIndex( subtitle = subtitle, @@ -115,7 +118,7 @@ class PlayerViewModel( } fun seekToTimePart(progress: Float) { - val position = (progress.toDouble() * uiState.value.durationMs).milliseconds + val position = (progress.toDouble() * uiState.value.playbackTiming.durationMs).milliseconds audioPlayer.seekTo(position) }