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

View file

@ -117,9 +117,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
// Handle the loader response.
when (response) {
// Ok, start restoring playback now
is MusicStore.Response.Ok -> playbackModel.setupPlayback(requireContext())
// Error, show the error to the user
is MusicStore.Response.Err -> {
logD("Received Response.Err")
@ -147,7 +144,7 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
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.RepeatMode
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -46,12 +45,12 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* class.**
* @author OxygenCobalt
*/
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val playbackManager = PlaybackStateManager.getInstance()
private var intentUri: Uri? = null
private var pendingDelayedAction: DelayedActionImpl? = null
private val _song = MutableLiveData<Song?>()
/** The current song. */
@ -82,6 +81,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
get() = _nextUp
init {
musicStore.addCallback(this)
playbackManager.addCallback(this)
// 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
// PlaybackStateManager.
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 ---
/**
* 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
* mode of the user if not specified.
@ -142,26 +183,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
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 */
fun shuffleAll() {
playbackManager.shuffleAll()
@ -265,42 +286,20 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
}
}
/**
* Restore playback on startup. This can do one of two things:
* - Play a file intent that was given by MainActivity in [play]
* - Restore the last playback state if there is no active file intent.
*/
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) }
}
/** An action delayed until the complete load of the music library. */
sealed class DelayedAction {
object RestoreState : DelayedAction()
data class Open(val uri: Uri) : 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)
}
private data class DelayedActionImpl(val context: Context, val inner: DelayedAction)
// --- OVERRIDES ---
override fun onCleared() {
musicStore.removeCallback(this)
playbackManager.removeCallback(this)
pendingDelayedAction = null
}
override fun onIndexMoved(index: Int) {
@ -333,4 +332,14 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
override fun onRepeatChanged(repeatMode: 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: Make PlaybackViewModel pass "delayed actions" to this and then await the service to start
* it???
*
* TODO: Bug test app behavior when playback stops
*/
class PlaybackStateManager private constructor() {
@ -125,6 +122,7 @@ class PlaybackStateManager private constructor() {
}
applyNewQueue(library, settingsManager.keepShuffle && isShuffled, song)
seekTo(0)
notifyNewPlayback()
notifyShuffledChanged()
isPlaying = true
@ -139,6 +137,7 @@ class PlaybackStateManager private constructor() {
val library = musicStore.library ?: return
this.parent = parent
applyNewQueue(library, shuffled, null)
seekTo(0)
notifyNewPlayback()
notifyShuffledChanged()
isPlaying = true
@ -150,6 +149,7 @@ class PlaybackStateManager private constructor() {
val library = musicStore.library ?: return
parent = null
applyNewQueue(library, true, null)
seekTo(0)
notifyNewPlayback()
notifyShuffledChanged()
isPlaying = true
@ -182,6 +182,7 @@ class PlaybackStateManager private constructor() {
private fun goto(idx: Int, play: Boolean) {
index = idx
seekTo(0)
notifyIndexMoved()
isPlaying = play
}
@ -261,8 +262,6 @@ class PlaybackStateManager private constructor() {
newIndex = keep?.let(newQueue::indexOf) ?: 0
}
logD("$newIndex $newQueue")
_queue = newQueue
index = newIndex
isShuffled = shuffled

View file

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

View file

@ -263,7 +263,6 @@ class PlaybackService :
}
logD("Loading ${song.rawName}")
player.seekTo(0)
player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare()
notificationComponent.updateMetadata(song, playbackManager.parent)
@ -287,6 +286,7 @@ class PlaybackService :
}
override fun onSeek(positionMs: Long) {
logD("Seeking to ${positionMs}ms")
player.seekTo(positionMs)
}
@ -374,14 +374,12 @@ class PlaybackService :
when (intent.action) {
// --- 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
// 2. ACTION_SCO_AUDIO_STATE_UPDATED, which only works with pausing from a plug
// event and I'm not even sure if it's needed
// 3. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
// 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.
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
* 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
@JvmOverloads