new: modularize data.playback into a dedicated module
This commit is contained in:
@@ -39,6 +39,7 @@ kotlin {
|
||||
implementation(projects.core.model)
|
||||
|
||||
// Data modules
|
||||
implementation(projects.data.playback)
|
||||
implementation(projects.data.preferences)
|
||||
implementation(projects.data.records)
|
||||
implementation(projects.data.resources)
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
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.di.playbackModule
|
||||
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
|
||||
import fr.ajaury.gwenedeg.preferences.di.preferencesModule
|
||||
import fr.ajaury.gwenedeg.records.di.recordsModule
|
||||
import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel
|
||||
import fr.ajaury.gwenedeg.resources.di.resourcesModule
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val sharedModule = module {
|
||||
includes(loggingModule)
|
||||
includes(playbackModule)
|
||||
includes(preferencesModule)
|
||||
includes(recordsModule)
|
||||
includes(resourcesModule)
|
||||
singleOf(::PlaybackRepositoryImpl) { bind<PlaybackRepository>() }
|
||||
viewModelOf(::RecordsViewModel)
|
||||
viewModelOf(::PlayerViewModel)
|
||||
}
|
||||
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user