new: implement PlaybackRepository to centralize playback and subtitle management across app

This commit is contained in:
2026-06-26 14:21:25 +02:00
parent e8bab1ef88
commit a9d3402d79
12 changed files with 275 additions and 152 deletions
@@ -2,6 +2,7 @@ package fr.ajaury.gwenedeg.subtitle.data
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine 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. * Parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) subtitle content.
@@ -17,7 +18,7 @@ internal class LrcParser {
val lines = content val lines = content
.lineSequence() .lineSequence()
.flatMap { line -> parseLine(line) } .flatMap { line -> parseLine(line) }
.sortedBy { it.startMs } .sortedBy { it.startTime }
.toList() .toList()
return Subtitle(lines) return Subtitle(lines)
} }
@@ -28,7 +29,7 @@ internal class LrcParser {
return timeTag return timeTag
.findAll(tags.value) .findAll(tags.value)
.mapNotNull { match -> .mapNotNull { match ->
SubtitleLine(startMs = match.toMs(), text = text) SubtitleLine(startTime = match.toMs().milliseconds, text = text)
.takeIf { it.text.isNotBlank() } .takeIf { it.text.isNotBlank() }
}.toList() }.toList()
} }
@@ -31,10 +31,10 @@ internal class LrcSubtitleRepository(
/** Attaches each [translation] line's text to the matching original line, paired by timestamp. */ /** Attaches each [translation] line's text to the matching original line, paired by timestamp. */
private fun Subtitle.mergeTranslation(translation: Subtitle?): Subtitle { private fun Subtitle.mergeTranslation(translation: Subtitle?): Subtitle {
if (translation == null) return this 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( return Subtitle(
lines = lines.map { line -> lines = lines.map { line ->
line.copy(translation = translationByStart[line.startMs]) line.copy(translation = translationByStart[line.startTime])
}, },
) )
} }
@@ -1,8 +1,10 @@
package fr.ajaury.gwenedeg.subtitle.domain package fr.ajaury.gwenedeg.subtitle.domain
import kotlin.time.Duration
class GetCurrentSubtitleIndexUseCase { class GetCurrentSubtitleIndexUseCase {
operator fun invoke( operator fun invoke(
subtitle: Subtitle, subtitle: Subtitle,
progressMs: Long, position: Duration,
): Int? = subtitle.lines.indexOfLast { it.startMs <= progressMs }.takeIf { it >= 0 } ): Int? = subtitle.lines.indexOfLast { it.startTime <= position }.takeIf { it >= 0 }
} }
@@ -1,11 +1,13 @@
package fr.ajaury.gwenedeg.subtitle.domain 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. * translation of [text] in the user's language (e.g. French), shown alongside the original.
*/ */
data class SubtitleLine( data class SubtitleLine(
val startMs: Long, val startTime: Duration,
val text: String, val text: String,
val translation: String? = null, val translation: String? = null,
) )
@@ -5,6 +5,7 @@ import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.milliseconds
class GetCurrentSubtitleIndexUseCaseTest { class GetCurrentSubtitleIndexUseCaseTest {
private val getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase() private val getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase()
@@ -13,32 +14,35 @@ class GetCurrentSubtitleIndexUseCaseTest {
Subtitle( Subtitle(
lines = lines =
listOf( listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !"), SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !"),
SubtitleLine(startMs = 4310, text = "Kenavo !"), SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"), SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !"),
), ),
) )
@Test @Test
fun returns_minus_one_before_the_first_cue() { fun returns_minus_one_before_the_first_cue() {
assertEquals(null, getCurrentSubtitleIndex(subtitle, progressMs = -1)) assertEquals(null, getCurrentSubtitleIndex(subtitle, position = (-1).milliseconds))
} }
@Test @Test
fun returns_the_index_at_and_after_each_cue_start() { fun returns_the_index_at_and_after_each_cue_start() {
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 0)) assertEquals(0, getCurrentSubtitleIndex(subtitle, position = 0.milliseconds))
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 4309)) assertEquals(0, getCurrentSubtitleIndex(subtitle, position = 4309.milliseconds))
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 4310)) assertEquals(1, getCurrentSubtitleIndex(subtitle, position = 4310.milliseconds))
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 5000)) assertEquals(1, getCurrentSubtitleIndex(subtitle, position = 5000.milliseconds))
} }
@Test @Test
fun returns_the_last_index_past_the_end() { fun returns_the_last_index_past_the_end() {
assertEquals(2, getCurrentSubtitleIndex(subtitle, progressMs = 50_000)) assertEquals(2, getCurrentSubtitleIndex(subtitle, position = 50_000.milliseconds))
} }
@Test @Test
fun returns_minus_one_for_an_empty_subtitle() { 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),
)
} }
} }
@@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.subtitle.data.LrcParser import fr.ajaury.gwenedeg.subtitle.data.LrcParser
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.milliseconds
class LrcParserTest { class LrcParserTest {
private val parser = LrcParser() private val parser = LrcParser()
@@ -31,16 +32,16 @@ class LrcParserTest {
fun converts_timestamp_to_milliseconds() { fun converts_timestamp_to_milliseconds() {
val subtitle = parser.parse(sample) val subtitle = parser.parse(sample)
assertEquals(0, subtitle.lines[0].startMs) assertEquals(0.milliseconds, subtitle.lines[0].startTime)
assertEquals(4310, subtitle.lines[1].startMs) assertEquals(4310.milliseconds, subtitle.lines[1].startTime)
assertEquals(7350, subtitle.lines[2].startMs) assertEquals(7350.milliseconds, subtitle.lines[2].startTime)
} }
@Test @Test
fun parses_three_digit_fraction_as_milliseconds() { fun parses_three_digit_fraction_as_milliseconds() {
val subtitle = parser.parse("[00:01.250]Hello") val subtitle = parser.parse("[00:01.250]Hello")
assertEquals(1250, subtitle.lines.single().startMs) assertEquals(1250.milliseconds, subtitle.lines.single().startTime)
} }
@Test @Test
@@ -1,6 +1,8 @@
package fr.ajaury.gwenedeg.di package fr.ajaury.gwenedeg.di
import fr.ajaury.gwenedeg.core.logging.di.loggingModule 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.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.records.data.InMemoryRecordRepository import fr.ajaury.gwenedeg.records.data.InMemoryRecordRepository
import fr.ajaury.gwenedeg.records.domain.RecordRepository 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 fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader
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
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
val sharedModule = module { val sharedModule = module {
includes(loggingModule) includes(loggingModule)
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() } factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
singleOf(::PlaybackRepositoryImpl) { bind<PlaybackRepository>() }
viewModelOf(::RecordsViewModel) viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel) viewModelOf(::PlayerViewModel)
factoryOf(::ComposeResourceReader) { bind<ResourceReader>() } factoryOf(::ComposeResourceReader) { bind<ResourceReader>() }
@@ -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<Subtitle> = subtitleState.asStateFlow()
override val playbackState: StateFlow<PlaybackState> = audioPlayer.playbackState
override val duration: Duration get() = audioPlayer.duration
@OptIn(ExperimentalCoroutinesApi::class)
override val currentPosition: Flow<Duration> = 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<Int?> = 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
}
}
@@ -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<PlaybackState>
val subtitle: StateFlow<Subtitle>
val currentPosition: Flow<Duration>
val currentSubtitleIndex: Flow<Int?>
val duration: Duration
val playbackTiming: Flow<PlaybackTiming>
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()
}
@@ -29,6 +29,7 @@ import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.theme.GwenedegTheme import fr.ajaury.gwenedeg.theme.GwenedegTheme
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun PlayerScreen( fun PlayerScreen(
@@ -119,9 +120,9 @@ fun PlayerScreen(
private val previewSubtitleLines = private val previewSubtitleLines =
listOf( listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"), SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"), SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"), SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"),
) )
@Preview @Preview
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.theme.GwenedegTheme import fr.ajaury.gwenedeg.theme.GwenedegTheme
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun SubtitleList( fun SubtitleList(
@@ -190,9 +191,9 @@ private fun TranslationLine(
private val previewSubtitleLines = private val previewSubtitleLines =
listOf( listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"), SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"), SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"), SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"),
) )
@Preview @Preview
@@ -3,27 +3,16 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import fr.ajaury.gwenedeg.core.logging.domain.Logger 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.AudioSessionManager
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming 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.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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine 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.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -32,57 +21,18 @@ import kotlin.time.Duration.Companion.seconds
class PlayerViewModel( class PlayerViewModel(
private val recordRepository: RecordRepository, private val recordRepository: RecordRepository,
private val resourceReader: ResourceReader, private val playbackRepository: PlaybackRepository,
private val audioPlayer: AudioPlayer,
private val audioSessionManager: AudioSessionManager, private val audioSessionManager: AudioSessionManager,
private val subtitleRepository: SubtitleRepository,
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
private val playbackState = audioPlayer.playbackState
private val recordTitle = MutableStateFlow("") 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<PlayerUiState> = combine( val uiState: StateFlow<PlayerUiState> = combine(
recordTitle, recordTitle,
playbackState, playbackRepository.playbackState,
playbackTiming, playbackRepository.playbackTiming,
subtitle, playbackRepository.subtitle,
currentSubtitleIndex, playbackRepository.currentSubtitleIndex,
) { recordTitle, playbackState, playbackTiming, subtitle, currentSubtitleIndex -> ) { recordTitle, playbackState, playbackTiming, subtitle, currentSubtitleIndex ->
PlayerUiState( PlayerUiState(
recordTitle = recordTitle, recordTitle = recordTitle,
@@ -110,100 +60,42 @@ class PlayerViewModel(
} }
recordTitle.value = record.title recordTitle.value = record.title
play(filePath = record.audioResourcePath) playbackRepository.load(record = record)
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())
} }
} }
fun performMainPlayAction() { fun performMainPlayAction() {
when (uiState.value.playbackState) { when (uiState.value.playbackState) {
PlaybackState.PLAYING -> audioPlayer.pause() PlaybackState.PLAYING -> playbackRepository.pause()
PlaybackState.ENDED -> replay() PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> audioPlayer.play() PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
} }
} }
private fun replay() {
audioPlayer.seekTo(0.milliseconds)
audioPlayer.play()
}
fun seekToTimePart(progress: Float) { fun seekToTimePart(progress: Float) {
val position = (progress.toDouble() * uiState.value.playbackTiming.durationMs).milliseconds val position = (progress.toDouble() * uiState.value.playbackTiming.durationMs).milliseconds
audioPlayer.seekTo(position) playbackRepository.seekTo(position)
} }
fun seekToSentence(sentenceIndex: Int) { fun seekToSentence(sentenceIndex: Int) {
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return playbackRepository.seekToSentence(sentenceIndex)
audioPlayer.seekTo(matchingSubtitleLine.startMs.milliseconds)
} }
fun goToPreviousSentence() { fun goToPreviousSentence() {
val lines = subtitle.value.lines playbackRepository.goToPreviousSentence()
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)
} }
fun goToNextSentence() { fun goToNextSentence() {
val lines = subtitle.value.lines playbackRepository.goToNextSentence()
val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex))
} }
private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex(
subtitle = subtitle.value,
progressMs = audioPlayer.currentPosition.inWholeMilliseconds,
)
fun stop() { fun stop() {
audioPlayer.stop() playbackRepository.stop()
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
audioSessionManager.deactivate() audioSessionManager.deactivate()
audioPlayer.release() playbackRepository.release()
}
companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
} }
} }