new: allow scrolling on subtitle list and advance playback

This commit is contained in:
2026-06-19 17:27:31 +02:00
parent 42df13faec
commit c4572eb042
9 changed files with 202 additions and 163 deletions
@@ -8,7 +8,7 @@ import fr.ajaury.gwenedeg.records.domain.RecordRepository
import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel
import fr.ajaury.gwenedeg.subtitle.data.LrcParser
import fr.ajaury.gwenedeg.subtitle.data.LrcSubtitleRepository
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf
@@ -20,7 +20,7 @@ val sharedModule = module {
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
factoryOf(::LrcParser)
factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() }
factoryOf(::GetSubtitleWindowUseCase)
factoryOf(::GetCurrentSubtitleIndexUseCase)
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
}
@@ -1,14 +1,19 @@
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.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
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
@@ -25,15 +30,17 @@ 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.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -41,16 +48,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.ajaury.gwenedeg.player.PlaybackState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
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(
recordTitle: String,
audioResourcePath: String,
subtitleResourcePath: String,
onBackClicked: () -> Unit,
onBackClicked: () -> Unit = {},
viewModel: PlayerViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -67,7 +75,8 @@ fun PlayerScreen(
recordTitle = recordTitle,
uiState = uiState,
onMainPlayActionButtonClicked = viewModel::performMainPlayAction,
onSeek = viewModel::seekTo,
onSeekToTimePart = viewModel::seekToTimePart,
onSeekToSentence = viewModel::seekToSentence,
onBackClicked = onBackClicked,
)
}
@@ -77,9 +86,10 @@ fun PlayerScreen(
fun PlayerScreen(
recordTitle: String,
uiState: PlayerUiState,
onMainPlayActionButtonClicked: () -> Unit,
onSeek: (progress: Float) -> Unit,
onBackClicked: () -> Unit,
onMainPlayActionButtonClicked: () -> Unit = {},
onSeekToTimePart: (progress: Float) -> Unit = {},
onSeekToSentence: (position: Int) -> Unit = {},
onBackClicked: () -> Unit = {},
) {
Scaffold(
topBar = {
@@ -101,69 +111,126 @@ fun PlayerScreen(
)
},
) { innerPadding ->
Box(
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Spacer(Modifier)
SubtitleList(
lines = uiState.subtitleLines,
currentIndex = uiState.currentSubtitleIndex,
onSeek = onSeekToSentence,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
Subtitles(
uiState = uiState,
)
PlayerState(
uiState = uiState,
onSeek = onSeekToTimePart,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
)
}
}
}
PlayerState(
uiState = uiState,
onSeek = onSeek,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
@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,
)
}
}
}
}
@Composable
private fun Subtitles(uiState: PlayerUiState) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Subtitle(
text = uiState.subtitles.previous,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
Subtitle(
text = uiState.subtitles.current,
style = MaterialTheme.typography.headlineSmall,
color = Color.Unspecified,
)
Subtitle(
text = uiState.subtitles.next,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
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(
text: String,
style: TextStyle,
color: Color,
line: SubtitleLine,
isCurrent: Boolean,
) {
Text(
text = text,
style = style,
color = color,
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(),
)
@@ -222,6 +289,13 @@ private fun PlaybackState.toActionContentDescription(): String =
PlaybackState.PAUSED, PlaybackState.IDLE -> "Play"
}
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 PlayerScreenPlayingPreview() {
@@ -232,14 +306,11 @@ private fun PlayerScreenPlayingPreview() {
playbackState = PlaybackState.PLAYING,
positionMs = 1234,
durationMs = 5000,
subtitles = SubtitleWindow(
previous = "Demat deoc'h !",
current = "Kenavo !",
next = "Kenavo emberr !",
),
subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 1,
),
onMainPlayActionButtonClicked = {},
onSeek = {},
onSeekToSentence = {},
onBackClicked = {},
)
}
@@ -255,14 +326,11 @@ private fun PlayerScreenPausedPreview() {
playbackState = PlaybackState.PAUSED,
positionMs = 1234,
durationMs = 5000,
subtitles = SubtitleWindow(
previous = "Demat deoc'h !",
current = "Kenavo !",
next = "Kenavo emberr !",
),
subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 1,
),
onMainPlayActionButtonClicked = {},
onSeek = {},
onSeekToSentence = {},
onBackClicked = {},
)
}
@@ -278,14 +346,11 @@ private fun PlayerScreenEndedPreview() {
playbackState = PlaybackState.ENDED,
positionMs = 1234,
durationMs = 5000,
subtitles = SubtitleWindow(
previous = "Demat deoc'h !",
current = "Kenavo !",
next = "Kenavo emberr !",
),
subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 2,
),
onMainPlayActionButtonClicked = {},
onSeek = {},
onSeekToSentence = {},
onBackClicked = {},
)
}
@@ -1,13 +1,14 @@
package fr.ajaury.gwenedeg.player.ui.viewmodel
import fr.ajaury.gwenedeg.player.PlaybackState
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
data class PlayerUiState(
val playbackState: PlaybackState = PlaybackState.IDLE,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val subtitles: SubtitleWindow = SubtitleWindow(),
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,7 +6,7 @@ import fr.ajaury.gwenedeg.core.logging.Logger
import fr.ajaury.gwenedeg.player.AudioPlayer
import fr.ajaury.gwenedeg.player.AudioSessionManager
import fr.ajaury.gwenedeg.player.PlaybackState
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import gwenedeg.shared.generated.resources.Res
@@ -29,7 +29,7 @@ class PlayerViewModel(
private val audioPlayer: AudioPlayer,
private val audioSessionManager: AudioSessionManager,
private val subtitleRepository: SubtitleRepository,
private val getSubtitleWindow: GetSubtitleWindowUseCase,
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val logger: Logger,
) : ViewModel() {
private var filePath: String? = null
@@ -62,7 +62,8 @@ class PlayerViewModel(
playbackState = playbackState,
positionMs = currentPosition.inWholeMilliseconds,
durationMs = audioPlayer.duration.inWholeMilliseconds,
subtitles = getSubtitleWindow(
subtitleLines = subtitle.lines,
currentSubtitleIndex = getCurrentSubtitleIndex(
subtitle = subtitle,
progressMs = currentPosition.inWholeMilliseconds,
),
@@ -110,11 +111,16 @@ class PlayerViewModel(
}
}
fun seekTo(progress: Float) {
fun seekToTimePart(progress: Float) {
val position = (progress.toDouble() * uiState.value.durationMs).milliseconds
audioPlayer.seekTo(position)
}
fun seekToSentence(sentenceIndex: Int) {
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startMs.milliseconds)
}
fun stop() {
audioPlayer.stop()
}
@@ -0,0 +1,8 @@
package fr.ajaury.gwenedeg.subtitle.domain
class GetCurrentSubtitleIndexUseCase {
operator fun invoke(
subtitle: Subtitle,
progressMs: Long,
): Int = subtitle.lines.indexOfLast { it.startMs <= progressMs }
}
@@ -1,16 +0,0 @@
package fr.ajaury.gwenedeg.subtitle.domain
class GetSubtitleWindowUseCase {
operator fun invoke(
subtitle: Subtitle,
progressMs: Long,
): SubtitleWindow {
val lines = subtitle.lines
val currentIndex = lines.indexOfLast { it.startMs <= progressMs }
return SubtitleWindow(
previous = lines.getOrNull(currentIndex - 1)?.text.orEmpty(),
current = lines.getOrNull(currentIndex)?.text.orEmpty(),
next = lines.getOrNull(currentIndex + 1)?.text.orEmpty(),
)
}
}
@@ -1,7 +0,0 @@
package fr.ajaury.gwenedeg.subtitle.domain
data class SubtitleWindow(
val previous: String = "",
val current: String = "",
val next: String = "",
)