diff --git a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt index 57c57b6..a1012cb 100644 --- a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt +++ b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt @@ -2,6 +2,7 @@ package fr.ajaury.gwenedeg.subtitle.data import fr.ajaury.gwenedeg.subtitle.domain.Subtitle import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine +import kotlin.time.Duration.Companion.milliseconds /** * Parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) subtitle content. @@ -17,7 +18,7 @@ internal class LrcParser { val lines = content .lineSequence() .flatMap { line -> parseLine(line) } - .sortedBy { it.startMs } + .sortedBy { it.startTime } .toList() return Subtitle(lines) } @@ -28,7 +29,7 @@ internal class LrcParser { return timeTag .findAll(tags.value) .mapNotNull { match -> - SubtitleLine(startMs = match.toMs(), text = text) + SubtitleLine(startTime = match.toMs().milliseconds, text = text) .takeIf { it.text.isNotBlank() } }.toList() } diff --git a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt index f112845..5bd3752 100644 --- a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt +++ b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt @@ -31,10 +31,10 @@ internal class LrcSubtitleRepository( /** Attaches each [translation] line's text to the matching original line, paired by timestamp. */ private fun Subtitle.mergeTranslation(translation: Subtitle?): Subtitle { if (translation == null) return this - val translationByStart = translation.lines.associate { it.startMs to it.text } + val translationByStart = translation.lines.associate { it.startTime to it.text } return Subtitle( lines = lines.map { line -> - line.copy(translation = translationByStart[line.startMs]) + line.copy(translation = translationByStart[line.startTime]) }, ) } diff --git a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetCurrentSubtitleIndexUseCase.kt b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetCurrentSubtitleIndexUseCase.kt index 649b4b1..6dcbec9 100644 --- a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetCurrentSubtitleIndexUseCase.kt +++ b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetCurrentSubtitleIndexUseCase.kt @@ -1,8 +1,10 @@ package fr.ajaury.gwenedeg.subtitle.domain +import kotlin.time.Duration + class GetCurrentSubtitleIndexUseCase { operator fun invoke( subtitle: Subtitle, - progressMs: Long, - ): Int? = subtitle.lines.indexOfLast { it.startMs <= progressMs }.takeIf { it >= 0 } + position: Duration, + ): Int? = subtitle.lines.indexOfLast { it.startTime <= position }.takeIf { it >= 0 } } diff --git a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt index 0f20866..f56834a 100644 --- a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt +++ b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt @@ -1,11 +1,13 @@ package fr.ajaury.gwenedeg.subtitle.domain +import kotlin.time.Duration + /** - * A single timed subtitle cue: [text] becomes active at [startMs]. [translation] is the optional + * A single timed subtitle cue: [text] becomes active at [startTime]. [translation] is the optional * translation of [text] in the user's language (e.g. French), shown alongside the original. */ data class SubtitleLine( - val startMs: Long, + val startTime: Duration, val text: String, val translation: String? = null, ) diff --git a/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetCurrentSubtitleIndexUseCaseTest.kt b/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetCurrentSubtitleIndexUseCaseTest.kt index f4502b7..30ead7f 100644 --- a/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetCurrentSubtitleIndexUseCaseTest.kt +++ b/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetCurrentSubtitleIndexUseCaseTest.kt @@ -5,6 +5,7 @@ import fr.ajaury.gwenedeg.subtitle.domain.Subtitle import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds class GetCurrentSubtitleIndexUseCaseTest { private val getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase() @@ -13,32 +14,35 @@ class GetCurrentSubtitleIndexUseCaseTest { Subtitle( lines = listOf( - SubtitleLine(startMs = 0, text = "Demat deoc'h !"), - SubtitleLine(startMs = 4310, text = "Kenavo !"), - SubtitleLine(startMs = 7350, text = "Kenavo emberr !"), + SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !"), + SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !"), + SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !"), ), ) @Test fun returns_minus_one_before_the_first_cue() { - assertEquals(null, getCurrentSubtitleIndex(subtitle, progressMs = -1)) + assertEquals(null, getCurrentSubtitleIndex(subtitle, position = (-1).milliseconds)) } @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)) + assertEquals(0, getCurrentSubtitleIndex(subtitle, position = 0.milliseconds)) + assertEquals(0, getCurrentSubtitleIndex(subtitle, position = 4309.milliseconds)) + assertEquals(1, getCurrentSubtitleIndex(subtitle, position = 4310.milliseconds)) + assertEquals(1, getCurrentSubtitleIndex(subtitle, position = 5000.milliseconds)) } @Test fun returns_the_last_index_past_the_end() { - assertEquals(2, getCurrentSubtitleIndex(subtitle, progressMs = 50_000)) + assertEquals(2, getCurrentSubtitleIndex(subtitle, position = 50_000.milliseconds)) } @Test fun returns_minus_one_for_an_empty_subtitle() { - assertEquals(null, getCurrentSubtitleIndex(Subtitle(emptyList()), progressMs = 1_000)) + assertEquals( + null, + getCurrentSubtitleIndex(Subtitle(emptyList()), position = 1_000.milliseconds), + ) } } diff --git a/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt b/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt index 224afc2..aa95887 100644 --- a/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt +++ b/data/subtitle/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt @@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.subtitle import fr.ajaury.gwenedeg.subtitle.data.LrcParser import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds class LrcParserTest { private val parser = LrcParser() @@ -31,16 +32,16 @@ class LrcParserTest { fun converts_timestamp_to_milliseconds() { val subtitle = parser.parse(sample) - assertEquals(0, subtitle.lines[0].startMs) - assertEquals(4310, subtitle.lines[1].startMs) - assertEquals(7350, subtitle.lines[2].startMs) + assertEquals(0.milliseconds, subtitle.lines[0].startTime) + assertEquals(4310.milliseconds, subtitle.lines[1].startTime) + assertEquals(7350.milliseconds, subtitle.lines[2].startTime) } @Test fun parses_three_digit_fraction_as_milliseconds() { val subtitle = parser.parse("[00:01.250]Hello") - assertEquals(1250, subtitle.lines.single().startMs) + assertEquals(1250.milliseconds, subtitle.lines.single().startTime) } @Test 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 32c1b22..4402e22 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt @@ -1,6 +1,8 @@ package fr.ajaury.gwenedeg.di import fr.ajaury.gwenedeg.core.logging.di.loggingModule +import fr.ajaury.gwenedeg.player.data.PlaybackRepositoryImpl +import fr.ajaury.gwenedeg.player.domain.PlaybackRepository import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel import fr.ajaury.gwenedeg.records.data.InMemoryRecordRepository import fr.ajaury.gwenedeg.records.domain.RecordRepository @@ -9,12 +11,14 @@ import fr.ajaury.gwenedeg.resourcereader.data.ComposeResourceReader import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val sharedModule = module { includes(loggingModule) factoryOf(::InMemoryRecordRepository) { bind() } + singleOf(::PlaybackRepositoryImpl) { bind() } viewModelOf(::RecordsViewModel) viewModelOf(::PlayerViewModel) factoryOf(::ComposeResourceReader) { bind() } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt new file mode 100644 index 0000000..cf11b77 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt @@ -0,0 +1,165 @@ +package fr.ajaury.gwenedeg.player.data + +import fr.ajaury.gwenedeg.core.logging.domain.Logger +import fr.ajaury.gwenedeg.player.domain.AudioPlayer +import fr.ajaury.gwenedeg.player.domain.PlaybackRepository +import fr.ajaury.gwenedeg.player.model.PlaybackState +import fr.ajaury.gwenedeg.player.model.PlaybackTiming +import fr.ajaury.gwenedeg.records.domain.Record +import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader +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 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +internal class PlaybackRepositoryImpl( + private val audioPlayer: AudioPlayer, + private val resourceReader: ResourceReader, + private val subtitleRepository: SubtitleRepository, + private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase, + private val logger: Logger, +) : PlaybackRepository { + private val subtitleState = MutableStateFlow(Subtitle(emptyList())) + override val subtitle: StateFlow = subtitleState.asStateFlow() + + override val playbackState: StateFlow = audioPlayer.playbackState + + override val duration: Duration get() = audioPlayer.duration + + @OptIn(ExperimentalCoroutinesApi::class) + override val currentPosition: Flow = playbackState.flatMapLatest { state -> + if (state == PlaybackState.PLAYING) { + flow { + while (true) { + emit(audioPlayer.currentPosition) + delay(POSITION_POLL_INTERVAL) + } + } + } else { + flowOf(audioPlayer.currentPosition) + } + } + + override val playbackTiming = currentPosition.map { currentPosition -> + PlaybackTiming( + positionMs = currentPosition.inWholeMilliseconds, + durationMs = duration.inWholeMilliseconds, + ) + } + + override val currentSubtitleIndex: Flow = combine( + subtitleState, + currentPosition, + ) { subtitle, position -> + getCurrentSubtitleIndex( + subtitle = subtitle, + position = position, + ) + } + + override suspend fun load(record: Record) { + playAudio(filePath = record.audioResourcePath) + subtitleState.value = loadSubtitle(record = record) + } + + private fun playAudio(filePath: String) { + try { + audioPlayer.load(uri = Res.getUri(filePath)) + audioPlayer.play() + } catch (exception: Exception) { + logger.error(message = "Failed to play audio: $filePath", throwable = exception) + } + } + + private suspend fun loadSubtitle(record: Record): Subtitle = + try { + val content = resourceReader.read(resourcePath = record.subtitleResourcePath) + val translationContent = record.translationSubtitleResourcePath + ?.let { resourceReader.read(resourcePath = it) } + subtitleRepository.getSubtitle( + content = content, + translationContent = translationContent, + ) + } catch (exception: Exception) { + logger.error( + message = "Failed to load subtitle: ${record.subtitleResourcePath}", + throwable = exception, + ) + Subtitle(emptyList()) + } + + override fun play() { + audioPlayer.play() + } + + override fun pause() { + audioPlayer.pause() + } + + override fun replay() { + audioPlayer.seekTo(Duration.ZERO) + audioPlayer.play() + } + + override fun seekTo(position: Duration) { + audioPlayer.seekTo(position) + } + + override fun seekToSentence(sentenceIndex: Int) { + val matchingSubtitleLine = subtitleState.value.lines.getOrNull(sentenceIndex) ?: return + audioPlayer.seekTo(matchingSubtitleLine.startTime) + } + + override fun goToPreviousSentence() { + val lines = subtitleState.value.lines + val currentIndex = currentSentenceIndex() ?: return + val currentLine = lines.getOrNull(currentIndex) ?: return + val elapsedInSentence = audioPlayer.currentPosition - currentLine.startTime + val targetIndex = if (elapsedInSentence < PREVIOUS_SENTENCE_THRESHOLD) { + currentIndex - 1 + } else { + currentIndex + }.coerceAtLeast(0) + + seekToSentence(targetIndex) + } + + override fun goToNextSentence() { + val lines = subtitleState.value.lines + val currentIndex = currentSentenceIndex() ?: return + seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex)) + } + + private fun currentSentenceIndex(): Int? = + getCurrentSubtitleIndex( + subtitle = subtitleState.value, + position = audioPlayer.currentPosition, + ) + + override fun stop() { + audioPlayer.stop() + } + + override fun release() { + audioPlayer.release() + } + + companion object { + private val POSITION_POLL_INTERVAL = 50.milliseconds + private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt new file mode 100644 index 0000000..7062e77 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt @@ -0,0 +1,50 @@ +package fr.ajaury.gwenedeg.player.domain + +import fr.ajaury.gwenedeg.player.model.PlaybackState +import fr.ajaury.gwenedeg.player.model.PlaybackTiming +import fr.ajaury.gwenedeg.records.domain.Record +import fr.ajaury.gwenedeg.subtitle.domain.Subtitle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration + +/** + * Single source of truth for the current playback: owns the [AudioPlayer] together with the + * subtitle of the record being played, and exposes the combined state plus the operations to + * control it (play/pause, seeking, sentence navigation). + * + * Keeps audio and subtitle in sync. + */ +interface PlaybackRepository { + val playbackState: StateFlow + + val subtitle: StateFlow + + val currentPosition: Flow + + val currentSubtitleIndex: Flow + + val duration: Duration + + val playbackTiming: Flow + + suspend fun load(record: Record) + + fun play() + + fun pause() + + fun replay() + + fun seekTo(position: Duration) + + fun seekToSentence(sentenceIndex: Int) + + fun goToPreviousSentence() + + fun goToNextSentence() + + fun stop() + + fun release() +} 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 0853a4e..d17a8ab 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 @@ -29,6 +29,7 @@ import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import fr.ajaury.gwenedeg.theme.GwenedegTheme import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Duration.Companion.milliseconds @Composable fun PlayerScreen( @@ -119,9 +120,9 @@ fun PlayerScreen( private val previewSubtitleLines = listOf( - SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"), - SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"), - SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"), + SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"), + SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"), + SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"), ) @Preview diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt index 81ae8db..2a99b2c 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import fr.ajaury.gwenedeg.theme.GwenedegTheme import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds @Composable fun SubtitleList( @@ -190,9 +191,9 @@ private fun TranslationLine( private val previewSubtitleLines = listOf( - SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"), - SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"), - SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"), + SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"), + SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"), + SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"), ) @Preview 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 c1d6581..f58b4b9 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 @@ -3,27 +3,16 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import fr.ajaury.gwenedeg.core.logging.domain.Logger -import fr.ajaury.gwenedeg.player.domain.AudioPlayer import fr.ajaury.gwenedeg.player.domain.AudioSessionManager +import fr.ajaury.gwenedeg.player.domain.PlaybackRepository import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackTiming -import fr.ajaury.gwenedeg.records.domain.Record import fr.ajaury.gwenedeg.records.domain.RecordRepository -import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader -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 -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -32,57 +21,18 @@ import kotlin.time.Duration.Companion.seconds class PlayerViewModel( private val recordRepository: RecordRepository, - private val resourceReader: ResourceReader, - private val audioPlayer: AudioPlayer, + private val playbackRepository: PlaybackRepository, private val audioSessionManager: AudioSessionManager, - private val subtitleRepository: SubtitleRepository, - private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase, private val logger: Logger, ) : ViewModel() { - private val playbackState = audioPlayer.playbackState - private val recordTitle = MutableStateFlow("") - private val subtitle = MutableStateFlow(Subtitle(emptyList())) - - @OptIn(ExperimentalCoroutinesApi::class) - private val currentPosition = playbackState - .flatMapLatest { state -> - if (state == PlaybackState.PLAYING) { - flow { - while (true) { - emit(audioPlayer.currentPosition) - delay(POSITION_POLL_INTERVAL) - } - } - } else { - flowOf(audioPlayer.currentPosition) - } - } - - private val currentSubtitleIndex = combine( - subtitle, - currentPosition, - ) { subtitle, currentPosition -> - getCurrentSubtitleIndex( - subtitle = subtitle, - progressMs = currentPosition.inWholeMilliseconds, - ) - } - - private val playbackTiming = currentPosition.map { currentPosition -> - PlaybackTiming( - positionMs = currentPosition.inWholeMilliseconds, - durationMs = audioPlayer.duration.inWholeMilliseconds, - ) - } - val uiState: StateFlow = combine( recordTitle, - playbackState, - playbackTiming, - subtitle, - currentSubtitleIndex, + playbackRepository.playbackState, + playbackRepository.playbackTiming, + playbackRepository.subtitle, + playbackRepository.currentSubtitleIndex, ) { recordTitle, playbackState, playbackTiming, subtitle, currentSubtitleIndex -> PlayerUiState( recordTitle = recordTitle, @@ -110,100 +60,42 @@ class PlayerViewModel( } recordTitle.value = record.title - play(filePath = record.audioResourcePath) - loadSubtitle(record = record) - } - } - - private fun play(filePath: String) { - try { - audioPlayer.load(uri = Res.getUri(filePath)) - audioPlayer.play() - } catch (exception: Exception) { - logger.error(message = "Failed to play audio: $filePath", throwable = exception) - } - } - - private suspend fun loadSubtitle(record: Record) { - subtitle.value = try { - val content = resourceReader.read(resourcePath = record.subtitleResourcePath) - val translationContent = record.translationSubtitleResourcePath - ?.let { resourceReader.read(resourcePath = it) } - subtitleRepository.getSubtitle( - content = content, - translationContent = translationContent, - ) - } catch (exception: Exception) { - logger.error( - message = "Failed to load subtitle: ${record.subtitleResourcePath}", - throwable = exception, - ) - Subtitle(emptyList()) + playbackRepository.load(record = record) } } fun performMainPlayAction() { when (uiState.value.playbackState) { - PlaybackState.PLAYING -> audioPlayer.pause() - PlaybackState.ENDED -> replay() - PlaybackState.PAUSED, PlaybackState.IDLE -> audioPlayer.play() + PlaybackState.PLAYING -> playbackRepository.pause() + PlaybackState.ENDED -> playbackRepository.replay() + PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play() } } - private fun replay() { - audioPlayer.seekTo(0.milliseconds) - audioPlayer.play() - } - fun seekToTimePart(progress: Float) { val position = (progress.toDouble() * uiState.value.playbackTiming.durationMs).milliseconds - audioPlayer.seekTo(position) + playbackRepository.seekTo(position) } fun seekToSentence(sentenceIndex: Int) { - val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return - audioPlayer.seekTo(matchingSubtitleLine.startMs.milliseconds) + playbackRepository.seekToSentence(sentenceIndex) } fun goToPreviousSentence() { - val lines = subtitle.value.lines - val currentIndex = currentSentenceIndex() ?: return - val currentLine = lines.getOrNull(currentIndex) ?: return - val elapsedInSentence = - audioPlayer.currentPosition.inWholeMilliseconds - currentLine.startMs - val targetIndex = if (elapsedInSentence < PREVIOUS_SENTENCE_THRESHOLD.inWholeMilliseconds) { - (currentIndex - 1) - } else { - currentIndex - }.coerceAtLeast(0) - - seekToSentence(targetIndex) + playbackRepository.goToPreviousSentence() } fun goToNextSentence() { - val lines = subtitle.value.lines - val currentIndex = currentSentenceIndex() ?: return - seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex)) + playbackRepository.goToNextSentence() } - private fun currentSentenceIndex(): Int? = - getCurrentSubtitleIndex( - subtitle = subtitle.value, - progressMs = audioPlayer.currentPosition.inWholeMilliseconds, - ) - fun stop() { - audioPlayer.stop() + playbackRepository.stop() } override fun onCleared() { super.onCleared() audioSessionManager.deactivate() - audioPlayer.release() - } - - companion object { - private val POSITION_POLL_INTERVAL = 50.milliseconds - private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds + playbackRepository.release() } }