playback: move delayed actions to service

Make PlaybackService handle delayed actions.

I wanted to do this before, but technical limitations always
stopped me from doing so. Turns out all I needed was a dash
of global mutable state to make it all work. This is actually
really good, as it separates concerns better and paves the way
for future improvements to the service.
This commit is contained in:
Alexander Capehart 2022-08-28 19:06:24 -06:00
parent 9fae621f7e
commit b2085e440e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 97 additions and 71 deletions

View file

@ -28,6 +28,7 @@ import androidx.core.view.updatePadding
import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.androidViewModels
@ -70,7 +71,7 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, PlaybackService::class.java))
if (!startIntentDelayedAction(intent)) {
playbackModel.startDelayedAction(PlaybackViewModel.DelayedAction.RestoreState)
playbackModel.startAction(PlaybackStateManager.ControllerAction.RestoreState)
}
}
@ -97,14 +98,14 @@ class MainActivity : AppCompatActivity() {
val action =
when (intent.action) {
Intent.ACTION_VIEW ->
PlaybackViewModel.DelayedAction.Open(intent.data ?: return false)
PlaybackStateManager.ControllerAction.Open(intent.data ?: return false)
AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> {
PlaybackViewModel.DelayedAction.ShuffleAll
PlaybackStateManager.ControllerAction.ShuffleAll
}
else -> return false
}
playbackModel.startDelayedAction(action)
playbackModel.startAction(action)
return true
}

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.playback
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
@ -28,7 +27,6 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
@ -47,13 +45,10 @@ import org.oxycblt.auxio.util.logE
* @author OxygenCobalt
*/
class PlaybackViewModel(application: Application) :
AndroidViewModel(application), PlaybackStateManager.Callback, MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
AndroidViewModel(application), PlaybackStateManager.Callback {
private val settings = Settings(application)
private val playbackManager = PlaybackStateManager.getInstance()
private var pendingDelayedAction: DelayedAction? = null
private val _song = MutableStateFlow<Song?>(null)
/** The current song. */
val song: StateFlow<Song?>
@ -80,7 +75,6 @@ class PlaybackViewModel(application: Application) :
get() = playbackManager.currentAudioSessionId
init {
musicStore.addCallback(this)
playbackManager.addCallback(this)
}
@ -136,41 +130,16 @@ class PlaybackViewModel(application: Application) :
}
/**
* Perform the given [DelayedAction].
* Perform the given [PlaybackStateManager.ControllerAction].
*
* A "delayed action" is a class of playback actions that must have music present to function,
* usually alongside a context too. Examples include:
* A "controller 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
* - 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 startDelayedAction(action: DelayedAction) {
val library = musicStore.library
if (library != null) {
performActionImpl(action, library)
} else {
pendingDelayedAction = action
}
}
private fun performActionImpl(action: DelayedAction, library: MusicStore.Library) {
when (action) {
is DelayedAction.RestoreState -> {
viewModelScope.launch {
playbackManager.restoreState(
PlaybackStateDatabase.getInstance(application), false)
}
}
is DelayedAction.ShuffleAll -> shuffleAll()
is DelayedAction.Open -> {
library.findSongForUri(application, action.uri)?.let { song ->
play(song, settings.libPlaybackMode)
}
}
}
fun startAction(action: PlaybackStateManager.ControllerAction) {
playbackManager.startAction(action)
}
// --- PLAYER FUNCTIONS ---
@ -280,19 +249,10 @@ class PlaybackViewModel(application: Application) :
}
}
/** An action delayed until the complete load of the music library. */
sealed class DelayedAction {
object RestoreState : DelayedAction()
object ShuffleAll : DelayedAction()
data class Open(val uri: Uri) : DelayedAction()
}
// --- OVERRIDES ---
override fun onCleared() {
musicStore.removeCallback(this)
playbackManager.removeCallback(this)
pendingDelayedAction = null
}
override fun onIndexMoved(index: Int) {
@ -319,14 +279,4 @@ class PlaybackViewModel(application: Application) :
override fun onRepeatChanged(repeatMode: RepeatMode) {
_repeatMode.value = repeatMode
}
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
val action = pendingDelayedAction
if (action != null) {
performActionImpl(action, library)
pendingDelayedAction = null
}
}
}
}

View file

@ -17,6 +17,7 @@
package org.oxycblt.auxio.playback.state
import android.net.Uri
import kotlin.math.max
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -93,6 +94,9 @@ class PlaybackStateManager private constructor() {
val currentAudioSessionId: Int?
get() = controller?.audioSessionId
/** An action that is awaiting the controller instance to consume it. */
var pendingAction: ControllerAction? = null
// --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>()
@ -130,7 +134,7 @@ class PlaybackStateManager private constructor() {
controller.loadSong(song)
controller.seekTo(positionMs)
controller.onPlayingChanged(isPlaying)
controller.onPlayingChanged(isPlaying)
requestAction(controller)
}
this.controller = controller
@ -322,7 +326,7 @@ class PlaybackStateManager private constructor() {
isShuffled = shuffled
}
// --- STATE FUNCTIONS ---
// --- CONTROLLER FUNCTIONS ---
/** Update the current [positionMs]. Only meant for use by [Controller] */
@Synchronized
@ -340,6 +344,29 @@ class PlaybackStateManager private constructor() {
}
}
@Synchronized
fun startAction(action: ControllerAction) {
val controller = controller
if (controller == null || !controller.onAction(action)) {
logD("Controller not present or did not consume action, ignoring.")
pendingAction = action
}
}
/** Request the stored [Controller.Action] */
@Synchronized
fun requestAction(controller: Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) {
logW("Given controller did not match current controller")
return
}
if (pendingAction?.let(controller::onAction) == true) {
logD("Pending action consumed")
pendingAction = null
}
}
/**
* **Seek** to a [positionMs].
* @param positionMs The position to seek to in millis.
@ -523,11 +550,17 @@ class PlaybackStateManager private constructor() {
/** Called when the playing state is changed. */
fun onPlayingChanged(isPlaying: Boolean)
// /** Called when the repeat mode is changed. */
// fun onRepeatChanged(repeatMode: RepeatMode)
//
// /** Called when the shuffled state is changed. */
// fun onShuffledChanged(isShuffled: Boolean)
/**
* Called when [PlaybackStateManager] desires some [ControllerAction] to be completed.
* Returns true if the action was consumed, false otherwise.
*/
fun onAction(action: ControllerAction): Boolean
}
sealed class ControllerAction {
object RestoreState : ControllerAction()
object ShuffleAll : ControllerAction()
data class Open(val uri: Uri) : ControllerAction()
}
/**

View file

@ -46,6 +46,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
@ -77,7 +78,8 @@ class PlaybackService :
Player.Listener,
PlaybackStateManager.Controller,
MediaSessionComponent.Callback,
Settings.Callback {
Settings.Callback,
MusicStore.Callback {
// Player components
private lateinit var player: ExoPlayer
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
@ -89,6 +91,7 @@ class PlaybackService :
// Managers
private val playbackManager = PlaybackStateManager.getInstance()
private val musicStore = MusicStore.getInstance()
private lateinit var settings: Settings
// State
@ -99,6 +102,7 @@ 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)
// --- SERVICE OVERRIDES ---
@ -139,7 +143,11 @@ class PlaybackService :
player.addListener(this)
settings = Settings(this, this)
foregroundManager = ForegroundManager(this)
playbackManager.registerController(this)
musicStore.addCallback(this)
positionScope.launch {
while (true) {
playbackManager.synchronizePosition(this@PlaybackService, player.currentPosition)
@ -147,9 +155,6 @@ class PlaybackService :
}
}
settings = Settings(this, this)
foregroundManager = ForegroundManager(this)
widgetComponent = WidgetComponent(this)
mediaSessionComponent = MediaSessionComponent(this, player, this)
@ -334,6 +339,36 @@ class PlaybackService :
player.playWhenReady = isPlaying
}
override fun onAction(action: PlaybackStateManager.ControllerAction): Boolean {
val library = musicStore.library
if (library != null) {
logD("Performing action: $action")
when (action) {
is PlaybackStateManager.ControllerAction.RestoreState -> {
restoreScope.launch {
playbackManager.restoreState(
PlaybackStateDatabase.getInstance(this@PlaybackService), false)
}
}
is PlaybackStateManager.ControllerAction.ShuffleAll -> {
playbackManager.shuffleAll(settings)
}
is PlaybackStateManager.ControllerAction.Open -> {
library.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play(song, settings.libPlaybackMode, settings)
}
}
}
return true
}
return false
}
// --- MEDIASESSIONCOMPONENT OVERRIDES ---
override fun onPostNotification(
notification: NotificationComponent?,
reason: MediaSessionComponent.PostingReason
@ -353,6 +388,13 @@ class PlaybackService :
}
}
// --- MUSICSTORE OVERRIDES ---
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
playbackManager.requestAction(this)
}
}
// --- SETTINGSMANAGER OVERRIDES ---
override fun onSettingChanged(key: String) {