new: modularize data.playback into a dedicated module

This commit is contained in:
2026-07-03 09:53:07 +02:00
parent d483174ddc
commit 4d397ac5e8
9 changed files with 46 additions and 5 deletions
+30
View File
@@ -0,0 +1,30 @@
plugins {
id("gwenedeg.kmp.library")
}
kotlin {
androidLibrary {
namespace = "fr.ajaury.gwenedeg.playback"
}
sourceSets {
commonMain.dependencies {
// Core modules
implementation(projects.core.audioplayer)
implementation(projects.core.logging)
implementation(projects.core.model)
// Data modules
implementation(projects.data.records)
implementation(projects.data.resources)
implementation(projects.data.subtitle)
// Coroutines
implementation(libs.kotlinx.coroutinesCore)
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
}
}
}
@@ -0,0 +1,182 @@
package fr.ajaury.gwenedeg.player.data
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.core.model.Phrase
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.player.model.PlayerState
import fr.ajaury.gwenedeg.records.model.Record
import fr.ajaury.gwenedeg.resources.domain.ResourceReader
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
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.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 recordTitle = MutableStateFlow(Phrase(transcription = ""))
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
private val playbackState: StateFlow<PlaybackState> = audioPlayer.playbackState
private val duration: Duration get() = audioPlayer.duration
@OptIn(ExperimentalCoroutinesApi::class)
private val currentPosition: Flow<Duration> = playbackState.flatMapLatest { state ->
if (state in listOf(PlaybackState.PLAYING, PlaybackState.PAUSED)) {
flow {
while (true) {
emit(audioPlayer.currentPosition)
delay(POSITION_POLL_INTERVAL)
}
}
} else {
flowOf(audioPlayer.currentPosition)
}
}
private val playbackTiming = currentPosition.map { currentPosition ->
PlaybackTiming(
positionMs = currentPosition.inWholeMilliseconds,
durationMs = duration.inWholeMilliseconds,
)
}
private val currentSubtitleIndex: Flow<Int?> = combine(
subtitle,
currentPosition,
) { subtitle, position ->
getCurrentSubtitleIndex(
subtitle = subtitle,
position = position,
)
}
override val playerState: Flow<PlayerState> = combine(
playbackState,
subtitle,
playbackTiming,
currentSubtitleIndex,
recordTitle,
) { playbackState, subtitle, playbackTiming, currentSubtitleIndex, recordTitle ->
PlayerState(
playbackState = playbackState,
playbackTiming = playbackTiming,
subtitleLines = subtitle.lines,
currentSubtitleIndex = currentSubtitleIndex,
recordTitle = recordTitle,
)
}
override suspend fun load(record: Record) {
playAudio(filePath = record.audioResourcePath)
subtitle.value = loadSubtitle(record = record)
recordTitle.value = record.title
}
private fun playAudio(filePath: String) {
try {
audioPlayer.load(uri = resourceReader.uri(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(
transcriptionContent = 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 = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
}
override fun goToPreviousSentence() {
val lines = subtitle.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 = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex))
}
private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex(
subtitle = subtitle.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,12 @@
package fr.ajaury.gwenedeg.player.di
import fr.ajaury.gwenedeg.player.data.PlaybackRepositoryImpl
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val playbackModule: Module = module {
singleOf(::PlaybackRepositoryImpl) { bind<PlaybackRepository>() }
}
@@ -0,0 +1,37 @@
package fr.ajaury.gwenedeg.player.domain
import fr.ajaury.gwenedeg.player.model.PlayerState
import fr.ajaury.gwenedeg.records.model.Record
import kotlinx.coroutines.flow.Flow
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 playerState: Flow<PlayerState>
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()
}
@@ -0,0 +1,9 @@
package fr.ajaury.gwenedeg.player.model
data class PlaybackTiming(
val positionMs: Long = 0L,
val durationMs: Long = 0L,
) {
val progress: Float
get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
}
@@ -0,0 +1,12 @@
package fr.ajaury.gwenedeg.player.model
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
data class PlayerState(
val recordTitle: Phrase = Phrase(transcription = ""),
val playbackState: PlaybackState = PlaybackState.IDLE,
val playbackTiming: PlaybackTiming = PlaybackTiming(),
val subtitleLines: List<SubtitleLine> = emptyList(),
val currentSubtitleIndex: Int? = null,
)