From e5d7cdc340c1fc24c8c81207675bb11047ff2fa3 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 6 Sep 2022 11:40:21 -0600 Subject: [PATCH] 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. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 2 - .../auxio/music/system/ExoPlayerBackend.kt | 6 +- .../auxio/playback/PlaybackViewModel.kt | 22 ++- .../oxycblt/auxio/playback/StyledSeekBar.kt | 12 +- .../auxio/playback/state/InternalPlayer.kt | 81 ++++++++++- .../playback/state/PlaybackStateManager.kt | 130 +++++++++--------- .../playback/system/MediaSessionComponent.kt | 63 ++------- .../auxio/playback/system/PlaybackService.kt | 63 +++++---- .../oxycblt/auxio/widgets/WidgetComponent.kt | 5 +- 9 files changed, 212 insertions(+), 172 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index b024729ea..12f8a16af 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -45,8 +45,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * - The RecyclerView data for each fragment * - The sorts for each type of data * @author OxygenCobalt - * - * TODO: Unify how detail items are indicated [When playlists are implemented] */ class DetailViewModel(application: Application) : AndroidViewModel(application), MusicStore.Callback { diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt index 1832b07fc..18dd0b9fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt @@ -189,11 +189,7 @@ class Task(context: Context, private val raw: Song.Raw) { val id = tag.key.sanitize().uppercase() val value = tag.value.sanitize() if (value.isNotEmpty()) { - if (vorbisTags.containsKey(id)) { - vorbisTags[id]!!.add(value) - } else { - vorbisTags[id] = mutableListOf(value) - } + vorbisTags.getOrPut(id) { mutableListOf() }.add(value) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index c4399bcdf..9bf54a183 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -20,6 +20,8 @@ package org.oxycblt.auxio.playback import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -78,6 +80,8 @@ class PlaybackViewModel(application: Application) : val currentAudioSessionId: Int? get() = playbackManager.currentAudioSessionId + private var lastPositionJob: Job? = null + init { playbackManager.addCallback(this) } @@ -209,7 +213,7 @@ class PlaybackViewModel(application: Application) : /** Flip the playing status, e.g from playing to paused */ 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. */ @@ -268,12 +272,18 @@ class PlaybackViewModel(application: Application) : _parent.value = playbackManager.parent } - override fun onPositionChanged(positionMs: Long) { - _positionDs.value = positionMs.msToDs() - } + override fun onStateChanged(state: InternalPlayer.State) { + _isPlaying.value = state.isPlaying - override fun onPlayingChanged(isPlaying: Boolean) { - _isPlaying.value = isPlaying + // Start watching the position again + lastPositionJob?.cancel() + lastPositionJob = + viewModelScope.launch { + while (true) { + _positionDs.value = state.calculateElapsedPosition().msToDs() + delay(100) + } + } } override fun onShuffledChanged(isShuffled: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/StyledSeekBar.kt b/app/src/main/java/org/oxycblt/auxio/playback/StyledSeekBar.kt index 657c120d0..6b282efae 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/StyledSeekBar.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/StyledSeekBar.kt @@ -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 * the app or result in blatantly janky behavior. Mostly. * - * TODO: Add smooth seeking - * * @author OxygenCobalt */ class StyledSeekBar @@ -69,15 +67,19 @@ constructor( var positionDs: Long get() = binding.seekBarSlider.value.toLong() 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 // the app, and that the user is not currently seeking (which would cause the SeekBar // to jump around). - if (value <= durationDs && !isActivated) { - binding.seekBarSlider.value = value.toFloat() + if (from <= durationDs && !isActivated) { + binding.seekBarSlider.value = from.toFloat() // 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. - binding.seekBarPosition.text = value.formatDurationDs(true) + binding.seekBarPosition.text = from.formatDurationDs(true) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt index dfd3b6099..67008b7be 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt @@ -18,6 +18,8 @@ package org.oxycblt.auxio.playback.state import android.net.Uri +import android.os.SystemClock +import android.support.v4.media.session.PlaybackStateCompat import org.oxycblt.auxio.music.Song /** 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. */ val shouldRewindWithPrev: Boolean + val currentState: State + /** 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. */ fun seekTo(positionMs: Long) - /** Called when the playing state is changed. */ - fun onPlayingChanged(isPlaying: Boolean) + /** Called when the playing state needs to be changed. */ + fun changePlaying(isPlaying: Boolean) /** * Called when [PlaybackStateManager] desires some [Action] to be completed. Returns true if the @@ -43,6 +47,77 @@ interface InternalPlayer { */ 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 { object RestoreState : Action() object ShuffleAll : Action() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 4a9a9338d..bb959e3ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -52,6 +52,8 @@ import org.oxycblt.auxio.util.logW */ class PlaybackStateManager private constructor() { private val musicStore = MusicStore.getInstance() + private val callbacks = mutableListOf() + private var internalPlayer: InternalPlayer? = null /** The currently playing song. Null if there isn't one */ val song @@ -67,14 +69,10 @@ class PlaybackStateManager private constructor() { var index = -1 private set - /** Whether playback is playing or not */ - var isPlaying = false - set(value) { - field = value - notifyPlayingChanged() - } - /** The current playback progress */ - private var positionMs = 0L + /** The current state of the internal player. */ + var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0) + private set + /** The current [RepeatMode] */ var repeatMode = RepeatMode.NONE set(value) { @@ -94,22 +92,16 @@ class PlaybackStateManager private constructor() { get() = internalPlayer?.audioSessionId /** An action that is awaiting the internal player instance to consume it. */ - var pendingAction: InternalPlayer.Action? = null - - // --- CALLBACKS --- - - private val callbacks = mutableListOf() - private var internalPlayer: InternalPlayer? = null + private var pendingAction: InternalPlayer.Action? = null /** Add a callback to this instance. Make sure to remove it when done. */ @Synchronized fun addCallback(callback: Callback) { if (isInitialized) { callback.onNewPlayback(index, queue, parent) - callback.onPositionChanged(positionMs) - callback.onPlayingChanged(isPlaying) callback.onRepeatChanged(repeatMode) callback.onShuffledChanged(isShuffled) + callback.onStateChanged(playerState) } callbacks.add(callback) @@ -130,10 +122,10 @@ class PlaybackStateManager private constructor() { } if (isInitialized) { - internalPlayer.loadSong(song) - internalPlayer.seekTo(positionMs) - internalPlayer.onPlayingChanged(isPlaying) + internalPlayer.loadSong(song, playerState.isPlaying) + internalPlayer.seekTo(playerState.calculateElapsedPosition()) requestAction(internalPlayer) + synchronizeState(internalPlayer) } this.internalPlayer = internalPlayer @@ -155,6 +147,7 @@ class PlaybackStateManager private constructor() { /** Play a [song]. */ @Synchronized fun play(song: Song, playbackMode: PlaybackMode, settings: Settings) { + val internalPlayer = internalPlayer ?: return val library = musicStore.library ?: return parent = @@ -169,33 +162,44 @@ class PlaybackStateManager private constructor() { } applyNewQueue(library, settings, settings.keepShuffle && isShuffled, song) + notifyNewPlayback() notifyShuffledChanged() - isPlaying = true + + internalPlayer.loadSong(song, true) + isInitialized = true } /** Play a [parent], such as an artist or album. */ @Synchronized fun play(parent: MusicParent, shuffled: Boolean, settings: Settings) { + val internalPlayer = internalPlayer ?: return val library = musicStore.library ?: return + this.parent = parent applyNewQueue(library, settings, shuffled, null) + notifyNewPlayback() notifyShuffledChanged() - isPlaying = true + + internalPlayer.loadSong(song, true) isInitialized = true } /** Shuffle all songs. */ @Synchronized fun shuffleAll(settings: Settings) { + val internalPlayer = internalPlayer ?: return val library = musicStore.library ?: return + parent = null applyNewQueue(library, settings, true, null) + notifyNewPlayback() notifyShuffledChanged() - isPlaying = true + + internalPlayer.loadSong(song, true) isInitialized = true } @@ -204,36 +208,41 @@ class PlaybackStateManager private constructor() { /** Go to the next song, along with doing all the checks that entails. */ @Synchronized fun next() { + val internalPlayer = internalPlayer ?: return + // Increment the index, if it cannot be incremented any further, then // repeat and pause/resume playback depending on the setting if (index < _queue.lastIndex) { - gotoImpl(index + 1, true) + gotoImpl(internalPlayer, index + 1, true) } else { - gotoImpl(0, repeatMode == RepeatMode.ALL) + gotoImpl(internalPlayer, 0, repeatMode == RepeatMode.ALL) } } /** Go to the previous song, doing any checks that are needed. */ @Synchronized fun prev() { + val internalPlayer = internalPlayer ?: return + // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] - if (internalPlayer?.shouldRewindWithPrev == true) { + if (internalPlayer.shouldRewindWithPrev) { rewind() - isPlaying = true + changePlaying(true) } else { - gotoImpl(max(index - 1, 0), true) + gotoImpl(internalPlayer, max(index - 1, 0), true) } } @Synchronized 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 notifyIndexMoved() - isPlaying = play + internalPlayer.loadSong(song, play) } /** Add a [song] to the top of the queue. */ @@ -330,19 +339,17 @@ class PlaybackStateManager private constructor() { // --- INTERNAL PLAYER FUNCTIONS --- - /** Update the current [positionMs]. Only meant for use by [InternalPlayer] */ @Synchronized - fun synchronizePosition(internalPlayer: InternalPlayer, positionMs: Long) { + fun synchronizeState(internalPlayer: InternalPlayer) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { logW("Given internal player did not match current internal player") return } - // Don't accept any bugged positions that are over the duration of the song. - val maxDuration = song?.durationMs ?: -1 - if (positionMs <= maxDuration) { - this.positionMs = positionMs - notifyPositionChanged() + val newState = internalPlayer.currentState + if (newState != playerState) { + playerState = newState + notifyStateChanged() } } @@ -350,7 +357,7 @@ class PlaybackStateManager private constructor() { fun startAction(action: InternalPlayer.Action) { val internalPlayer = internalPlayer 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 } } @@ -369,15 +376,18 @@ class PlaybackStateManager private constructor() { } } + /** Change the current playing state. */ + fun changePlaying(isPlaying: Boolean) { + internalPlayer?.changePlaying(isPlaying) + } + /** * **Seek** to a [positionMs]. * @param positionMs The position to seek to in millis. */ @Synchronized fun seekTo(positionMs: Long) { - this.positionMs = positionMs internalPlayer?.seekTo(positionMs) - notifyPositionChanged() } /** Rewind to the beginning of a song. */ @@ -392,14 +402,13 @@ class PlaybackStateManager private constructor() { } val library = musicStore.library ?: return false + val internalPlayer = internalPlayer ?: return false val state = withContext(Dispatchers.IO) { database.read(library) } synchronized(this) { if (state != null && (!isInitialized || force)) { // Continuing playback while also possibly doing drastic state updates is // a bad idea, so pause. - isPlaying = false - index = state.index parent = state.parent _queue = state.queue.toMutableList() @@ -407,10 +416,12 @@ class PlaybackStateManager private constructor() { isShuffled = state.isShuffled notifyNewPlayback() - seekTo(state.positionMs) notifyRepeatModeChanged() notifyShuffledChanged() + internalPlayer.loadSong(song, false) + internalPlayer.seekTo(state.positionMs) + isInitialized = true return true @@ -440,13 +451,15 @@ class PlaybackStateManager private constructor() { return } + val internalPlayer = internalPlayer ?: return + logD("Sanitizing 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 oldPosition = positionMs + val oldPosition = playerState.calculateElapsedPosition() parent = parent?.let { @@ -463,10 +476,11 @@ class PlaybackStateManager private constructor() { index-- } + notifyNewPlayback() + // Continuing playback while also possibly doing drastic state updates is // a bad idea, so pause. - isPlaying = false - notifyNewPlayback() + internalPlayer.loadSong(song, false) if (index > -1) { // Internal player may have reloaded the media item, re-seek to the previous position @@ -479,14 +493,13 @@ class PlaybackStateManager private constructor() { index = index, parent = parent, queue = _queue, - positionMs = positionMs, + positionMs = playerState.calculateElapsedPosition(), isShuffled = isShuffled, repeatMode = repeatMode) // --- CALLBACKS --- private fun notifyIndexMoved() { - internalPlayer?.loadSong(song) for (callback in callbacks) { callback.onIndexMoved(index) } @@ -505,22 +518,14 @@ class PlaybackStateManager private constructor() { } private fun notifyNewPlayback() { - internalPlayer?.loadSong(song) for (callback in callbacks) { callback.onNewPlayback(index, queue, parent) } } - private fun notifyPlayingChanged() { - internalPlayer?.onPlayingChanged(isPlaying) + private fun notifyStateChanged() { for (callback in callbacks) { - callback.onPlayingChanged(isPlaying) - } - } - - private fun notifyPositionChanged() { - for (callback in callbacks) { - callback.onPositionChanged(positionMs) + callback.onStateChanged(playerState) } } @@ -553,11 +558,8 @@ class PlaybackStateManager private constructor() { /** Called when playback is changed completely, with a new index, queue, and parent. */ fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) {} - /** Called when the playing state is changed. */ - fun onPlayingChanged(isPlaying: Boolean) {} - - /** Called when the position is re-synchronized by the internal player. */ - fun onPositionChanged(positionMs: Long) {} + /** Called when the state of the internal player changes. */ + fun onStateChanged(state: InternalPlayer.State) {} /** Called when the repeat mode is changed. */ fun onRepeatChanged(repeatMode: RepeatMode) {} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index b02d03a21..f0a853bd2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -22,18 +22,17 @@ import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.os.Bundle -import android.os.SystemClock import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.media.session.MediaButtonReceiver -import com.google.android.exoplayer2.Player import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.music.MusicParent 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.RepeatMode import org.oxycblt.auxio.settings.Settings @@ -59,26 +58,16 @@ import org.oxycblt.auxio.util.logD * Replace it. Please. * * @author OxygenCobalt - * - * TODO: Remove the player callback once smooth seeking is implemented */ -class MediaSessionComponent( - private val context: Context, - private val player: Player, - private val callback: Callback -) : - Player.Listener, - MediaSessionCompat.Callback(), - PlaybackStateManager.Callback, - Settings.Callback { +class MediaSessionComponent(private val context: Context, private val callback: Callback) : + MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback { interface Callback { fun onPostNotification(notification: NotificationComponent?, reason: PostingReason) } enum class PostingReason { METADATA, - ACTIONS, - POSITION + ACTIONS } private val mediaSession = @@ -94,7 +83,6 @@ class MediaSessionComponent( private val provider = BitmapProvider(context) init { - player.addListener(this) playbackManager.addCallback(this) mediaSession.setCallback(this) } @@ -106,7 +94,6 @@ class MediaSessionComponent( fun release() { provider.release() settings.release() - player.removeListener(this) playbackManager.removeCallback(this) mediaSession.apply { @@ -225,13 +212,10 @@ class MediaSessionComponent( mediaSession.setQueue(queueItems) } - override fun onPlayingChanged(isPlaying: Boolean) { + override fun onStateChanged(state: InternalPlayer.State) { invalidateSessionState() - notification.updatePlaying(playbackManager.isPlaying) - + notification.updatePlaying(playbackManager.playerState.isPlaying) if (!provider.isBusy) { - // Still probably want to start the notification though regardless of the version, - // as playback is starting. 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 --- override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { @@ -316,11 +281,11 @@ class MediaSessionComponent( } override fun onPlay() { - playbackManager.isPlaying = true + playbackManager.changePlaying(true) } override fun onPause() { - playbackManager.isPlaying = false + playbackManager.changePlaying(false) } override fun onSkipToNext() { @@ -341,7 +306,7 @@ class MediaSessionComponent( override fun onRewind() { playbackManager.rewind() - playbackManager.isPlaying = true + playbackManager.changePlaying(true) } override fun onSetRepeatMode(repeatMode: Int) { @@ -391,17 +356,9 @@ class MediaSessionComponent( val state = PlaybackStateCompat.Builder() .setActions(ACTIONS) - .setBufferedPosition(player.bufferedPosition) .setActiveQueueItemId(playbackManager.index.toLong()) - val playerState = - if (playbackManager.isPlaying) { - PlaybackStateCompat.STATE_PLAYING - } else { - PlaybackStateCompat.STATE_PAUSED - } - - state.setState(playerState, player.currentPosition, 1.0f, SystemClock.elapsedRealtime()) + playbackManager.playerState.intoPlaybackState(state) // Android 13+ leverages custom actions in the notification. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index ca2bbe723..c20538dc4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -39,10 +39,10 @@ import com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory import com.google.android.exoplayer2.mediacodec.MediaCodecSelector import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import kotlin.math.max import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R @@ -102,7 +102,6 @@ class PlaybackService : // Coroutines private val serviceJob = Job() - private val positionScope = CoroutineScope(serviceJob + Dispatchers.Main) private val restoreScope = CoroutineScope(serviceJob + Dispatchers.Main) private val saveScope = CoroutineScope(serviceJob + Dispatchers.Main) @@ -149,15 +148,9 @@ class PlaybackService : playbackManager.registerInternalPlayer(this) musicStore.addCallback(this) - positionScope.launch { - while (true) { - playbackManager.synchronizePosition(this@PlaybackService, player.currentPosition) - delay(POS_POLL_INTERVAL) - } - } widgetComponent = WidgetComponent(this) - mediaSessionComponent = MediaSessionComponent(this, player, this) + mediaSessionComponent = MediaSessionComponent(this, this) IntentFilter().apply { addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) @@ -197,10 +190,10 @@ class PlaybackService : foregroundManager.release() // Pause just in case this destruction was unexpected. - playbackManager.isPlaying = false + playbackManager.changePlaying(false) playbackManager.unregisterInternalPlayer(this) - musicStore.addCallback(this) + musicStore.removeCallback(this) settings.release() unregisterReceiver(systemReceiver) serviceJob.cancel() @@ -220,15 +213,21 @@ class PlaybackService : // --- PLAYER OVERRIDES --- - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - super.onPlayWhenReadyChanged(playWhenReady, reason) + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) - if (playWhenReady) { - hasPlayed = true + var needToSynchronize = + 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) { - playbackManager.isPlaying = playWhenReady + if (needToSynchronize) { + playbackManager.synchronizeState(this) } } @@ -237,7 +236,7 @@ class PlaybackService : if (playbackManager.repeatMode == RepeatMode.TRACK) { playbackManager.rewind() if (settings.pauseOnRepeat) { - playbackManager.isPlaying = false + playbackManager.changePlaying(false) } } else { playbackManager.next() @@ -251,14 +250,6 @@ class PlaybackService : playbackManager.next() } - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - playbackManager.synchronizePosition(this, player.currentPosition) - } - override fun onTracksChanged(tracks: Tracks) { super.onTracksChanged(tracks) @@ -284,7 +275,12 @@ class PlaybackService : override val shouldRewindWithPrev: Boolean 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) { // Stop the foreground state if there's nothing to play. logD("Nothing playing, stopping playback") @@ -310,6 +306,8 @@ class PlaybackService : broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) openAudioEffectSession = true } + + player.playWhenReady = play } private fun broadcastAudioEffectAction(event: String) { @@ -337,7 +335,7 @@ class PlaybackService : player.seekTo(positionMs) } - override fun onPlayingChanged(isPlaying: Boolean) { + override fun changePlaying(isPlaying: Boolean) { player.playWhenReady = isPlaying } @@ -437,7 +435,8 @@ class PlaybackService : AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug() // --- AUXIO EVENTS --- - ACTION_PLAY_PAUSE -> playbackManager.isPlaying = !playbackManager.isPlaying + ACTION_PLAY_PAUSE -> + playbackManager.changePlaying(!playbackManager.playerState.isPlaying) ACTION_INC_REPEAT_MODE -> playbackManager.repeatMode = playbackManager.repeatMode.increment() ACTION_INVERT_SHUFFLE -> @@ -445,7 +444,7 @@ class PlaybackService : ACTION_SKIP_PREV -> playbackManager.prev() ACTION_SKIP_NEXT -> playbackManager.next() ACTION_EXIT -> { - playbackManager.isPlaying = false + playbackManager.changePlaying(false) stopAndSave() } WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update() @@ -466,7 +465,7 @@ class PlaybackService : settings.headsetAutoplay && initialHeadsetPlugEventHandled) { logD("Device connected, resuming") - playbackManager.isPlaying = true + playbackManager.changePlaying(true) } } @@ -474,7 +473,7 @@ class PlaybackService : private fun pauseFromPlug() { if (playbackManager.song != null) { logD("Device disconnected, pausing") - playbackManager.isPlaying = false + playbackManager.changePlaying(false) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index d9aa4fe6e..ef42a3af7 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -28,6 +28,7 @@ import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.SquareFrameTransform import org.oxycblt.auxio.music.MusicParent 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.RepeatMode 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. - val isPlaying = playbackManager.isPlaying + val isPlaying = playbackManager.playerState.isPlaying val repeatMode = playbackManager.repeatMode val isShuffled = playbackManager.isShuffled @@ -139,7 +140,7 @@ class WidgetComponent(private val context: Context) : override fun onIndexMoved(index: Int) = update() override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) = update() - override fun onPlayingChanged(isPlaying: Boolean) = update() + override fun onStateChanged(state: InternalPlayer.State) = update() override fun onShuffledChanged(isShuffled: Boolean) = update() override fun onRepeatChanged(repeatMode: RepeatMode) = update() override fun onSettingChanged(key: String) {