playback: add delayed action system

Replace the hodge-podge framework of state restoration and URI playing
with a single "delayed action" system.

Auxio's initialization routine is a total cluster----. This is mostly
because it involves multiple asynchronous operations such as music
loading, state restore, and service starting which tend to make it
highly prone to race conditions and other insanity.

In particular, the way Auxio would attempt to restore playback and
handle file opening was a spaghetti pile of bad API boundaries and
dubious UI code. This has not changed. I want to move this routine
to the service, but it's lifecycle is also sh------ed to such an
extent where that would be nearly impossible. Instead, this commit
introduces a new "delayed action" system that bites the bullet and
allowes PlaybackViewModel to accept a context and an action in
return for initializing playback...eventually.

I tried my best to eliminate as much memory leaks as I physically
could here, but could only go so far. Still though, even this insane
system is better than the UI-level LiveData shenanigans I did
previously, and actually works compared to the broken android
components that google keeps wanting you to use.
This commit is contained in:
OxygenCobalt 2022-05-22 11:55:17 -06:00
parent 1ac55c534e
commit 594fa3597e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
7 changed files with 98 additions and 72 deletions

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@ -68,13 +69,24 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, PlaybackService::class.java)) startService(Intent(this, PlaybackService::class.java))
// onNewIntent doesn't automatically call on startup, so call it here. // If we have a file URI already, open it. Otherwise, restore the playback state.
onNewIntent(intent) val action =
retrieveViewUri(intent)?.let { PlaybackViewModel.DelayedAction.Open(it) }
?: PlaybackViewModel.DelayedAction.RestoreState
playbackModel.performAction(this, action)
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
val uri = retrieveViewUri(intent)
if (uri != null) {
playbackModel.performAction(this, PlaybackViewModel.DelayedAction.Open(uri))
}
}
private fun retrieveViewUri(intent: Intent?): Uri? {
// If this intent is a valid view intent that has not been used already, give it // If this intent is a valid view intent that has not been used already, give it
// to PlaybackViewModel to be used later. // to PlaybackViewModel to be used later.
if (intent != null) { if (intent != null) {
@ -84,9 +96,11 @@ class MainActivity : AppCompatActivity() {
if (action == Intent.ACTION_VIEW && !isConsumed) { if (action == Intent.ACTION_VIEW && !isConsumed) {
// Mark the intent as used so this does not fire again // Mark the intent as used so this does not fire again
intent.putExtra(KEY_INTENT_USED, true) intent.putExtra(KEY_INTENT_USED, true)
intent.data?.let { fileUri -> playbackModel.play(fileUri, this) } return intent.data
} }
} }
return null
} }
private fun setupTheme() { private fun setupTheme() {

View file

@ -117,9 +117,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// Handle the loader response. // Handle the loader response.
when (response) { when (response) {
// Ok, start restoring playback now
is MusicStore.Response.Ok -> playbackModel.setupPlayback(requireContext())
// Error, show the error to the user // Error, show the error to the user
is MusicStore.Response.Err -> { is MusicStore.Response.Err -> {
logD("Received Response.Err") logD("Received Response.Err")
@ -147,7 +144,7 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
show() show()
} }
} }
null -> {} else -> {}
} }
} }

View file

@ -34,7 +34,6 @@ import org.oxycblt.auxio.playback.state.PlaybackMode
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.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -46,12 +45,12 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* class.** * class.**
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore.Callback {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private var intentUri: Uri? = null private var pendingDelayedAction: DelayedActionImpl? = null
private val _song = MutableLiveData<Song?>() private val _song = MutableLiveData<Song?>()
/** The current song. */ /** The current song. */
@ -82,6 +81,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
get() = _nextUp get() = _nextUp
init { init {
musicStore.addCallback(this)
playbackManager.addCallback(this) playbackManager.addCallback(this)
// If the PlaybackViewModel was cleared [Signified by PlaybackStateManager still being // If the PlaybackViewModel was cleared [Signified by PlaybackStateManager still being
@ -89,12 +89,53 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// ViewModel state. If it isn't, then wait for MainFragment to give the command to restore // ViewModel state. If it isn't, then wait for MainFragment to give the command to restore
// PlaybackStateManager. // PlaybackStateManager.
if (playbackManager.isInitialized) { if (playbackManager.isInitialized) {
restorePlaybackState() onNewPlayback(playbackManager.index, playbackManager.queue, playbackManager.parent)
onPositionChanged(playbackManager.positionMs)
onPlayingChanged(playbackManager.isPlaying)
onShuffledChanged(playbackManager.isShuffled)
onRepeatChanged(playbackManager.repeatMode)
} }
} }
// --- PLAYING FUNCTIONS --- // --- PLAYING FUNCTIONS ---
/**
* Perform the given [DelayedAction].
*
* A "delayed action" is a class of playback actions that must have music present to function,
* usually alongside a context too. Examples include:
* - Opening files
* - Restoring the playback state
* - Future app shortcuts
*
* We would normally want to put this kind of functionality into PlaybackService, but it's
* lifecycle makes that more or less impossible.
*/
fun performAction(context: Context, action: DelayedAction) {
val library = musicStore.library
val actionImpl = DelayedActionImpl(context.applicationContext, action)
if (library != null) {
performActionImpl(actionImpl, library)
} else {
pendingDelayedAction = actionImpl
}
}
private fun performActionImpl(action: DelayedActionImpl, library: MusicStore.Library) {
when (action.inner) {
is DelayedAction.RestoreState -> {
if (!playbackManager.isInitialized) {
viewModelScope.launch { playbackManager.restoreState(action.context) }
}
}
is DelayedAction.Open -> {
library
.findSongForUri(action.inner.uri, action.context.contentResolver)
?.let(::play)
}
}
}
/** /**
* Play a [song] with the [mode] specified. [mode] will default to the preferred song playback * Play a [song] with the [mode] specified. [mode] will default to the preferred song playback
* mode of the user if not specified. * mode of the user if not specified.
@ -142,26 +183,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.play(genre, shuffled) playbackManager.play(genre, shuffled)
} }
/**
* Play using a file [uri]. This will not play instantly during the initial startup sequence.
*/
fun play(uri: Uri, context: Context) {
// Check if everything is already running to run the URI play
if (playbackManager.isInitialized && musicStore.library != null) {
playUriImpl(uri, context)
} else {
logD("Cant play this URI right now, waiting")
intentUri = uri
}
}
/** Play with a file URI. This is called after [play] once its deemed safe to do so. */
private fun playUriImpl(uri: Uri, context: Context) {
logD("Playing with uri $uri")
val library = musicStore.library ?: return
library.findSongForUri(uri, context.contentResolver)?.let { song -> play(song) }
}
/** Shuffle all songs */ /** Shuffle all songs */
fun shuffleAll() { fun shuffleAll() {
playbackManager.shuffleAll() playbackManager.shuffleAll()
@ -265,42 +286,20 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
} }
} }
/** /** An action delayed until the complete load of the music library. */
* Restore playback on startup. This can do one of two things: sealed class DelayedAction {
* - Play a file intent that was given by MainActivity in [play] object RestoreState : DelayedAction()
* - Restore the last playback state if there is no active file intent. data class Open(val uri: Uri) : DelayedAction()
*/
fun setupPlayback(context: Context) {
val intentUri = intentUri
if (intentUri != null) {
playUriImpl(intentUri, context)
// Remove the uri after finishing the calls so that this does not fire again.
this.intentUri = null
} else if (!playbackManager.isInitialized) {
// Otherwise just restore
viewModelScope.launch { playbackManager.restoreState(context) }
}
} }
/** private data class DelayedActionImpl(val context: Context, val inner: DelayedAction)
* Attempt to restore the current playback state from an existing [PlaybackStateManager]
* instance.
*/
private fun restorePlaybackState() {
logD("Attempting to restore playback state")
onNewPlayback(playbackManager.index, playbackManager.queue, playbackManager.parent)
onPositionChanged(playbackManager.positionMs)
onPlayingChanged(playbackManager.isPlaying)
onShuffledChanged(playbackManager.isShuffled)
onRepeatChanged(playbackManager.repeatMode)
}
// --- OVERRIDES --- // --- OVERRIDES ---
override fun onCleared() { override fun onCleared() {
musicStore.removeCallback(this)
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
pendingDelayedAction = null
} }
override fun onIndexMoved(index: Int) { override fun onIndexMoved(index: Int) {
@ -333,4 +332,14 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
override fun onRepeatChanged(repeatMode: RepeatMode) { override fun onRepeatChanged(repeatMode: RepeatMode) {
_repeatMode.value = repeatMode _repeatMode.value = repeatMode
} }
override fun onMusicUpdate(response: MusicStore.Response) {
if (response is MusicStore.Response.Ok) {
val action = pendingDelayedAction
if (action != null) {
performActionImpl(action, response.library)
pendingDelayedAction = null
}
}
}
} }

View file

@ -44,9 +44,6 @@ import org.oxycblt.auxio.util.logD
* *
* TODO: Add a controller role and move song loading/seeking to that * TODO: Add a controller role and move song loading/seeking to that
* *
* TODO: Make PlaybackViewModel pass "delayed actions" to this and then await the service to start
* it???
*
* TODO: Bug test app behavior when playback stops * TODO: Bug test app behavior when playback stops
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
@ -125,6 +122,7 @@ class PlaybackStateManager private constructor() {
} }
applyNewQueue(library, settingsManager.keepShuffle && isShuffled, song) applyNewQueue(library, settingsManager.keepShuffle && isShuffled, song)
seekTo(0)
notifyNewPlayback() notifyNewPlayback()
notifyShuffledChanged() notifyShuffledChanged()
isPlaying = true isPlaying = true
@ -139,6 +137,7 @@ class PlaybackStateManager private constructor() {
val library = musicStore.library ?: return val library = musicStore.library ?: return
this.parent = parent this.parent = parent
applyNewQueue(library, shuffled, null) applyNewQueue(library, shuffled, null)
seekTo(0)
notifyNewPlayback() notifyNewPlayback()
notifyShuffledChanged() notifyShuffledChanged()
isPlaying = true isPlaying = true
@ -150,6 +149,7 @@ class PlaybackStateManager private constructor() {
val library = musicStore.library ?: return val library = musicStore.library ?: return
parent = null parent = null
applyNewQueue(library, true, null) applyNewQueue(library, true, null)
seekTo(0)
notifyNewPlayback() notifyNewPlayback()
notifyShuffledChanged() notifyShuffledChanged()
isPlaying = true isPlaying = true
@ -182,6 +182,7 @@ class PlaybackStateManager private constructor() {
private fun goto(idx: Int, play: Boolean) { private fun goto(idx: Int, play: Boolean) {
index = idx index = idx
seekTo(0)
notifyIndexMoved() notifyIndexMoved()
isPlaying = play isPlaying = play
} }
@ -261,8 +262,6 @@ class PlaybackStateManager private constructor() {
newIndex = keep?.let(newQueue::indexOf) ?: 0 newIndex = keep?.let(newQueue::indexOf) ?: 0
} }
logD("$newIndex $newQueue")
_queue = newQueue _queue = newQueue
index = newIndex index = newIndex
isShuffled = shuffled isShuffled = shuffled

View file

@ -54,6 +54,13 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
playbackManager.addCallback(this) playbackManager.addCallback(this)
settingsManager.addCallback(this) settingsManager.addCallback(this)
mediaSession.setCallback(this) mediaSession.setCallback(this)
if (playbackManager.isInitialized) {
updateMediaMetadata(playbackManager.song)
invalidateSessionState()
onRepeatChanged(playbackManager.repeatMode)
onShuffledChanged(playbackManager.isShuffled)
}
} }
fun release() { fun release() {

View file

@ -263,7 +263,6 @@ class PlaybackService :
} }
logD("Loading ${song.rawName}") logD("Loading ${song.rawName}")
player.seekTo(0)
player.setMediaItem(MediaItem.fromUri(song.uri)) player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare() player.prepare()
notificationComponent.updateMetadata(song, playbackManager.parent) notificationComponent.updateMetadata(song, playbackManager.parent)
@ -287,6 +286,7 @@ class PlaybackService :
} }
override fun onSeek(positionMs: Long) { override fun onSeek(positionMs: Long) {
logD("Seeking to ${positionMs}ms")
player.seekTo(positionMs) player.seekTo(positionMs)
} }
@ -374,14 +374,12 @@ class PlaybackService :
when (intent.action) { when (intent.action) {
// --- SYSTEM EVENTS --- // --- SYSTEM EVENTS ---
// Android has four different ways of handling audio plug events for some reason: // Android has three different ways of handling audio plug events for some reason:
// 1. ACTION_HEADSET_PLUG, which only works with wired headsets // 1. ACTION_HEADSET_PLUG, which only works with wired headsets
// 2. ACTION_SCO_AUDIO_STATE_UPDATED, which only works with pausing from a plug // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// event and I'm not even sure if it's needed
// 3. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
// a non-starter since both require me to display a permission prompt // a non-starter since both require me to display a permission prompt
// 4. Some weird internal framework thing that also handles bluetooth headsets??? // 3. Some weird internal framework thing that also handles bluetooth headsets???
// //
// They should have just stopped at ACTION_HEADSET_PLUG. // They should have just stopped at ACTION_HEADSET_PLUG.
AudioManager.ACTION_HEADSET_PLUG -> { AudioManager.ACTION_HEADSET_PLUG -> {

View file

@ -29,7 +29,9 @@ import org.oxycblt.auxio.util.getDrawableSafe
/** /**
* A [SwitchPreferenceCompat] that emulates the M3 switches until the design team actually bothers * A [SwitchPreferenceCompat] that emulates the M3 switches until the design team actually bothers
* to add them to MDC TODO: Remove this once MaterialSwitch is stabilized. * to add them to MDC
*
* TODO: Remove this once MaterialSwitch is stabilized.
*/ */
class M3SwitchPreference class M3SwitchPreference
@JvmOverloads @JvmOverloads