Coverage Summary for Class: Player (com.vsevolodganin.clicktrack.player)
Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
Player |
0%
(0/14)
|
7.1%
(1/14)
|
0%
(0/66)
|
0%
(0/361)
|
Player$Const |
0%
(0/2)
|
|
0%
(0/2)
|
0%
(0/3)
|
Player$externalPlaybackState$2 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/14)
|
Player$externalPlaybackState$3 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/8)
|
0%
(0/49)
|
Player$Input |
0%
(0/1)
|
|
0%
(0/4)
|
0%
(0/17)
|
Player$InternalPlaybackState |
0%
(0/3)
|
|
0%
(0/7)
|
0%
(0/32)
|
Player$pausable$2 |
0%
(0/1)
|
0%
(0/8)
|
0%
(0/3)
|
0%
(0/62)
|
Player$play$1 |
|
Player$play$10 |
0%
(0/2)
|
0%
(0/10)
|
0%
(0/15)
|
0%
(0/152)
|
Player$play$2 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/23)
|
Player$play$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/32)
|
Player$play$5 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/2)
|
0%
(0/30)
|
Player$play$5$invokeSuspend$$inlined$pausable$1 |
0%
(0/1)
|
|
Player$play$6 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/1)
|
0%
(0/21)
|
Player$play$6$invokeSuspend$$inlined$pausable$1 |
0%
(0/1)
|
|
Player$play$8 |
0%
(0/2)
|
0%
(0/10)
|
0%
(0/15)
|
0%
(0/155)
|
Player$soundsById$$inlined$map$1 |
0%
(0/2)
|
|
Player$soundsById$$inlined$map$1$2 |
0%
(0/1)
|
|
Player$soundsById$$inlined$map$1$2$1 |
|
Player$soundSourceProvider$1 |
|
Player$special$$inlined$map$1 |
0%
(0/2)
|
|
Player$special$$inlined$map$1$2 |
0%
(0/1)
|
|
Player$special$$inlined$map$1$2$1 |
|
Total |
0%
(0/39)
|
1.9%
(1/52)
|
0%
(0/126)
|
0%
(0/951)
|
package com.vsevolodganin.clicktrack.player
import com.vsevolodganin.clicktrack.di.component.PlayerServiceScope
import com.vsevolodganin.clicktrack.di.module.PlayerDispatcher
import com.vsevolodganin.clicktrack.model.ClickSounds
import com.vsevolodganin.clicktrack.model.ClickSoundsId
import com.vsevolodganin.clicktrack.model.ClickTrack
import com.vsevolodganin.clicktrack.model.ClickTrackId
import com.vsevolodganin.clicktrack.model.PlayProgress
import com.vsevolodganin.clicktrack.model.PlayableId
import com.vsevolodganin.clicktrack.model.PlayableProgressTimeMark
import com.vsevolodganin.clicktrack.model.PlayableProgressTimeSource
import com.vsevolodganin.clicktrack.model.TwoLayerPolyrhythm
import com.vsevolodganin.clicktrack.model.TwoLayerPolyrhythmId
import com.vsevolodganin.clicktrack.primitiveaudio.PrimitiveAudioPlayer
import com.vsevolodganin.clicktrack.soundlibrary.SoundSourceProvider
import com.vsevolodganin.clicktrack.soundlibrary.UserSelectedSounds
import com.vsevolodganin.clicktrack.storage.ClickSoundsRepository
import com.vsevolodganin.clicktrack.utils.grabIf
import com.vsevolodganin.clicktrack.utils.log.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.withContext
import me.tatarka.inject.annotations.Inject
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@PlayerServiceScope
@Inject
class Player(
private val playerDispatcher: PlayerDispatcher,
private val primitiveAudioPlayer: PrimitiveAudioPlayer,
private val clickSoundsRepository: ClickSoundsRepository,
private val playableContentProvider: PlayableContentProvider,
private val userSelectedSounds: UserSelectedSounds,
latencyTracker: LatencyTracker,
private val logger: Logger,
) {
data class Input(
val id: PlayableId,
val startAtProgress: Double?,
val soundsId: ClickSoundsId?,
)
private val playbackState = MutableStateFlow<InternalPlaybackState?>(null)
private val pausedState = MutableStateFlow(false)
suspend fun play(input: Flow<Input>) {
try {
coroutineScope {
input.collectLatest { (id, startAtProgress, soundsId) ->
play(id, startAtProgress, soundsId)
}
}
} catch (_: CancellationException) {
// Cancelling from inside means normal playback end, hence ignore
} finally {
playbackState.value = null
}
}
fun pause() {
pausedState.value = true
}
fun resume() {
pausedState.value = false
}
private suspend fun CoroutineScope.play(id: PlayableId, startAtProgress: Double?, soundsId: ClickSoundsId?) {
when (id) {
is ClickTrackId -> {
playableContentProvider.clickTrackFlow(id)
.withIndex()
.collectLatest inner@{ (index, clickTrack) ->
clickTrack ?: return@inner
pausable(if (index == 0) startAtProgress else null) { progress ->
play(id, clickTrack, progress, soundsId)
cancel()
}
}
}
TwoLayerPolyrhythmId -> {
playableContentProvider.twoLayerPolyrhythmFlow()
.withIndex()
.collectLatest { (index, polyrhythm) ->
pausable(if (index == 0) startAtProgress else null) { progress ->
play(polyrhythm, progress, soundsId)
cancel()
}
}
}
}
}
private suspend inline fun pausable(startAt: Double?, crossinline play: suspend (startAt: Double?) -> Unit) {
var savedProgress: Double? = startAt
pausedState.collectLatest { isPaused ->
if (isPaused) {
savedProgress = playbackState.value?.realProgress
} else {
play(savedProgress)
}
}
}
private suspend fun play(id: ClickTrackId, clickTrack: ClickTrack, atProgress: Double?, soundsId: ClickSoundsId?) {
withContext(playerDispatcher) {
val duration = clickTrack.durationInTime
if (duration == Duration.ZERO) {
logger.logError(TAG, "Tried to play track with zero duration, exiting")
return@withContext
}
val currentPlayback = playbackState.value
val progress = atProgress
?: grabIf(id == currentPlayback?.id) { currentPlayback?.realProgress }
?: 0.0
val startAt = duration * progress
clickTrack.play(
startAt = startAt,
reportProgress = {
playbackState.value = InternalPlaybackState(
id = id,
duration = duration,
position = it,
)
},
soundSourceProvider = soundSourceProvider(soundsId),
)
}
}
private suspend fun play(twoLayerPolyrhythm: TwoLayerPolyrhythm, atProgress: Double?, soundsId: ClickSoundsId?) {
withContext(playerDispatcher) {
val duration = twoLayerPolyrhythm.durationInTime
if (duration == Duration.ZERO) {
logger.logError(TAG, "Tried to play polyrhythm with zero duration, exiting")
return@withContext
}
val currentPlayback = playbackState.value
val progress = atProgress
?: grabIf(TwoLayerPolyrhythmId == currentPlayback?.id) { currentPlayback?.realProgress }
?: 0.0
val startAt = duration * progress
twoLayerPolyrhythm.play(
startAt = startAt,
reportProgress = {
playbackState.value = InternalPlaybackState(
id = TwoLayerPolyrhythmId,
duration = duration,
position = it,
)
},
soundSourceProvider = soundSourceProvider(soundsId),
)
}
}
fun playbackState(): Flow<PlaybackState?> = externalPlaybackState
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
private val externalPlaybackState = combine(
playbackState,
pausedState,
latencyTracker.latencyState
.map { LATENCY_RESOLUTION * (it / LATENCY_RESOLUTION).toInt() }
.distinctUntilChanged(),
) { internalPlaybackState, isPaused, latency -> Triple(internalPlaybackState, isPaused, latency) }
.mapLatest { (internalPlaybackState, isPaused, latency) ->
if (!isPaused) {
delay(latency)
}
internalPlaybackState ?: return@mapLatest null
PlaybackState(
id = internalPlaybackState.id,
progress = PlayProgress(
position = internalPlaybackState.realPosition - latency,
isPaused = isPaused,
),
)
}
.shareIn(GlobalScope, SharingStarted.Eagerly, 1)
private suspend fun ClickTrack.play(startAt: Duration, reportProgress: (Duration) -> Unit, soundSourceProvider: SoundSourceProvider) {
val durationInTime = durationInTime
val startingAt = if (startAt >= durationInTime) {
if (loop) Duration.ZERO else return
} else {
startAt
}
primitiveAudioPlayer.play(
startingAt = startingAt,
singleIterationDuration = durationInTime,
playerEvents = toPlayerEvents()
.loop(loop)
.startingAt(startingAt),
reportProgress = reportProgress,
soundSourceProvider = soundSourceProvider,
)
}
private suspend fun TwoLayerPolyrhythm.play(
startAt: Duration,
reportProgress: (Duration) -> Unit,
soundSourceProvider: SoundSourceProvider,
) {
val durationInTime = durationInTime
val startingAt = if (startAt >= durationInTime) {
Duration.ZERO
} else {
startAt
}
primitiveAudioPlayer.play(
startingAt = startingAt,
singleIterationDuration = durationInTime,
playerEvents = toPlayerEvents()
.loop(true)
.startingAt(startingAt),
reportProgress = reportProgress,
soundSourceProvider = soundSourceProvider,
)
}
@OptIn(DelicateCoroutinesApi::class)
private suspend fun soundSourceProvider(soundsId: ClickSoundsId?): SoundSourceProvider {
val soundsFlow = if (soundsId != null) soundsById(soundsId) else userSelectedSounds.get()
val soundsState = soundsFlow.stateIn(GlobalScope)
return SoundSourceProvider(soundsState)
}
private fun soundsById(soundsId: ClickSoundsId): Flow<ClickSounds?> {
return when (soundsId) {
is ClickSoundsId.Builtin -> flowOf(soundsId.value.sounds)
is ClickSoundsId.Database -> clickSoundsRepository.getById(soundsId).map { it?.value }
}
}
private class InternalPlaybackState(
val id: PlayableId,
val duration: Duration,
val position: Duration,
) {
private val emissionTime: PlayableProgressTimeMark = PlayableProgressTimeSource.markNow()
val realPosition: Duration get() = position + emissionTime.elapsedNow()
val realProgress: Double get() = realPosition / duration
}
private companion object Const {
const val TAG = "Player"
// Higher means lower precision when correcting UI for latency
// Lower means higher precision but more progress bar jumps due to more frequent updates
val LATENCY_RESOLUTION = 50.milliseconds
}
}