refactor: improve player package structure

This commit is contained in:
2026-06-22 13:18:53 +02:00
parent 1028b748a9
commit 5e3dc3059b
6 changed files with 253 additions and 195 deletions
@@ -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
}
@@ -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<Float?>(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"
}
@@ -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<SubtitleLine>,
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<Float?>(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,
),
@@ -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<SubtitleLine>,
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,
)
}
}
@@ -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<SubtitleLine> = emptyList(),
val currentSubtitleIndex: Int? = null,
) {
val progress: Float
get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
}
)
@@ -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)
}