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.records.ui.viewmodel.RecordsViewModel
import fr.ajaury.gwenedeg.subtitle.data.LrcParser import fr.ajaury.gwenedeg.subtitle.data.LrcParser
import fr.ajaury.gwenedeg.subtitle.data.LrcSubtitleRepository 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 fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.factoryOf
@@ -20,7 +20,7 @@ val sharedModule = module {
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() } factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
factoryOf(::LrcParser) factoryOf(::LrcParser)
factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() } factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() }
factoryOf(::GetSubtitleTextUseCase) factoryOf(::GetSubtitleWindowUseCase)
viewModelOf(::RecordsViewModel) viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel) viewModelOf(::PlayerViewModel)
} }
@@ -31,7 +31,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.PlaybackState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
import fr.ajaury.gwenedeg.theme.GwenedegTheme import fr.ajaury.gwenedeg.theme.GwenedegTheme
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -81,7 +84,12 @@ fun PlayerScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = {}, title = {
Text(
text = recordTitle,
style = MaterialTheme.typography.headlineSmall,
)
},
navigationIcon = { navigationIcon = {
IconButton(onClick = onBackClicked) { IconButton(onClick = onBackClicked) {
Icon( Icon(
@@ -101,53 +109,105 @@ fun PlayerScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Spacer(Modifier)
text = "Playing: $recordTitle",
style = MaterialTheme.typography.headlineMedium, Subtitles(
uiState = uiState,
) )
Spacer(modifier = Modifier.weight(1f)) PlayerState(
uiState = uiState,
Text( onSeek = onSeek,
text = uiState.subtitle, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
) )
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 = private fun PlaybackState.toActionIcon(): ImageVector =
when (this) { when (this) {
PlaybackState.PLAYING -> Icons.Default.Pause PlaybackState.PLAYING -> Icons.Default.Pause
@@ -168,7 +228,16 @@ private fun PlayerScreenPlayingPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", 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 = {}, onMainPlayActionButtonClicked = {},
onSeek = {}, onSeek = {},
onBackClicked = {}, onBackClicked = {},
@@ -182,7 +251,16 @@ private fun PlayerScreenPausedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", 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 = {}, onMainPlayActionButtonClicked = {},
onSeek = {}, onSeek = {},
onBackClicked = {}, onBackClicked = {},
@@ -196,7 +274,16 @@ private fun PlayerScreenEndedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", 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 = {}, onMainPlayActionButtonClicked = {},
onSeek = {}, onSeek = {},
onBackClicked = {}, onBackClicked = {},
@@ -1,12 +1,13 @@
package fr.ajaury.gwenedeg.player.ui.viewmodel package fr.ajaury.gwenedeg.player.ui.viewmodel
import fr.ajaury.gwenedeg.player.PlaybackState import fr.ajaury.gwenedeg.player.PlaybackState
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
data class PlayerUiState( data class PlayerUiState(
val playbackState: PlaybackState = PlaybackState.IDLE, val playbackState: PlaybackState = PlaybackState.IDLE,
val positionMs: Long = 0L, val positionMs: Long = 0L,
val durationMs: Long = 0L, val durationMs: Long = 0L,
val subtitle: String = "", val subtitles: SubtitleWindow = SubtitleWindow(),
) { ) {
val progress: Float val progress: Float
get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f 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.AudioPlayer
import fr.ajaury.gwenedeg.player.AudioSessionManager import fr.ajaury.gwenedeg.player.AudioSessionManager
import fr.ajaury.gwenedeg.player.PlaybackState 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.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import gwenedeg.shared.generated.resources.Res import gwenedeg.shared.generated.resources.Res
@@ -29,7 +29,7 @@ class PlayerViewModel(
private val audioPlayer: AudioPlayer, private val audioPlayer: AudioPlayer,
private val audioSessionManager: AudioSessionManager, private val audioSessionManager: AudioSessionManager,
private val subtitleRepository: SubtitleRepository, private val subtitleRepository: SubtitleRepository,
private val getSubtitleText: GetSubtitleTextUseCase, private val getSubtitleWindow: GetSubtitleWindowUseCase,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
private var filePath: String? = null private var filePath: String? = null
@@ -62,7 +62,10 @@ class PlayerViewModel(
playbackState = playbackState, playbackState = playbackState,
positionMs = currentPosition.inWholeMilliseconds, positionMs = currentPosition.inWholeMilliseconds,
durationMs = audioPlayer.duration.inWholeMilliseconds, durationMs = audioPlayer.duration.inWholeMilliseconds,
subtitle = getSubtitleText(subtitle, currentPosition.inWholeMilliseconds), subtitles = getSubtitleWindow(
subtitle = subtitle,
progressMs = currentPosition.inWholeMilliseconds,
),
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -90,7 +93,10 @@ class PlayerViewModel(
try { try {
subtitleRepository.getSubtitle(resourcePath) subtitleRepository.getSubtitle(resourcePath)
} catch (exception: Exception) { } 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()) 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),
)
}
}