new: implement PlaybackRepository to centralize playback and subtitle management across app
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
+2
-2
@@ -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])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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 }
|
||||
}
|
||||
|
||||
+4
-2
@@ -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,
|
||||
)
|
||||
|
||||
+14
-10
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<RecordRepository>() }
|
||||
singleOf(::PlaybackRepositoryImpl) { bind<PlaybackRepository>() }
|
||||
viewModelOf(::RecordsViewModel)
|
||||
viewModelOf(::PlayerViewModel)
|
||||
factoryOf(::ComposeResourceReader) { bind<ResourceReader>() }
|
||||
|
||||
+165
@@ -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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-124
@@ -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<PlayerUiState> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user