All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.bjoernpetersen.musicbot.internal.player.ActorPlayer.kt Maven / Gradle / Ivy

There is a newer version: 0.25.0
Show newest version
package net.bjoernpetersen.musicbot.internal.player

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import net.bjoernpetersen.musicbot.api.player.DefaultSuggester
import net.bjoernpetersen.musicbot.api.player.ErrorState
import net.bjoernpetersen.musicbot.api.player.PauseState
import net.bjoernpetersen.musicbot.api.player.PlayState
import net.bjoernpetersen.musicbot.api.player.PlayerState
import net.bjoernpetersen.musicbot.api.player.ProgressTracker
import net.bjoernpetersen.musicbot.api.player.QueueEntry
import net.bjoernpetersen.musicbot.api.player.Song
import net.bjoernpetersen.musicbot.api.player.SongEntry
import net.bjoernpetersen.musicbot.api.player.StopState
import net.bjoernpetersen.musicbot.api.player.SuggestedSongEntry
import net.bjoernpetersen.musicbot.spi.loader.ResourceCache
import net.bjoernpetersen.musicbot.spi.player.Player
import net.bjoernpetersen.musicbot.spi.player.PlayerStateListener
import net.bjoernpetersen.musicbot.spi.player.QueueChangeListener
import net.bjoernpetersen.musicbot.spi.player.SongPlayedNotifier
import net.bjoernpetersen.musicbot.spi.player.SongQueue
import net.bjoernpetersen.musicbot.spi.plugin.AbstractPlayback
import net.bjoernpetersen.musicbot.spi.plugin.BrokenSuggesterException
import net.bjoernpetersen.musicbot.spi.plugin.Playback
import net.bjoernpetersen.musicbot.spi.plugin.PlaybackFeedbackChannel
import net.bjoernpetersen.musicbot.spi.plugin.PlaybackState
import net.bjoernpetersen.musicbot.spi.plugin.PluginLookup
import net.bjoernpetersen.musicbot.spi.plugin.Suggester
import java.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext

private sealed class PlayerMessage
private sealed class FeedbackPlayerMessage(val response: CompletableDeferred) :
    PlayerMessage()

private class Start(response: CompletableDeferred) : FeedbackPlayerMessage(response)

private data class StateChange(
    val oldState: PlayerState,
    val feedback: PlaybackState
) : PlayerMessage()

private data class AddListener(val listener: PlayerStateListener) : PlayerMessage()
private data class RemoveListener(val listener: PlayerStateListener) : PlayerMessage()
private class Play(response: CompletableDeferred) : FeedbackPlayerMessage(response)
private class Pause(response: CompletableDeferred) : FeedbackPlayerMessage(response)
private class Stop(response: CompletableDeferred) : FeedbackPlayerMessage(response)
private class Next(
    val oldState: PlayerState,
    response: CompletableDeferred
) : FeedbackPlayerMessage(response)

private class Await(response: CompletableDeferred) : FeedbackPlayerMessage(response)

/**
 * A playback implementation that doesn't actually do anything. The only way it ever ends is if
 * [close] is called.
 */
private class CompletablePlayback : AbstractPlayback() {

    override suspend fun play() {}
    override suspend fun pause() {}
}

/**
 * A player implementation that performs no synchronization at all, so it is not thread safe.
 *
 * This player should be used by a "synchronizing" player like [ActorPlayer].
 */
private class SyncPlayer @Inject private constructor(
    private val queue: SongQueue,
    defaultSuggester: DefaultSuggester,
    private val resourceCache: ResourceCache,
    private val pluginLookup: PluginLookup,
    private val songPlayedNotifier: SongPlayedNotifier,
    private val playbackFeedbackChannel: PlaybackFeedbackChannel
) : Player {

    private val logger = KotlinLogging.logger {}

    private val stateListeners: MutableSet = HashSet()

    /**
     * The current state of this player. This might be play, pause, stop or error.
     */
    override var state: PlayerState = StopState
        private set(value) {
            val old = field
            field = value
            logger.debug { "Now playing ${value.entry}" }
            for (listener in stateListeners) {
                listener(old, value)
            }
        }
    private var playback: Playback = CompletablePlayback()

    private val suggester: Suggester? = defaultSuggester.suggester

    suspend fun applyStateFeedback(feedback: PlaybackState) {
        val oldState = state
        when (feedback) {
            PlaybackState.PLAY -> if (oldState is PauseState) {
                logger.debug { "Changed to PLAY by Playback request" }
                state = oldState.play()
            }
            PlaybackState.PAUSE -> if (oldState is PlayState) {
                logger.debug { "Changed to PAUSE by Playback request" }
                state = oldState.pause()
            }
            PlaybackState.BROKEN -> if (state !is ErrorState) {
                logger.error { "Playback broke: ${playback::class.qualifiedName}" }
                state = ErrorState
                next()
            }
        }
    }

    suspend fun awaitCurrentPlayback() {
        logger.debug { "Awaiting playback $playback" }
        playback.waitForFinish()
    }

    override fun start() {
        // This player isn't active in any way.
    }

    override fun addListener(listener: PlayerStateListener) {
        stateListeners.add(listener)
    }

    override fun removeListener(listener: PlayerStateListener) {
        stateListeners.remove(listener)
    }

    override suspend fun play() {
        logger.debug("Playing...")
        when (val oldState = state) {
            is PlayState -> logger.debug { "Already playing." }
            !is PauseState -> {
                logger.debug { "Calling next because of play call in state ${oldState.name}" }
                next()
            }
            else -> {
                playback.play()
                state = oldState.play()
            }
        }
    }

    override suspend fun pause() {
        logger.debug("Pausing...")
        when (val oldState = state) {
            is PauseState -> logger.debug { "Already paused." }
            !is PlayState -> logger.info { "Tried to pause player in state $oldState" }
            else -> {
                playback.pause()
                state = oldState.pause()
            }
        }
    }

    override suspend fun next() {
        logger.debug("Next...")

        try {
            logger.debug { "Closing playback $playback" }
            playback.close()
        } catch (e: Exception) {
            logger.warn(e) { "Error closing playback" }
        }

        val nextQueueEntry = queue.pop()
        if (nextQueueEntry == null && suggester == null) {
            if (state !is StopState) logger.info("Queue is empty. Stopping.")
            playback = CompletablePlayback()
            state = StopState
            return
        }

        val nextEntry: SongEntry = nextQueueEntry ?: try {
            SuggestedSongEntry(suggester!!.suggestNext())
        } catch (e: BrokenSuggesterException) {
            logger.warn("Default suggester could not suggest anything. Stopping.")
            playback = CompletablePlayback()
            state = StopState
            return
        }

        val nextSong = nextEntry.song
        songPlayedNotifier.notifyPlayed(nextEntry)
        logger.debug { "Next song is: $nextSong" }
        try {
            val resource = resourceCache.get(nextSong)
            val provider = pluginLookup
                .lookup(nextSong.provider)
                ?: throw Exception("No such provider: ${nextSong.provider}")
            playback = provider.supplyPlayback(nextSong, resource)
        } catch (e: Exception) {
            logger.warn(e) { "Error creating playback" }

            playback = CompletablePlayback()
            state = ErrorState
            return
        }

        playback.setFeedbackChannel(playbackFeedbackChannel)
        state = PauseState(nextEntry)
        play()
    }

    override suspend fun close() {
        try {
            playback.close()
        } catch (e: Exception) {
            logger.error(e) { "Could not close playback" }
        }
        playback = CompletablePlayback()
        state = StopState
    }
}

@Suppress("EXPERIMENTAL_API_USAGE")
internal class ActorPlayer @Inject private constructor(
    private val syncPlayer: SyncPlayer,
    private val queue: SongQueue,
    defaultSuggester: DefaultSuggester,
    private val resourceCache: ResourceCache,
    feedbackChannel: ActorPlaybackFeedbackChannel,
    progressTracker: ProgressTracker
) : Player, CoroutineScope {

    private val logger = KotlinLogging.logger {}

    private val job = Job()
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Default + job

    private val suggester: Suggester? = defaultSuggester.suggester

    override val state: PlayerState
        get() = syncPlayer.state

    private val actor = actor {
        for (msg in channel) {
            try {
                when (msg) {
                    is Start -> {
                        syncPlayer.play()
                        msg.response.complete(Unit)
                    }
                    is AddListener -> syncPlayer.addListener(msg.listener)
                    is RemoveListener -> syncPlayer.removeListener(msg.listener)
                    is Play -> {
                        syncPlayer.play()
                        msg.response.complete(Unit)
                    }
                    is Pause -> {
                        syncPlayer.pause()
                        msg.response.complete(Unit)
                    }
                    is Next -> {
                        if (syncPlayer.state !== msg.oldState) {
                            logger.debug { "Skipping next call due to state change" }
                        } else {
                            syncPlayer.next()
                        }
                        msg.response.complete(Unit)
                    }
                    is Stop -> {
                        syncPlayer.close()
                        msg.response.complete(Unit)
                    }
                    is StateChange -> {
                        if (syncPlayer.state !== msg.oldState) {
                            logger.debug { "Ignoring playback state due to state change" }
                        } else {
                            syncPlayer.applyStateFeedback(msg.feedback)
                        }
                    }
                    is Await -> {
                        launch {
                            syncPlayer.awaitCurrentPlayback()
                            msg.response.complete(Unit)
                        }
                    }
                }
            } catch (e: Throwable) {
                if (msg is FeedbackPlayerMessage<*>) {
                    msg.response.completeExceptionally(e)
                }
                // TODO remove
                logger.warn(e) { "May be cancellation in ActorPlayer actor loop" }
                if (e is CancellationException) {
                    throw e
                }
                logger.error(e) { "Error in ActorPlayer actor loop" }
            }
        }
    }

    init {
        feedbackChannel.onStateChange = { feedback ->
            logger.trace { "Playback state update: $feedback" }
            try {
                launch { actor.send(StateChange(state, feedback)) }
            } catch (e: CancellationException) {
                logger.warn(e) { "Could not send playback feedback to actor" }
            }
        }

        addListener { old, new ->
            launch {
                if (!old.hasSong() && new.hasSong()) {
                    progressTracker.startSong()
                }

                if (new.hasSong() && new.entry?.song != old.entry?.song) {
                    progressTracker.reset()
                    progressTracker.startSong()
                }

                when (new) {
                    is PauseState -> progressTracker.startPause()
                    is PlayState -> progressTracker.stopPause()
                    ErrorState, StopState -> progressTracker.reset()
                }
            }
        }

        queue.addListener(object : QueueChangeListener {
            override fun onAdd(entry: QueueEntry) {
                launch { resourceCache.get(entry.song) }
            }

            override fun onRemove(entry: QueueEntry) {}

            override fun onMove(entry: QueueEntry, fromIndex: Int, toIndex: Int) {}
        })

        if (suggester != null) {
            addListener { _, _ -> preloadSuggestion(suggester) }
        }
    }


    override fun start() {
        launch {
            val response = CompletableDeferred()
            actor.send(Start(response))
            response.await()
            autoPlay()
        }
    }

    private fun preloadSuggestion(suggester: Suggester) {
        launch {
            if (queue.isEmpty) {
                val suggestions: List = try {
                    suggester.getNextSuggestions(1)
                } catch (e: BrokenSuggesterException) {
                    logger.warn(e) { "Default suggester could not suggest anything to preload" }
                    return@launch
                }
                resourceCache.get(suggestions[0])
            }
        }
    }

    override fun addListener(listener: PlayerStateListener) {
        launch {
            actor.send(AddListener(listener))
        }
    }

    override fun removeListener(listener: PlayerStateListener) {
        launch {
            actor.send(RemoveListener(listener))
        }
    }

    override suspend fun play() {
        withContext(coroutineContext) {
            val result = CompletableDeferred()
            actor.send(Play(result))
            result.await()
        }
    }

    override suspend fun pause() {
        withContext(coroutineContext) {
            val result = CompletableDeferred()
            actor.send(Pause(result))
            result.await()
        }
    }

    override suspend fun next() {
        val oldState = state
        withContext(coroutineContext) {
            val result = CompletableDeferred()
            actor.send(Next(oldState, result))
            result.await()
        }
    }

    private suspend fun autoPlay() {
        withContext(coroutineContext) {
            while (isActive) {
                val previousState = state
                logger.debug("Waiting for song to finish")
                val await = CompletableDeferred()
                actor.send(Await(await))
                await.await()
                logger.trace("Waiting done")

                // Prevent auto next calls if next was manually called
                if (state !== previousState) {
                    logger.debug("Skipping auto call to next()")
                } else {
                    logger.debug("Auto call to next()")
                    next()
                }
            }
        }
    }

    override suspend fun close() {
        withContext(coroutineContext) {
            val result = CompletableDeferred()
            actor.send(Stop(result))
            actor.close()
            result.await()
        }
        job.cancel()
    }
}

@Singleton
internal class ActorPlaybackFeedbackChannel @Inject private constructor(
    private val progressTracker: ProgressTracker
) : PlaybackFeedbackChannel {

    var onStateChange: ((PlaybackState) -> Unit)? = null
        set(value) {
            if (field != null) throw IllegalStateException("Already initialized")
            else field = value
        }

    override fun updateState(state: PlaybackState) {
        onStateChange?.let { it(state) }
    }

    override fun updateProgress(progress: Duration) {
        runBlocking {
            progressTracker.updateProgress(progress)
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy