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 = "",
)
@@ -0,0 +1,44 @@
package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.test.Test
import kotlin.test.assertEquals
class GetCurrentSubtitleIndexUseCaseTest {
private val getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase()
private val subtitle =
Subtitle(
lines =
listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !"),
SubtitleLine(startMs = 4310, text = "Kenavo !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"),
),
)
@Test
fun returns_minus_one_before_the_first_cue() {
assertEquals(-1, getCurrentSubtitleIndex(subtitle, progressMs = -1))
}
@Test
fun returns_the_index_at_and_after_each_cue_start() {
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 0))
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 4309))
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 4310))
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 5000))
}
@Test
fun returns_the_last_index_past_the_end() {
assertEquals(2, getCurrentSubtitleIndex(subtitle, progressMs = 50_000))
}
@Test
fun returns_minus_one_for_an_empty_subtitle() {
assertEquals(-1, getCurrentSubtitleIndex(Subtitle(emptyList()), progressMs = 1_000))
}
}
@@ -1,62 +0,0 @@
package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
import kotlin.test.Test
import kotlin.test.assertEquals
class GetSubtitleWindowUseCaseTest {
private val getSubtitleWindow = GetSubtitleWindowUseCase()
private val subtitle =
Subtitle(
lines =
listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !"),
SubtitleLine(startMs = 4310, text = "Kenavo !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"),
),
)
@Test
fun before_the_first_cue_only_next_is_set() {
assertEquals(
SubtitleWindow(previous = "", current = "", next = "Demat deoc'h !"),
getSubtitleWindow(subtitle, progressMs = -1),
)
}
@Test
fun on_the_first_cue_there_is_no_previous() {
assertEquals(
SubtitleWindow(previous = "", current = "Demat deoc'h !", next = "Kenavo !"),
getSubtitleWindow(subtitle, progressMs = 0),
)
}
@Test
fun on_a_middle_cue_all_three_are_set() {
assertEquals(
SubtitleWindow(previous = "Demat deoc'h !", current = "Kenavo !", next = "Kenavo emberr !"),
getSubtitleWindow(subtitle, progressMs = 5000),
)
}
@Test
fun on_the_last_cue_there_is_no_next() {
assertEquals(
SubtitleWindow(previous = "Kenavo !", current = "Kenavo emberr !", next = ""),
getSubtitleWindow(subtitle, progressMs = 50_000),
)
}
@Test
fun an_empty_subtitle_yields_an_empty_window() {
assertEquals(
SubtitleWindow(),
getSubtitleWindow(Subtitle(emptyList()), progressMs = 1_000),
)
}
}