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.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()
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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])
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-10
@@ -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>() }
|
||||||
|
|||||||
+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.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
|
||||||
|
|||||||
+16
-124
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user