new: show previous and next subtitles

This commit is contained in:
2026-06-19 16:50:44 +02:00
parent d86a18fcea
commit 42df13faec
9 changed files with 227 additions and 109 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.GetSubtitleTextUseCase
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
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(::GetSubtitleTextUseCase)
factoryOf(::GetSubtitleWindowUseCase)
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
}
@@ -31,7 +31,9 @@ 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.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
@@ -39,6 +41,7 @@ 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.theme.GwenedegTheme
import org.koin.compose.viewmodel.koinViewModel
@@ -81,7 +84,12 @@ fun PlayerScreen(
Scaffold(
topBar = {
TopAppBar(
title = {},
title = {
Text(
text = recordTitle,
style = MaterialTheme.typography.headlineSmall,
)
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
@@ -101,53 +109,105 @@ fun PlayerScreen(
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Playing: $recordTitle",
style = MaterialTheme.typography.headlineMedium,
Spacer(Modifier)
Subtitles(
uiState = uiState,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = uiState.subtitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
PlayerState(
uiState = uiState,
onSeek = onSeek,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
)
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(),
)
}
}
}
}
}
@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),
)
}
}
@Composable
private fun Subtitle(
text: String,
style: TextStyle,
color: Color,
) {
Text(
text = text,
style = style,
color = color,
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
@@ -168,7 +228,16 @@ private fun PlayerScreenPlayingPreview() {
GwenedegTheme {
PlayerScreen(
recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"),
uiState = PlayerUiState(
playbackState = PlaybackState.PLAYING,
positionMs = 1234,
durationMs = 5000,
subtitles = SubtitleWindow(
previous = "Demat deoc'h !",
current = "Kenavo !",
next = "Kenavo emberr !",
),
),
onMainPlayActionButtonClicked = {},
onSeek = {},
onBackClicked = {},
@@ -182,7 +251,16 @@ private fun PlayerScreenPausedPreview() {
GwenedegTheme {
PlayerScreen(
recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"),
uiState = PlayerUiState(
playbackState = PlaybackState.PAUSED,
positionMs = 1234,
durationMs = 5000,
subtitles = SubtitleWindow(
previous = "Demat deoc'h !",
current = "Kenavo !",
next = "Kenavo emberr !",
),
),
onMainPlayActionButtonClicked = {},
onSeek = {},
onBackClicked = {},
@@ -196,7 +274,16 @@ private fun PlayerScreenEndedPreview() {
GwenedegTheme {
PlayerScreen(
recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"),
uiState = PlayerUiState(
playbackState = PlaybackState.ENDED,
positionMs = 1234,
durationMs = 5000,
subtitles = SubtitleWindow(
previous = "Demat deoc'h !",
current = "Kenavo !",
next = "Kenavo emberr !",
),
),
onMainPlayActionButtonClicked = {},
onSeek = {},
onBackClicked = {},
@@ -1,12 +1,13 @@
package fr.ajaury.gwenedeg.player.ui.viewmodel
import fr.ajaury.gwenedeg.player.PlaybackState
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
data class PlayerUiState(
val playbackState: PlaybackState = PlaybackState.IDLE,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val subtitle: String = "",
val subtitles: SubtitleWindow = SubtitleWindow(),
) {
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.GetSubtitleTextUseCase
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
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 getSubtitleText: GetSubtitleTextUseCase,
private val getSubtitleWindow: GetSubtitleWindowUseCase,
private val logger: Logger,
) : ViewModel() {
private var filePath: String? = null
@@ -62,7 +62,10 @@ class PlayerViewModel(
playbackState = playbackState,
positionMs = currentPosition.inWholeMilliseconds,
durationMs = audioPlayer.duration.inWholeMilliseconds,
subtitle = getSubtitleText(subtitle, currentPosition.inWholeMilliseconds),
subtitles = getSubtitleWindow(
subtitle = subtitle,
progressMs = currentPosition.inWholeMilliseconds,
),
)
}.stateIn(
scope = viewModelScope,
@@ -90,7 +93,10 @@ class PlayerViewModel(
try {
subtitleRepository.getSubtitle(resourcePath)
} catch (exception: Exception) {
logger.error(message = "Failed to load subtitle: $resourcePath", throwable = exception)
logger.error(
message = "Failed to load subtitle: $resourcePath",
throwable = exception,
)
Subtitle(emptyList())
}
}
@@ -1,16 +0,0 @@
package fr.ajaury.gwenedeg.subtitle.domain
/**
* Resolves the subtitle text to display for a given playback progress: the latest cue whose start
* is at or before [progressMs], or `null` when no cue has started yet.
*/
class GetSubtitleTextUseCase {
operator fun invoke(
subtitle: Subtitle,
progressMs: Long,
): String =
subtitle.lines
.lastOrNull { it.startMs <= progressMs }
?.text
.orEmpty()
}
@@ -0,0 +1,16 @@
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(),
)
}
}
@@ -0,0 +1,7 @@
package fr.ajaury.gwenedeg.subtitle.domain
data class SubtitleWindow(
val previous: String = "",
val current: String = "",
val next: String = "",
)
@@ -1,45 +0,0 @@
package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleTextUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class GetSubtitleTextUseCaseTest {
private val getSubtitleText = GetSubtitleTextUseCase()
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_null_before_the_first_cue() {
assertEquals("", getSubtitleText(subtitle, progressMs = -1))
}
@Test
fun returns_the_active_cue_at_and_after_its_start() {
assertEquals("Demat deoc'h !", getSubtitleText(subtitle, progressMs = 0))
assertEquals("Demat deoc'h !", getSubtitleText(subtitle, progressMs = 4309))
assertEquals("Kenavo !", getSubtitleText(subtitle, progressMs = 4310))
assertEquals("Kenavo !", getSubtitleText(subtitle, progressMs = 5000))
}
@Test
fun returns_the_last_cue_past_the_end() {
assertEquals("Kenavo emberr !", getSubtitleText(subtitle, progressMs = 50_000))
}
@Test
fun returns_null_for_an_empty_subtitle() {
assertEquals("", getSubtitleText(Subtitle(emptyList()), progressMs = 1_000))
}
}
@@ -0,0 +1,62 @@
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),
)
}
}