playback: migrate to reactive model

Migrate the playback system to a reactive model where internalPlayer
is now the complete source of truth for the playback state.

This removes the observer pattern for positions and instead introduces
a new State datatype that allows consumers to reactively calculate
where the position probably is.

This is actually really great for efficiency and state coherency, and
is really what I was trying to aim for with previous (failed) reworks
to the playback system. There's probably some bugs, but way less than
the ground-up rewrites I tried before.

This also lays the groundwork for gapless playback, as the internal
player framework is now completely capable of having more functionality
borged into it.
This commit is contained in:
Alexander Capehart 2022-09-06 11:40:21 -06:00
parent a9bbdff25d
commit e5d7cdc340
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 212 additions and 172 deletions

View file

@ -45,8 +45,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* - The RecyclerView data for each fragment * - The RecyclerView data for each fragment
* - The sorts for each type of data * - The sorts for each type of data
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Unify how detail items are indicated [When playlists are implemented]
*/ */
class DetailViewModel(application: Application) : class DetailViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Callback { AndroidViewModel(application), MusicStore.Callback {

View file

@ -189,11 +189,7 @@ class Task(context: Context, private val raw: Song.Raw) {
val id = tag.key.sanitize().uppercase() val id = tag.key.sanitize().uppercase()
val value = tag.value.sanitize() val value = tag.value.sanitize()
if (value.isNotEmpty()) { if (value.isNotEmpty()) {
if (vorbisTags.containsKey(id)) { vorbisTags.getOrPut(id) { mutableListOf() }.add(value)
vorbisTags[id]!!.add(value)
} else {
vorbisTags[id] = mutableListOf(value)
}
} }
} }
} }

View file

@ -20,6 +20,8 @@ package org.oxycblt.auxio.playback
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -78,6 +80,8 @@ class PlaybackViewModel(application: Application) :
val currentAudioSessionId: Int? val currentAudioSessionId: Int?
get() = playbackManager.currentAudioSessionId get() = playbackManager.currentAudioSessionId
private var lastPositionJob: Job? = null
init { init {
playbackManager.addCallback(this) playbackManager.addCallback(this)
} }
@ -209,7 +213,7 @@ class PlaybackViewModel(application: Application) :
/** Flip the playing status, e.g from playing to paused */ /** Flip the playing status, e.g from playing to paused */
fun invertPlaying() { fun invertPlaying() {
playbackManager.isPlaying = !playbackManager.isPlaying playbackManager.changePlaying(!playbackManager.playerState.isPlaying)
} }
/** Flip the shuffle status, e.g from on to off. Will keep song by default. */ /** Flip the shuffle status, e.g from on to off. Will keep song by default. */
@ -268,12 +272,18 @@ class PlaybackViewModel(application: Application) :
_parent.value = playbackManager.parent _parent.value = playbackManager.parent
} }
override fun onPositionChanged(positionMs: Long) { override fun onStateChanged(state: InternalPlayer.State) {
_positionDs.value = positionMs.msToDs() _isPlaying.value = state.isPlaying
}
override fun onPlayingChanged(isPlaying: Boolean) { // Start watching the position again
_isPlaying.value = isPlaying lastPositionJob?.cancel()
lastPositionJob =
viewModelScope.launch {
while (true) {
_positionDs.value = state.calculateElapsedPosition().msToDs()
delay(100)
}
}
} }
override fun onShuffledChanged(isShuffled: Boolean) { override fun onShuffledChanged(isShuffled: Boolean) {

View file

@ -39,8 +39,6 @@ import org.oxycblt.auxio.util.logD
* Instead, we wrap it in a safe class that hopefully implements enough sanity checks to not crash * Instead, we wrap it in a safe class that hopefully implements enough sanity checks to not crash
* the app or result in blatantly janky behavior. Mostly. * the app or result in blatantly janky behavior. Mostly.
* *
* TODO: Add smooth seeking
*
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class StyledSeekBar class StyledSeekBar
@ -69,15 +67,19 @@ constructor(
var positionDs: Long var positionDs: Long
get() = binding.seekBarSlider.value.toLong() get() = binding.seekBarSlider.value.toLong()
set(value) { set(value) {
// Sanity check 1: Ensure that no negative values are sneaking their way into
// this component.
val from = max(value, 0)
// Sanity check: Ensure that this value is within the duration and will not crash // Sanity check: Ensure that this value is within the duration and will not crash
// the app, and that the user is not currently seeking (which would cause the SeekBar // the app, and that the user is not currently seeking (which would cause the SeekBar
// to jump around). // to jump around).
if (value <= durationDs && !isActivated) { if (from <= durationDs && !isActivated) {
binding.seekBarSlider.value = value.toFloat() binding.seekBarSlider.value = from.toFloat()
// We would want to keep this in the callback, but the callback only fires when // We would want to keep this in the callback, but the callback only fires when
// a value changes completely, and sometimes that does not happen with this view. // a value changes completely, and sometimes that does not happen with this view.
binding.seekBarPosition.text = value.formatDurationDs(true) binding.seekBarPosition.text = from.formatDurationDs(true)
} }
} }

View file

@ -18,6 +18,8 @@
package org.oxycblt.auxio.playback.state package org.oxycblt.auxio.playback.state
import android.net.Uri import android.net.Uri
import android.os.SystemClock
import android.support.v4.media.session.PlaybackStateCompat
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
/** Represents a class capable of managing the internal player. */ /** Represents a class capable of managing the internal player. */
@ -28,14 +30,16 @@ interface InternalPlayer {
/** Whether the player should rewind instead of going to the previous song. */ /** Whether the player should rewind instead of going to the previous song. */
val shouldRewindWithPrev: Boolean val shouldRewindWithPrev: Boolean
val currentState: State
/** Called when a new song should be loaded into the player. */ /** Called when a new song should be loaded into the player. */
fun loadSong(song: Song?) fun loadSong(song: Song?, play: Boolean)
/** Seek to [positionMs] in the player. */ /** Seek to [positionMs] in the player. */
fun seekTo(positionMs: Long) fun seekTo(positionMs: Long)
/** Called when the playing state is changed. */ /** Called when the playing state needs to be changed. */
fun onPlayingChanged(isPlaying: Boolean) fun changePlaying(isPlaying: Boolean)
/** /**
* Called when [PlaybackStateManager] desires some [Action] to be completed. Returns true if the * Called when [PlaybackStateManager] desires some [Action] to be completed. Returns true if the
@ -43,6 +47,77 @@ interface InternalPlayer {
*/ */
fun onAction(action: Action): Boolean fun onAction(action: Action): Boolean
class State
private constructor(
/**
* Whether the user has actually chosen to play this audio. The player might not actually be
* playing at this time.
*/
val isPlaying: Boolean,
/** Whether the player is actually advancing through the audio. */
private val isAdvancing: Boolean,
/** The initial position at update time. */
private val initPositionMs: Long,
/** The time this instance was created. */
private val creationTime: Long
) {
/**
* Calculate the estimated position that the player is now at. If the player's position is
* not advancing, this will be the initial position. Otherwise, this will be the position
* plus the elapsed time since this state was uploaded.
*/
fun calculateElapsedPosition() =
if (isAdvancing) {
initPositionMs + (SystemClock.elapsedRealtime() - creationTime)
} else {
// Not advancing due to buffering or some unrelated pausing, such as
// a transient audio focus change.
initPositionMs
}
/** Load this state into the analogous [PlaybackStateCompat.Builder]. */
fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
builder.setState(
if (isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
},
initPositionMs,
if (isAdvancing) {
1f
} else {
// Not advancing, so don't move the position.
0f
},
creationTime)
override fun equals(other: Any?) =
other is State &&
isPlaying == other.isPlaying &&
isAdvancing == other.isAdvancing &&
initPositionMs == other.initPositionMs
override fun hashCode(): Int {
var result = isPlaying.hashCode()
result = 31 * result + isAdvancing.hashCode()
result = 31 * result + initPositionMs.hashCode()
return result
}
companion object {
/** Create a new instance of this state. */
fun new(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
State(
// Minor sanity check: Make sure that advancing can't occur if the
// main playing value is paused.
isPlaying,
isPlaying && isAdvancing,
positionMs,
SystemClock.elapsedRealtime())
}
}
sealed class Action { sealed class Action {
object RestoreState : Action() object RestoreState : Action()
object ShuffleAll : Action() object ShuffleAll : Action()

View file

@ -52,6 +52,8 @@ import org.oxycblt.auxio.util.logW
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val callbacks = mutableListOf<Callback>()
private var internalPlayer: InternalPlayer? = null
/** The currently playing song. Null if there isn't one */ /** The currently playing song. Null if there isn't one */
val song val song
@ -67,14 +69,10 @@ class PlaybackStateManager private constructor() {
var index = -1 var index = -1
private set private set
/** Whether playback is playing or not */ /** The current state of the internal player. */
var isPlaying = false var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0)
set(value) { private set
field = value
notifyPlayingChanged()
}
/** The current playback progress */
private var positionMs = 0L
/** The current [RepeatMode] */ /** The current [RepeatMode] */
var repeatMode = RepeatMode.NONE var repeatMode = RepeatMode.NONE
set(value) { set(value) {
@ -94,22 +92,16 @@ class PlaybackStateManager private constructor() {
get() = internalPlayer?.audioSessionId get() = internalPlayer?.audioSessionId
/** An action that is awaiting the internal player instance to consume it. */ /** An action that is awaiting the internal player instance to consume it. */
var pendingAction: InternalPlayer.Action? = null private var pendingAction: InternalPlayer.Action? = null
// --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>()
private var internalPlayer: InternalPlayer? = 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. */
@Synchronized @Synchronized
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
if (isInitialized) { if (isInitialized) {
callback.onNewPlayback(index, queue, parent) callback.onNewPlayback(index, queue, parent)
callback.onPositionChanged(positionMs)
callback.onPlayingChanged(isPlaying)
callback.onRepeatChanged(repeatMode) callback.onRepeatChanged(repeatMode)
callback.onShuffledChanged(isShuffled) callback.onShuffledChanged(isShuffled)
callback.onStateChanged(playerState)
} }
callbacks.add(callback) callbacks.add(callback)
@ -130,10 +122,10 @@ class PlaybackStateManager private constructor() {
} }
if (isInitialized) { if (isInitialized) {
internalPlayer.loadSong(song) internalPlayer.loadSong(song, playerState.isPlaying)
internalPlayer.seekTo(positionMs) internalPlayer.seekTo(playerState.calculateElapsedPosition())
internalPlayer.onPlayingChanged(isPlaying)
requestAction(internalPlayer) requestAction(internalPlayer)
synchronizeState(internalPlayer)
} }
this.internalPlayer = internalPlayer this.internalPlayer = internalPlayer
@ -155,6 +147,7 @@ class PlaybackStateManager private constructor() {
/** Play a [song]. */ /** Play a [song]. */
@Synchronized @Synchronized
fun play(song: Song, playbackMode: PlaybackMode, settings: Settings) { fun play(song: Song, playbackMode: PlaybackMode, settings: Settings) {
val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return val library = musicStore.library ?: return
parent = parent =
@ -169,33 +162,44 @@ class PlaybackStateManager private constructor() {
} }
applyNewQueue(library, settings, settings.keepShuffle && isShuffled, song) applyNewQueue(library, settings, settings.keepShuffle && isShuffled, song)
notifyNewPlayback() notifyNewPlayback()
notifyShuffledChanged() notifyShuffledChanged()
isPlaying = true
internalPlayer.loadSong(song, true)
isInitialized = true isInitialized = true
} }
/** Play a [parent], such as an artist or album. */ /** Play a [parent], such as an artist or album. */
@Synchronized @Synchronized
fun play(parent: MusicParent, shuffled: Boolean, settings: Settings) { fun play(parent: MusicParent, shuffled: Boolean, settings: Settings) {
val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return val library = musicStore.library ?: return
this.parent = parent this.parent = parent
applyNewQueue(library, settings, shuffled, null) applyNewQueue(library, settings, shuffled, null)
notifyNewPlayback() notifyNewPlayback()
notifyShuffledChanged() notifyShuffledChanged()
isPlaying = true
internalPlayer.loadSong(song, true)
isInitialized = true isInitialized = true
} }
/** Shuffle all songs. */ /** Shuffle all songs. */
@Synchronized @Synchronized
fun shuffleAll(settings: Settings) { fun shuffleAll(settings: Settings) {
val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return val library = musicStore.library ?: return
parent = null parent = null
applyNewQueue(library, settings, true, null) applyNewQueue(library, settings, true, null)
notifyNewPlayback() notifyNewPlayback()
notifyShuffledChanged() notifyShuffledChanged()
isPlaying = true
internalPlayer.loadSong(song, true)
isInitialized = true isInitialized = true
} }
@ -204,36 +208,41 @@ class PlaybackStateManager private constructor() {
/** Go to the next song, along with doing all the checks that entails. */ /** Go to the next song, along with doing all the checks that entails. */
@Synchronized @Synchronized
fun next() { fun next() {
val internalPlayer = internalPlayer ?: return
// Increment the index, if it cannot be incremented any further, then // Increment the index, if it cannot be incremented any further, then
// repeat and pause/resume playback depending on the setting // repeat and pause/resume playback depending on the setting
if (index < _queue.lastIndex) { if (index < _queue.lastIndex) {
gotoImpl(index + 1, true) gotoImpl(internalPlayer, index + 1, true)
} else { } else {
gotoImpl(0, repeatMode == RepeatMode.ALL) gotoImpl(internalPlayer, 0, repeatMode == RepeatMode.ALL)
} }
} }
/** Go to the previous song, doing any checks that are needed. */ /** Go to the previous song, doing any checks that are needed. */
@Synchronized @Synchronized
fun prev() { fun prev() {
val internalPlayer = internalPlayer ?: return
// 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 (internalPlayer?.shouldRewindWithPrev == true) { if (internalPlayer.shouldRewindWithPrev) {
rewind() rewind()
isPlaying = true changePlaying(true)
} else { } else {
gotoImpl(max(index - 1, 0), true) gotoImpl(internalPlayer, max(index - 1, 0), true)
} }
} }
@Synchronized @Synchronized
fun goto(index: Int) { fun goto(index: Int) {
gotoImpl(index, true) val internalPlayer = internalPlayer ?: return
gotoImpl(internalPlayer, index, true)
} }
private fun gotoImpl(idx: Int, play: Boolean) { private fun gotoImpl(internalPlayer: InternalPlayer, idx: Int, play: Boolean) {
index = idx index = idx
notifyIndexMoved() notifyIndexMoved()
isPlaying = play internalPlayer.loadSong(song, play)
} }
/** Add a [song] to the top of the queue. */ /** Add a [song] to the top of the queue. */
@ -330,19 +339,17 @@ class PlaybackStateManager private constructor() {
// --- INTERNAL PLAYER FUNCTIONS --- // --- INTERNAL PLAYER FUNCTIONS ---
/** Update the current [positionMs]. Only meant for use by [InternalPlayer] */
@Synchronized @Synchronized
fun synchronizePosition(internalPlayer: InternalPlayer, positionMs: Long) { fun synchronizeState(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player") logW("Given internal player did not match current internal player")
return return
} }
// Don't accept any bugged positions that are over the duration of the song. val newState = internalPlayer.currentState
val maxDuration = song?.durationMs ?: -1 if (newState != playerState) {
if (positionMs <= maxDuration) { playerState = newState
this.positionMs = positionMs notifyStateChanged()
notifyPositionChanged()
} }
} }
@ -350,7 +357,7 @@ class PlaybackStateManager private constructor() {
fun startAction(action: InternalPlayer.Action) { fun startAction(action: InternalPlayer.Action) {
val internalPlayer = internalPlayer val internalPlayer = internalPlayer
if (internalPlayer == null || !internalPlayer.onAction(action)) { if (internalPlayer == null || !internalPlayer.onAction(action)) {
logD("Internal player not present or did not consume action, ignoring") logD("Internal player not present or did not consume action, waiting")
pendingAction = action pendingAction = action
} }
} }
@ -369,15 +376,18 @@ class PlaybackStateManager private constructor() {
} }
} }
/** Change the current playing state. */
fun changePlaying(isPlaying: Boolean) {
internalPlayer?.changePlaying(isPlaying)
}
/** /**
* **Seek** to a [positionMs]. * **Seek** to a [positionMs].
* @param positionMs The position to seek to in millis. * @param positionMs The position to seek to in millis.
*/ */
@Synchronized @Synchronized
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
this.positionMs = positionMs
internalPlayer?.seekTo(positionMs) internalPlayer?.seekTo(positionMs)
notifyPositionChanged()
} }
/** Rewind to the beginning of a song. */ /** Rewind to the beginning of a song. */
@ -392,14 +402,13 @@ class PlaybackStateManager private constructor() {
} }
val library = musicStore.library ?: return false val library = musicStore.library ?: return false
val internalPlayer = internalPlayer ?: return false
val state = withContext(Dispatchers.IO) { database.read(library) } val state = withContext(Dispatchers.IO) { database.read(library) }
synchronized(this) { synchronized(this) {
if (state != null && (!isInitialized || force)) { if (state != null && (!isInitialized || force)) {
// Continuing playback while also possibly doing drastic state updates is // Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause. // a bad idea, so pause.
isPlaying = false
index = state.index index = state.index
parent = state.parent parent = state.parent
_queue = state.queue.toMutableList() _queue = state.queue.toMutableList()
@ -407,10 +416,12 @@ class PlaybackStateManager private constructor() {
isShuffled = state.isShuffled isShuffled = state.isShuffled
notifyNewPlayback() notifyNewPlayback()
seekTo(state.positionMs)
notifyRepeatModeChanged() notifyRepeatModeChanged()
notifyShuffledChanged() notifyShuffledChanged()
internalPlayer.loadSong(song, false)
internalPlayer.seekTo(state.positionMs)
isInitialized = true isInitialized = true
return true return true
@ -440,13 +451,15 @@ class PlaybackStateManager private constructor() {
return return
} }
val internalPlayer = internalPlayer ?: return
logD("Sanitizing state") logD("Sanitizing state")
// While we could just save and reload the state, we instead sanitize the state // While we could just save and reload the state, we instead sanitize the state
// at runtime for better efficiency (and to sidestep a co-routine on behalf of the caller). // at runtime for better performance (and to sidestep a co-routine on behalf of the caller).
val oldSongId = song?.id val oldSongId = song?.id
val oldPosition = positionMs val oldPosition = playerState.calculateElapsedPosition()
parent = parent =
parent?.let { parent?.let {
@ -463,10 +476,11 @@ class PlaybackStateManager private constructor() {
index-- index--
} }
notifyNewPlayback()
// Continuing playback while also possibly doing drastic state updates is // Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause. // a bad idea, so pause.
isPlaying = false internalPlayer.loadSong(song, false)
notifyNewPlayback()
if (index > -1) { if (index > -1) {
// Internal player may have reloaded the media item, re-seek to the previous position // Internal player may have reloaded the media item, re-seek to the previous position
@ -479,14 +493,13 @@ class PlaybackStateManager private constructor() {
index = index, index = index,
parent = parent, parent = parent,
queue = _queue, queue = _queue,
positionMs = positionMs, positionMs = playerState.calculateElapsedPosition(),
isShuffled = isShuffled, isShuffled = isShuffled,
repeatMode = repeatMode) repeatMode = repeatMode)
// --- CALLBACKS --- // --- CALLBACKS ---
private fun notifyIndexMoved() { private fun notifyIndexMoved() {
internalPlayer?.loadSong(song)
for (callback in callbacks) { for (callback in callbacks) {
callback.onIndexMoved(index) callback.onIndexMoved(index)
} }
@ -505,22 +518,14 @@ class PlaybackStateManager private constructor() {
} }
private fun notifyNewPlayback() { private fun notifyNewPlayback() {
internalPlayer?.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 notifyStateChanged() {
internalPlayer?.onPlayingChanged(isPlaying)
for (callback in callbacks) { for (callback in callbacks) {
callback.onPlayingChanged(isPlaying) callback.onStateChanged(playerState)
}
}
private fun notifyPositionChanged() {
for (callback in callbacks) {
callback.onPositionChanged(positionMs)
} }
} }
@ -553,11 +558,8 @@ class PlaybackStateManager private constructor() {
/** Called when playback is changed completely, with a new index, queue, and parent. */ /** 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. */ /** Called when the state of the internal player changes. */
fun onPlayingChanged(isPlaying: Boolean) {} fun onStateChanged(state: InternalPlayer.State) {}
/** Called when the position is re-synchronized by the internal player. */
fun onPositionChanged(positionMs: Long) {}
/** Called when the repeat mode is changed. */ /** Called when the repeat mode is changed. */
fun onRepeatChanged(repeatMode: RepeatMode) {} fun onRepeatChanged(repeatMode: RepeatMode) {}

View file

@ -22,18 +22,17 @@ import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import androidx.media.session.MediaButtonReceiver import androidx.media.session.MediaButtonReceiver
import com.google.android.exoplayer2.Player
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
@ -59,26 +58,16 @@ import org.oxycblt.auxio.util.logD
* Replace it. Please. * Replace it. Please.
* *
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Remove the player callback once smooth seeking is implemented
*/ */
class MediaSessionComponent( class MediaSessionComponent(private val context: Context, private val callback: Callback) :
private val context: Context, MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback {
private val player: Player,
private val callback: Callback
) :
Player.Listener,
MediaSessionCompat.Callback(),
PlaybackStateManager.Callback,
Settings.Callback {
interface Callback { interface Callback {
fun onPostNotification(notification: NotificationComponent?, reason: PostingReason) fun onPostNotification(notification: NotificationComponent?, reason: PostingReason)
} }
enum class PostingReason { enum class PostingReason {
METADATA, METADATA,
ACTIONS, ACTIONS
POSITION
} }
private val mediaSession = private val mediaSession =
@ -94,7 +83,6 @@ class MediaSessionComponent(
private val provider = BitmapProvider(context) private val provider = BitmapProvider(context)
init { init {
player.addListener(this)
playbackManager.addCallback(this) playbackManager.addCallback(this)
mediaSession.setCallback(this) mediaSession.setCallback(this)
} }
@ -106,7 +94,6 @@ class MediaSessionComponent(
fun release() { fun release() {
provider.release() provider.release()
settings.release() settings.release()
player.removeListener(this)
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
mediaSession.apply { mediaSession.apply {
@ -225,13 +212,10 @@ class MediaSessionComponent(
mediaSession.setQueue(queueItems) mediaSession.setQueue(queueItems)
} }
override fun onPlayingChanged(isPlaying: Boolean) { override fun onStateChanged(state: InternalPlayer.State) {
invalidateSessionState() invalidateSessionState()
notification.updatePlaying(playbackManager.isPlaying) notification.updatePlaying(playbackManager.playerState.isPlaying)
if (!provider.isBusy) { if (!provider.isBusy) {
// Still probably want to start the notification though regardless of the version,
// as playback is starting.
callback.onPostNotification(notification, PostingReason.ACTIONS) callback.onPostNotification(notification, PostingReason.ACTIONS)
} }
} }
@ -269,25 +253,6 @@ class MediaSessionComponent(
} }
} }
// --- EXOPLAYER CALLBACKS ---
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
invalidateSessionState()
if (!playbackManager.isPlaying) {
// Hack around issue where the position won't update after a seek when paused.
// Apparently this can be fixed by re-posting the notification, but not always
// when we invalidate the state (that will cause us to be rate-limited), and also not
// always when we seek (that will also cause us to be rate-limited). Someone looked at
// this system and said it was well-designed.
callback.onPostNotification(notification, PostingReason.POSITION)
}
}
// --- MEDIASESSION CALLBACKS --- // --- MEDIASESSION CALLBACKS ---
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
@ -316,11 +281,11 @@ class MediaSessionComponent(
} }
override fun onPlay() { override fun onPlay() {
playbackManager.isPlaying = true playbackManager.changePlaying(true)
} }
override fun onPause() { override fun onPause() {
playbackManager.isPlaying = false playbackManager.changePlaying(false)
} }
override fun onSkipToNext() { override fun onSkipToNext() {
@ -341,7 +306,7 @@ class MediaSessionComponent(
override fun onRewind() { override fun onRewind() {
playbackManager.rewind() playbackManager.rewind()
playbackManager.isPlaying = true playbackManager.changePlaying(true)
} }
override fun onSetRepeatMode(repeatMode: Int) { override fun onSetRepeatMode(repeatMode: Int) {
@ -391,17 +356,9 @@ class MediaSessionComponent(
val state = val state =
PlaybackStateCompat.Builder() PlaybackStateCompat.Builder()
.setActions(ACTIONS) .setActions(ACTIONS)
.setBufferedPosition(player.bufferedPosition)
.setActiveQueueItemId(playbackManager.index.toLong()) .setActiveQueueItemId(playbackManager.index.toLong())
val playerState = playbackManager.playerState.intoPlaybackState(state)
if (playbackManager.isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
}
state.setState(playerState, player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
// Android 13+ leverages custom actions in the notification. // Android 13+ leverages custom actions in the notification.

View file

@ -39,10 +39,10 @@ import com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import kotlin.math.max
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
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.R import org.oxycblt.auxio.R
@ -102,7 +102,6 @@ class PlaybackService :
// Coroutines // Coroutines
private val serviceJob = Job() private val serviceJob = Job()
private val positionScope = CoroutineScope(serviceJob + Dispatchers.Main)
private val restoreScope = CoroutineScope(serviceJob + Dispatchers.Main) private val restoreScope = CoroutineScope(serviceJob + Dispatchers.Main)
private val saveScope = CoroutineScope(serviceJob + Dispatchers.Main) private val saveScope = CoroutineScope(serviceJob + Dispatchers.Main)
@ -149,15 +148,9 @@ class PlaybackService :
playbackManager.registerInternalPlayer(this) playbackManager.registerInternalPlayer(this)
musicStore.addCallback(this) musicStore.addCallback(this)
positionScope.launch {
while (true) {
playbackManager.synchronizePosition(this@PlaybackService, player.currentPosition)
delay(POS_POLL_INTERVAL)
}
}
widgetComponent = WidgetComponent(this) widgetComponent = WidgetComponent(this)
mediaSessionComponent = MediaSessionComponent(this, player, this) mediaSessionComponent = MediaSessionComponent(this, this)
IntentFilter().apply { IntentFilter().apply {
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
@ -197,10 +190,10 @@ class PlaybackService :
foregroundManager.release() foregroundManager.release()
// Pause just in case this destruction was unexpected. // Pause just in case this destruction was unexpected.
playbackManager.isPlaying = false playbackManager.changePlaying(false)
playbackManager.unregisterInternalPlayer(this) playbackManager.unregisterInternalPlayer(this)
musicStore.addCallback(this) musicStore.removeCallback(this)
settings.release() settings.release()
unregisterReceiver(systemReceiver) unregisterReceiver(systemReceiver)
serviceJob.cancel() serviceJob.cancel()
@ -220,15 +213,21 @@ class PlaybackService :
// --- PLAYER OVERRIDES --- // --- PLAYER OVERRIDES ---
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { override fun onEvents(player: Player, events: Player.Events) {
super.onPlayWhenReadyChanged(playWhenReady, reason) super.onEvents(player, events)
if (playWhenReady) { var needToSynchronize =
hasPlayed = true events.containsAny(Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY)
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
needToSynchronize = true
if (player.playWhenReady) {
hasPlayed = true
}
} }
if (playbackManager.isPlaying != playWhenReady) { if (needToSynchronize) {
playbackManager.isPlaying = playWhenReady playbackManager.synchronizeState(this)
} }
} }
@ -237,7 +236,7 @@ class PlaybackService :
if (playbackManager.repeatMode == RepeatMode.TRACK) { if (playbackManager.repeatMode == RepeatMode.TRACK) {
playbackManager.rewind() playbackManager.rewind()
if (settings.pauseOnRepeat) { if (settings.pauseOnRepeat) {
playbackManager.isPlaying = false playbackManager.changePlaying(false)
} }
} else { } else {
playbackManager.next() playbackManager.next()
@ -251,14 +250,6 @@ class PlaybackService :
playbackManager.next() playbackManager.next()
} }
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
playbackManager.synchronizePosition(this, player.currentPosition)
}
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks) super.onTracksChanged(tracks)
@ -284,7 +275,12 @@ class PlaybackService :
override val shouldRewindWithPrev: Boolean override val shouldRewindWithPrev: Boolean
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun loadSong(song: Song?) { override val currentState: InternalPlayer.State
get() =
InternalPlayer.State.new(
player.playWhenReady, player.isPlaying, max(player.currentPosition, 0))
override fun loadSong(song: Song?, play: Boolean) {
if (song == null) { if (song == null) {
// Stop the foreground state if there's nothing to play. // Stop the foreground state if there's nothing to play.
logD("Nothing playing, stopping playback") logD("Nothing playing, stopping playback")
@ -310,6 +306,8 @@ class PlaybackService :
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = true openAudioEffectSession = true
} }
player.playWhenReady = play
} }
private fun broadcastAudioEffectAction(event: String) { private fun broadcastAudioEffectAction(event: String) {
@ -337,7 +335,7 @@ class PlaybackService :
player.seekTo(positionMs) player.seekTo(positionMs)
} }
override fun onPlayingChanged(isPlaying: Boolean) { override fun changePlaying(isPlaying: Boolean) {
player.playWhenReady = isPlaying player.playWhenReady = isPlaying
} }
@ -437,7 +435,8 @@ class PlaybackService :
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug() AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
// --- AUXIO EVENTS --- // --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> playbackManager.isPlaying = !playbackManager.isPlaying ACTION_PLAY_PAUSE ->
playbackManager.changePlaying(!playbackManager.playerState.isPlaying)
ACTION_INC_REPEAT_MODE -> ACTION_INC_REPEAT_MODE ->
playbackManager.repeatMode = playbackManager.repeatMode.increment() playbackManager.repeatMode = playbackManager.repeatMode.increment()
ACTION_INVERT_SHUFFLE -> ACTION_INVERT_SHUFFLE ->
@ -445,7 +444,7 @@ class PlaybackService :
ACTION_SKIP_PREV -> playbackManager.prev() ACTION_SKIP_PREV -> playbackManager.prev()
ACTION_SKIP_NEXT -> playbackManager.next() ACTION_SKIP_NEXT -> playbackManager.next()
ACTION_EXIT -> { ACTION_EXIT -> {
playbackManager.isPlaying = false playbackManager.changePlaying(false)
stopAndSave() stopAndSave()
} }
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update() WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
@ -466,7 +465,7 @@ class PlaybackService :
settings.headsetAutoplay && settings.headsetAutoplay &&
initialHeadsetPlugEventHandled) { initialHeadsetPlugEventHandled) {
logD("Device connected, resuming") logD("Device connected, resuming")
playbackManager.isPlaying = true playbackManager.changePlaying(true)
} }
} }
@ -474,7 +473,7 @@ class PlaybackService :
private fun pauseFromPlug() { private fun pauseFromPlug() {
if (playbackManager.song != null) { if (playbackManager.song != null) {
logD("Device disconnected, pausing") logD("Device disconnected, pausing")
playbackManager.isPlaying = false playbackManager.changePlaying(false)
} }
} }
} }

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.SquareFrameTransform import org.oxycblt.auxio.image.SquareFrameTransform
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
@ -74,7 +75,7 @@ class WidgetComponent(private val context: Context) :
} }
// Note: Store these values here so they remain consistent once the bitmap is loaded. // Note: Store these values here so they remain consistent once the bitmap is loaded.
val isPlaying = playbackManager.isPlaying val isPlaying = playbackManager.playerState.isPlaying
val repeatMode = playbackManager.repeatMode val repeatMode = playbackManager.repeatMode
val isShuffled = playbackManager.isShuffled val isShuffled = playbackManager.isShuffled
@ -139,7 +140,7 @@ class WidgetComponent(private val context: Context) :
override fun onIndexMoved(index: Int) = update() override fun onIndexMoved(index: Int) = update()
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update() override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update()
override fun onPlayingChanged(isPlaying: Boolean) = update() override fun onStateChanged(state: InternalPlayer.State) = update()
override fun onShuffledChanged(isShuffled: Boolean) = update() override fun onShuffledChanged(isShuffled: Boolean) = update()
override fun onRepeatChanged(repeatMode: RepeatMode) = update() override fun onRepeatChanged(repeatMode: RepeatMode) = update()
override fun onSettingChanged(key: String) { override fun onSettingChanged(key: String) {