diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt index a937a06..d25a5fd 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt @@ -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() } factoryOf(::LrcParser) factoryOf(::LrcSubtitleRepository) { bind() } - factoryOf(::GetSubtitleTextUseCase) + factoryOf(::GetSubtitleWindowUseCase) viewModelOf(::RecordsViewModel) viewModelOf(::PlayerViewModel) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt index 6407f7d..aade3a7 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt @@ -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(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(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 = {}, diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt index c2f420f..bd23c5f 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt index e1c707f..b7e8370 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt @@ -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()) } } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleTextUseCase.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleTextUseCase.kt deleted file mode 100644 index 762aced..0000000 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleTextUseCase.kt +++ /dev/null @@ -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() -} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleWindowUseCase.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleWindowUseCase.kt new file mode 100644 index 0000000..c16fc3b --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleWindowUseCase.kt @@ -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(), + ) + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleWindow.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleWindow.kt new file mode 100644 index 0000000..36f7d9f --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleWindow.kt @@ -0,0 +1,7 @@ +package fr.ajaury.gwenedeg.subtitle.domain + +data class SubtitleWindow( + val previous: String = "", + val current: String = "", + val next: String = "", +) diff --git a/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleTextUseCaseTest.kt b/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleTextUseCaseTest.kt deleted file mode 100644 index 7cadbca..0000000 --- a/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleTextUseCaseTest.kt +++ /dev/null @@ -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)) - } -} diff --git a/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleWindowUseCaseTest.kt b/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleWindowUseCaseTest.kt new file mode 100644 index 0000000..c2f9fd2 --- /dev/null +++ b/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleWindowUseCaseTest.kt @@ -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), + ) + } +}