playback: add controller role

Add a controller role to PlaybackStateManager.

The controller manages the internal player, and acts as an intermediary
between other internal objects that want to modify the player, but
don't actually have access to it.

This makes a bunch of future changes far easier.
This commit is contained in:
OxygenCobalt 2022-06-19 16:23:40 -06:00
parent 29fe849565
commit b8cc153f07
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
2 changed files with 99 additions and 39 deletions

View file

@ -21,6 +21,7 @@ import android.content.Context
import kotlin.math.max import kotlin.math.max
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
@ -29,6 +30,7 @@ import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/** /**
* Master class (and possible god object) for the playback state. * Master class (and possible god object) for the playback state.
@ -39,10 +41,13 @@ import org.oxycblt.auxio.util.logD
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use * - If you want to use the playback state with the ExoPlayer instance or system-side things, use
* [org.oxycblt.auxio.playback.system.PlaybackService]. * [org.oxycblt.auxio.playback.system.PlaybackService].
* *
* Internal consumers should usually use [Callback], however the component that manages the player
* itself should instead operate as a [Controller].
*
* All access should be done with [PlaybackStateManager.getInstance]. * All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt * @author OxygenCobalt
* *
* TODO: Add a controller role and move song loading/seeking to that * TODO: Leverage synchronized here to prevent state issues.
* *
* TODO: Bug test app behavior when playback stops * TODO: Bug test app behavior when playback stops
*/ */
@ -89,16 +94,16 @@ class PlaybackStateManager private constructor() {
// --- CALLBACKS --- // --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
private var controller: Controller? = null
/** Add a callback to this instance. Make sure to remove it when done. */ /** Add a callback to this instance. Make sure to remove it when done. */
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
if (isInitialized) { if (isInitialized) {
callback.onNewPlayback(index, queue, parent) callback.onNewPlayback(index, queue, parent)
callback.onSeek(positionMs)
callback.onPositionChanged(positionMs) callback.onPositionChanged(positionMs)
callback.onPlayingChanged(isPlaying)
callback.onRepeatChanged(repeatMode) callback.onRepeatChanged(repeatMode)
callback.onShuffledChanged(isShuffled) callback.onShuffledChanged(isShuffled)
callback.onPlayingChanged(isPlaying)
} }
callbacks.add(callback) callbacks.add(callback)
@ -109,6 +114,34 @@ class PlaybackStateManager private constructor() {
callbacks.remove(callback) callbacks.remove(callback)
} }
/** Register a [PlaybackStateManager.Controller] with this instance. */
fun registerController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller != null) {
logW("Controller is already registered")
return
}
if (isInitialized) {
controller.loadSong(song)
controller.seekTo(positionMs)
controller.onPlayingChanged(isPlaying)
controller.onRepeatChanged(repeatMode)
controller.onPlayingChanged(isPlaying)
}
this.controller = controller
}
/** Unregister a [PlaybackStateManager.Controller] with this instance. */
fun unregisterController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) {
logW("Given controller did not match current controller")
return
}
this.controller = null
}
// --- PLAYING FUNCTIONS --- // --- PLAYING FUNCTIONS ---
/** /**
@ -177,7 +210,7 @@ class PlaybackStateManager private constructor() {
/** Go to the previous song, doing any checks that are needed. */ /** Go to the previous song, doing any checks that are needed. */
fun prev() { fun prev() {
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
if (settingsManager.rewindWithPrev && positionMs >= REWIND_THRESHOLD) { if (controller?.shouldPrevRewind() == true) {
rewind() rewind()
isPlaying = true isPlaying = true
} else { } else {
@ -279,7 +312,12 @@ class PlaybackStateManager private constructor() {
* @param positionMs The new position in millis. * @param positionMs The new position in millis.
* @see seekTo * @see seekTo
*/ */
fun synchronizePosition(positionMs: Long) { fun synchronizePosition(controller: Controller, positionMs: Long) {
if (BuildConfig.DEBUG && this.controller !== controller) {
logW("Given controller did not match current controller")
return
}
// Don't accept any bugged positions that are over the duration of the song. // Don't accept any bugged positions that are over the duration of the song.
val maxDuration = song?.durationMs ?: -1 val maxDuration = song?.durationMs ?: -1
if (positionMs <= maxDuration) { if (positionMs <= maxDuration) {
@ -289,13 +327,12 @@ class PlaybackStateManager private constructor() {
} }
/** /**
* **Seek** to a [positionMs], this calls [PlaybackStateManager.Callback.onSeek] to notify * **Seek** to a [positionMs].
* elements that rely on that.
* @param positionMs The position to seek to in millis. * @param positionMs The position to seek to in millis.
*/ */
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
this.positionMs = positionMs this.positionMs = positionMs
notifySeekEvent() controller?.seekTo(positionMs)
notifyPositionChanged() notifyPositionChanged()
} }
@ -378,6 +415,7 @@ class PlaybackStateManager private constructor() {
// --- CALLBACKS --- // --- CALLBACKS ---
private fun notifyIndexMoved() { private fun notifyIndexMoved() {
controller?.loadSong(song)
for (callback in callbacks) { for (callback in callbacks) {
callback.onIndexMoved(index) callback.onIndexMoved(index)
} }
@ -390,12 +428,14 @@ class PlaybackStateManager private constructor() {
} }
private fun notifyNewPlayback() { private fun notifyNewPlayback() {
controller?.loadSong(song)
for (callback in callbacks) { for (callback in callbacks) {
callback.onNewPlayback(index, queue, parent) callback.onNewPlayback(index, queue, parent)
} }
} }
private fun notifyPlayingChanged() { private fun notifyPlayingChanged() {
controller?.onPlayingChanged(isPlaying)
for (callback in callbacks) { for (callback in callbacks) {
callback.onPlayingChanged(isPlaying) callback.onPlayingChanged(isPlaying)
} }
@ -408,21 +448,38 @@ class PlaybackStateManager private constructor() {
} }
private fun notifyRepeatModeChanged() { private fun notifyRepeatModeChanged() {
controller?.onRepeatChanged(repeatMode)
for (callback in callbacks) { for (callback in callbacks) {
callback.onRepeatChanged(repeatMode) callback.onRepeatChanged(repeatMode)
} }
} }
private fun notifyShuffledChanged() { private fun notifyShuffledChanged() {
controller?.onShuffledChanged(isShuffled)
for (callback in callbacks) { for (callback in callbacks) {
callback.onShuffledChanged(isShuffled) callback.onShuffledChanged(isShuffled)
} }
} }
private fun notifySeekEvent() { /** Represents a class capable of managing the internal player. */
for (callback in callbacks) { interface Controller {
callback.onSeek(positionMs) /** Called when a new song should be loaded into the player. */
} fun loadSong(song: Song?)
/** Seek to [positionMs] in the player. */
fun seekTo(positionMs: Long)
/** Called when the class wants to determine whether it should rewind or skip back. */
fun shouldPrevRewind(): Boolean
/** Called when the playing state is changed. */
fun onPlayingChanged(isPlaying: Boolean)
/** Called when the repeat mode is changed. */
fun onRepeatChanged(repeatMode: RepeatMode)
/** Called when the shuffled state is changed. */
fun onShuffledChanged(isShuffled: Boolean)
} }
/** /**
@ -430,21 +487,29 @@ class PlaybackStateManager private constructor() {
* [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback]. * [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
*/ */
interface Callback { interface Callback {
/** Called when the index is moved, but the queue does not change. This changes the song. */
fun onIndexMoved(index: Int) {} fun onIndexMoved(index: Int) {}
/** Called when the queue and/or index changed, but the song has not. */
fun onQueueChanged(index: Int, queue: List<Song>) {} fun onQueueChanged(index: Int, queue: List<Song>) {}
/** Called when playback is changed completely, with a new index, queue, and parent. */
fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {} fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {}
/** Called when the playing state is changed. */
fun onPlayingChanged(isPlaying: Boolean) {} fun onPlayingChanged(isPlaying: Boolean) {}
fun onPositionChanged(positionMs: Long) {}
fun onRepeatChanged(repeatMode: RepeatMode) {}
fun onShuffledChanged(isShuffled: Boolean) {}
fun onSeek(positionMs: Long) {} /** Called when the position is re-synchronized by the controller. */
fun onPositionChanged(positionMs: Long) {}
/** Called when the repeat mode is changed. */
fun onRepeatChanged(repeatMode: RepeatMode) {}
/** Called when the shuffled state is changed. */
fun onShuffledChanged(isShuffled: Boolean) {}
} }
companion object { companion object {
private const val REWIND_THRESHOLD = 3000L
@Volatile private var INSTANCE: PlaybackStateManager? = null @Volatile private var INSTANCE: PlaybackStateManager? = null
/** Get/Instantiate the single instance of [PlaybackStateManager]. */ /** Get/Instantiate the single instance of [PlaybackStateManager]. */

View file

@ -46,7 +46,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
@ -74,7 +73,7 @@ class PlaybackService :
Service(), Service(),
Player.Listener, Player.Listener,
NotificationComponent.Callback, NotificationComponent.Callback,
PlaybackStateManager.Callback, PlaybackStateManager.Controller,
SettingsManager.Callback { SettingsManager.Callback {
// Player components // Player components
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
@ -111,7 +110,7 @@ class PlaybackService :
positionScope.launch { positionScope.launch {
while (true) { while (true) {
playbackManager.synchronizePosition(player.currentPosition) playbackManager.synchronizePosition(this@PlaybackService, player.currentPosition)
delay(POS_POLL_INTERVAL) delay(POS_POLL_INTERVAL)
} }
} }
@ -139,7 +138,7 @@ class PlaybackService :
// --- PLAYBACKSTATEMANAGER SETUP --- // --- PLAYBACKSTATEMANAGER SETUP ---
playbackManager.addCallback(this) playbackManager.registerController(this)
settingsManager.addCallback(this) settingsManager.addCallback(this)
logD("Service created") logD("Service created")
@ -166,7 +165,7 @@ class PlaybackService :
// Pause just in case this destruction was unexpected. // Pause just in case this destruction was unexpected.
playbackManager.isPlaying = false playbackManager.isPlaying = false
playbackManager.removeCallback(this) playbackManager.unregisterController(this)
settingsManager.removeCallback(this) settingsManager.removeCallback(this)
unregisterReceiver(systemReceiver) unregisterReceiver(systemReceiver)
serviceJob.cancel() serviceJob.cancel()
@ -218,7 +217,7 @@ class PlaybackService :
reason: Int reason: Int
) { ) {
if (reason == Player.DISCONTINUITY_REASON_SEEK) { if (reason == Player.DISCONTINUITY_REASON_SEEK) {
playbackManager.synchronizePosition(player.currentPosition) playbackManager.synchronizePosition(this, player.currentPosition)
} }
} }
@ -239,17 +238,9 @@ class PlaybackService :
} }
} }
// --- PLAYBACK STATE CALLBACK OVERRIDES --- // --- CONTROLLER OVERRIDES ---
override fun onIndexMoved(index: Int) { override fun loadSong(song: Song?) {
loadSong(playbackManager.song)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
loadSong(playbackManager.song)
}
private fun loadSong(song: Song?) {
if (song == null) { if (song == null) {
// Clear if there's nothing to play. // Clear if there's nothing to play.
logD("Nothing playing, stopping playback") logD("Nothing playing, stopping playback")
@ -264,6 +255,14 @@ class PlaybackService :
notificationComponent.updateMetadata(song, playbackManager.parent) notificationComponent.updateMetadata(song, playbackManager.parent)
} }
override fun seekTo(positionMs: Long) {
logD("Seeking to ${positionMs}ms")
player.seekTo(positionMs)
}
override fun shouldPrevRewind() =
settingsManager.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun onPlayingChanged(isPlaying: Boolean) { override fun onPlayingChanged(isPlaying: Boolean) {
player.playWhenReady = isPlaying player.playWhenReady = isPlaying
notificationComponent.updatePlaying(isPlaying) notificationComponent.updatePlaying(isPlaying)
@ -281,11 +280,6 @@ class PlaybackService :
} }
} }
override fun onSeek(positionMs: Long) {
logD("Seeking to ${positionMs}ms")
player.seekTo(positionMs)
}
// --- SETTINGSMANAGER OVERRIDES --- // --- SETTINGSMANAGER OVERRIDES ---
override fun onReplayGainSettingsChanged() { override fun onReplayGainSettingsChanged() {
@ -432,6 +426,7 @@ class PlaybackService :
companion object { companion object {
private const val POS_POLL_INTERVAL = 1000L private const val POS_POLL_INTERVAL = 1000L
private const val REWIND_THRESHOLD = 3000L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"