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
     }
 }