playback: rework controller into InternalPlayer

Rework the Controller interface into a standalone interface called
InternalPlayer.

This is mostly preparation for further changes.
This commit is contained in:
Alexander Capehart 2022-08-29 09:13:37 -06:00
parent 8a15868ba1
commit 4d02dfb578
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 120 additions and 101 deletions

View file

@ -28,7 +28,7 @@ import androidx.core.view.updatePadding
import org.oxycblt.auxio.databinding.ActivityMainBinding import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.music.system.IndexerService import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.system.PlaybackService import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.androidViewModels import org.oxycblt.auxio.util.androidViewModels
@ -70,17 +70,17 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, IndexerService::class.java)) startService(Intent(this, IndexerService::class.java))
startService(Intent(this, PlaybackService::class.java)) startService(Intent(this, PlaybackService::class.java))
if (!startIntentDelayedAction(intent)) { if (!startIntentAction(intent)) {
playbackModel.startAction(PlaybackStateManager.ControllerAction.RestoreState) playbackModel.startAction(InternalPlayer.Action.RestoreState)
} }
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
startIntentDelayedAction(intent) startIntentAction(intent)
} }
private fun startIntentDelayedAction(intent: Intent?): Boolean { private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) { if (intent == null) {
return false return false
} }
@ -97,10 +97,9 @@ class MainActivity : AppCompatActivity() {
val action = val action =
when (intent.action) { when (intent.action) {
Intent.ACTION_VIEW -> Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
PlaybackStateManager.ControllerAction.Open(intent.data ?: return false)
AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> { AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> {
PlaybackStateManager.ControllerAction.ShuffleAll InternalPlayer.Action.ShuffleAll
} }
else -> return false else -> return false
} }

View file

@ -104,11 +104,7 @@ class MusicStore private constructor() {
/** Sanitize an old item to find the corresponding item in a new library. */ /** Sanitize an old item to find the corresponding item in a new library. */
fun sanitize(genre: Genre) = findGenreById(genre.id) fun sanitize(genre: Genre) = findGenreById(genre.id)
/** /** Find a song for a [uri]. */
* Find a song for a [uri], this is similar to [findSong], but with some kind of content
* uri.
* @return The corresponding [Song] for this [uri], null if there isn't one.
*/
fun findSongForUri(context: Context, uri: Uri) = fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(uri, arrayOf(OpenableColumns.DISPLAY_NAME)) { context.contentResolverSafe.useQuery(uri, arrayOf(OpenableColumns.DISPLAY_NAME)) {
cursor -> cursor ->

View file

@ -47,7 +47,6 @@ class MusicViewModel : ViewModel(), Indexer.Callback {
override fun onIndexerStateChanged(state: Indexer.State?) { override fun onIndexerStateChanged(state: Indexer.State?) {
_indexerState.value = state _indexerState.value = state
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) { if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
_libraryExists.value = true _libraryExists.value = true
} }

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
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.PlaybackMode import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
@ -63,6 +64,7 @@ class PlaybackViewModel(application: Application) :
/** The current playback position, in seconds */ /** The current playback position, in seconds */
val positionSecs: StateFlow<Long> val positionSecs: StateFlow<Long>
get() = _positionSecs get() = _positionSecs
private val _repeatMode = MutableStateFlow(RepeatMode.NONE) private val _repeatMode = MutableStateFlow(RepeatMode.NONE)
/** The current repeat mode, see [RepeatMode] for more information */ /** The current repeat mode, see [RepeatMode] for more information */
val repeatMode: StateFlow<RepeatMode> val repeatMode: StateFlow<RepeatMode>
@ -130,15 +132,15 @@ class PlaybackViewModel(application: Application) :
} }
/** /**
* Perform the given [PlaybackStateManager.ControllerAction]. * Perform the given [InternalPlayer.Action].
* *
* A "controller action" is a class of playback actions that must have music present to * These are a class of playback actions that must have music present to function, usually
* function, usually alongside a context too. Examples include: * alongside a context too. Examples include:
* - Opening files * - Opening files
* - Restoring the playback state * - Restoring the playback state
* - App shortcuts * - App shortcuts
*/ */
fun startAction(action: PlaybackStateManager.ControllerAction) { fun startAction(action: InternalPlayer.Action) {
playbackManager.startAction(action) playbackManager.startAction(action)
} }

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.state
import android.net.Uri
import org.oxycblt.auxio.music.Song
/** Represents a class capable of managing the internal player. */
interface InternalPlayer {
/** The audio session ID of the player instance. */
val audioSessionId: Int
/** Whether the player should rewind instead of going to the previous song. */
val shouldRewindWithPrev: Boolean
/** Called when a new song should be loaded into the player. */
fun loadSong(song: Song?)
/** Seek to [positionMs] in the player. */
fun seekTo(positionMs: Long)
/** Called when the playing state is changed. */
fun onPlayingChanged(isPlaying: Boolean)
/**
* Called when [PlaybackStateManager] desires some [Action] to be completed. Returns true if the
* action was consumed, false otherwise.
*/
fun onAction(action: Action): Boolean
sealed class Action {
object RestoreState : Action()
object ShuffleAll : Action()
data class Open(val uri: Uri) : Action()
}
}

View file

@ -17,7 +17,6 @@
package org.oxycblt.auxio.playback.state package org.oxycblt.auxio.playback.state
import android.net.Uri
import kotlin.math.max import kotlin.math.max
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -46,7 +45,7 @@ import org.oxycblt.auxio.util.logW
* [org.oxycblt.auxio.playback.system.PlaybackService]. * [org.oxycblt.auxio.playback.system.PlaybackService].
* *
* Internal consumers should usually use [Callback], however the component that manages the player * Internal consumers should usually use [Callback], however the component that manages the player
* itself should instead operate as a [Controller]. * itself should instead operate as a [InternalPlayer].
* *
* All access should be done with [PlaybackStateManager.getInstance]. * All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt * @author OxygenCobalt
@ -90,17 +89,17 @@ class PlaybackStateManager private constructor() {
var isInitialized = false var isInitialized = false
private set private set
/** The current audio session ID of the controller. Null if no controller present. */ /** The current audio session ID of the internal player. Null if no internal player present. */
val currentAudioSessionId: Int? val currentAudioSessionId: Int?
get() = controller?.audioSessionId get() = internalPlayer?.audioSessionId
/** An action that is awaiting the controller instance to consume it. */ /** An action that is awaiting the internal player instance to consume it. */
var pendingAction: ControllerAction? = null var pendingAction: InternalPlayer.Action? = null
// --- CALLBACKS --- // --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
private var controller: Controller? = null 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
@ -122,33 +121,33 @@ class PlaybackStateManager private constructor() {
callbacks.remove(callback) callbacks.remove(callback)
} }
/** Register a [Controller] with this instance. */ /** Register a [InternalPlayer] with this instance. */
@Synchronized @Synchronized
fun registerController(controller: Controller) { fun registerInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.controller != null) { if (BuildConfig.DEBUG && this.internalPlayer != null) {
logW("Controller is already registered") logW("Internal player is already registered")
return return
} }
if (isInitialized) { if (isInitialized) {
controller.loadSong(song) internalPlayer.loadSong(song)
controller.seekTo(positionMs) internalPlayer.seekTo(positionMs)
controller.onPlayingChanged(isPlaying) internalPlayer.onPlayingChanged(isPlaying)
requestAction(controller) requestAction(internalPlayer)
} }
this.controller = controller this.internalPlayer = internalPlayer
} }
/** Unregister a [Controller] with this instance. */ /** Unregister a [InternalPlayer] with this instance. */
@Synchronized @Synchronized
fun unregisterController(controller: Controller) { fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.controller !== controller) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given controller did not match current controller") logW("Given internal player did not match current internal player")
return return
} }
this.controller = null this.internalPlayer = null
} }
// --- PLAYING FUNCTIONS --- // --- PLAYING FUNCTIONS ---
@ -215,7 +214,7 @@ class PlaybackStateManager private constructor() {
@Synchronized @Synchronized
fun prev() { fun prev() {
// 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 (controller?.shouldPrevRewind() == true) { if (internalPlayer?.shouldRewindWithPrev == true) {
rewind() rewind()
isPlaying = true isPlaying = true
} else { } else {
@ -326,13 +325,13 @@ class PlaybackStateManager private constructor() {
isShuffled = shuffled isShuffled = shuffled
} }
// --- CONTROLLER FUNCTIONS --- // --- INTERNAL PLAYER FUNCTIONS ---
/** Update the current [positionMs]. Only meant for use by [Controller] */ /** Update the current [positionMs]. Only meant for use by [InternalPlayer] */
@Synchronized @Synchronized
fun synchronizePosition(controller: Controller, positionMs: Long) { fun synchronizePosition(internalPlayer: InternalPlayer, positionMs: Long) {
if (BuildConfig.DEBUG && this.controller !== controller) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given controller did not match current controller") logW("Given internal player did not match current internal player")
return return
} }
@ -345,23 +344,23 @@ class PlaybackStateManager private constructor() {
} }
@Synchronized @Synchronized
fun startAction(action: ControllerAction) { fun startAction(action: InternalPlayer.Action) {
val controller = controller val internalPlayer = internalPlayer
if (controller == null || !controller.onAction(action)) { if (internalPlayer == null || !internalPlayer.onAction(action)) {
logD("Controller not present or did not consume action, ignoring.") logD("Internal player not present or did not consume action, ignoring")
pendingAction = action pendingAction = action
} }
} }
/** Request the stored [Controller.Action] */ /** Request the stored [InternalPlayer.Action] */
@Synchronized @Synchronized
fun requestAction(controller: Controller) { fun requestAction(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.controller !== controller) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given controller did not match current controller") logW("Given internal player did not match current internal player")
return return
} }
if (pendingAction?.let(controller::onAction) == true) { if (pendingAction?.let(internalPlayer::onAction) == true) {
logD("Pending action consumed") logD("Pending action consumed")
pendingAction = null pendingAction = null
} }
@ -374,7 +373,7 @@ class PlaybackStateManager private constructor() {
@Synchronized @Synchronized
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
this.positionMs = positionMs this.positionMs = positionMs
controller?.seekTo(positionMs) internalPlayer?.seekTo(positionMs)
notifyPositionChanged() notifyPositionChanged()
} }
@ -467,7 +466,7 @@ class PlaybackStateManager private constructor() {
notifyNewPlayback() notifyNewPlayback()
if (index > -1) { if (index > -1) {
// Controller 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
seekTo(oldPosition) seekTo(oldPosition)
} }
} }
@ -484,7 +483,7 @@ class PlaybackStateManager private constructor() {
// --- CALLBACKS --- // --- CALLBACKS ---
private fun notifyIndexMoved() { private fun notifyIndexMoved() {
controller?.loadSong(song) internalPlayer?.loadSong(song)
for (callback in callbacks) { for (callback in callbacks) {
callback.onIndexMoved(index) callback.onIndexMoved(index)
} }
@ -503,14 +502,14 @@ class PlaybackStateManager private constructor() {
} }
private fun notifyNewPlayback() { private fun notifyNewPlayback() {
controller?.loadSong(song) 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 notifyPlayingChanged() {
controller?.onPlayingChanged(isPlaying) internalPlayer?.onPlayingChanged(isPlaying)
for (callback in callbacks) { for (callback in callbacks) {
callback.onPlayingChanged(isPlaying) callback.onPlayingChanged(isPlaying)
} }
@ -534,35 +533,6 @@ class PlaybackStateManager private constructor() {
} }
} }
/** Represents a class capable of managing the internal player. */
interface Controller {
val audioSessionId: Int
/** Called when a new song should be loaded into the player. */
fun loadSong(song: Song?)
/** Seek to [positionMs] in the player. */
fun seekTo(positionMs: Long)
/** Called when the class wants to determine whether it should rewind or skip back. */
fun shouldPrevRewind(): Boolean
/** Called when the playing state is changed. */
fun onPlayingChanged(isPlaying: 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()
}
/** /**
* The interface for receiving updates from [PlaybackStateManager]. Add the callback to * The interface for receiving updates from [PlaybackStateManager]. Add the callback to
* [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback]. * [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
@ -583,7 +553,7 @@ class PlaybackStateManager private constructor() {
/** Called when the playing state is changed. */ /** Called when the playing state is changed. */
fun onPlayingChanged(isPlaying: Boolean) {} fun onPlayingChanged(isPlaying: Boolean) {}
/** Called when the position is re-synchronized by the controller. */ /** Called when the position is re-synchronized by the internal player. */
fun onPositionChanged(positionMs: Long) {} fun onPositionChanged(positionMs: Long) {}
/** Called when the repeat mode is changed. */ /** Called when the repeat mode is changed. */

View file

@ -49,6 +49,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
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
@ -76,7 +77,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider
class PlaybackService : class PlaybackService :
Service(), Service(),
Player.Listener, Player.Listener,
PlaybackStateManager.Controller, InternalPlayer,
MediaSessionComponent.Callback, MediaSessionComponent.Callback,
Settings.Callback, Settings.Callback,
MusicStore.Callback { MusicStore.Callback {
@ -146,7 +147,7 @@ class PlaybackService :
settings = Settings(this, this) settings = Settings(this, this)
foregroundManager = ForegroundManager(this) foregroundManager = ForegroundManager(this)
playbackManager.registerController(this) playbackManager.registerInternalPlayer(this)
musicStore.addCallback(this) musicStore.addCallback(this)
positionScope.launch { positionScope.launch {
while (true) { while (true) {
@ -198,7 +199,7 @@ class PlaybackService :
// Pause just in case this destruction was unexpected. // Pause just in case this destruction was unexpected.
playbackManager.isPlaying = false playbackManager.isPlaying = false
playbackManager.unregisterController(this) playbackManager.unregisterInternalPlayer(this)
settings.release() settings.release()
unregisterReceiver(systemReceiver) unregisterReceiver(systemReceiver)
serviceJob.cancel() serviceJob.cancel()
@ -279,6 +280,9 @@ class PlaybackService :
override val audioSessionId: Int override val audioSessionId: Int
get() = player.audioSessionId get() = player.audioSessionId
override val shouldRewindWithPrev: Boolean
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun loadSong(song: Song?) { override fun loadSong(song: Song?) {
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.
@ -332,29 +336,26 @@ class PlaybackService :
player.seekTo(positionMs) player.seekTo(positionMs)
} }
override fun shouldPrevRewind() =
settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun onPlayingChanged(isPlaying: Boolean) { override fun onPlayingChanged(isPlaying: Boolean) {
player.playWhenReady = isPlaying player.playWhenReady = isPlaying
} }
override fun onAction(action: PlaybackStateManager.ControllerAction): Boolean { override fun onAction(action: InternalPlayer.Action): Boolean {
val library = musicStore.library val library = musicStore.library
if (library != null) { if (library != null) {
logD("Performing action: $action") logD("Performing action: $action")
when (action) { when (action) {
is PlaybackStateManager.ControllerAction.RestoreState -> { is InternalPlayer.Action.RestoreState -> {
restoreScope.launch { restoreScope.launch {
playbackManager.restoreState( playbackManager.restoreState(
PlaybackStateDatabase.getInstance(this@PlaybackService), false) PlaybackStateDatabase.getInstance(this@PlaybackService), false)
} }
} }
is PlaybackStateManager.ControllerAction.ShuffleAll -> { is InternalPlayer.Action.ShuffleAll -> {
playbackManager.shuffleAll(settings) playbackManager.shuffleAll(settings)
} }
is PlaybackStateManager.ControllerAction.Open -> { is InternalPlayer.Action.Open -> {
library.findSongForUri(application, action.uri)?.let { song -> library.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play(song, settings.libPlaybackMode, settings) playbackManager.play(song, settings.libPlaybackMode, settings)
} }
@ -389,6 +390,7 @@ class PlaybackService :
} }
// --- MUSICSTORE OVERRIDES --- // --- MUSICSTORE OVERRIDES ---
override fun onLibraryChanged(library: MusicStore.Library?) { override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) { if (library != null) {
playbackManager.requestAction(this) playbackManager.requestAction(this)
@ -477,7 +479,7 @@ class PlaybackService :
} }
companion object { companion object {
private const val POS_POLL_INTERVAL = 1000L private const val POS_POLL_INTERVAL = 100L
private const val REWIND_THRESHOLD = 3000L private const val REWIND_THRESHOLD = 3000L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"

View file

@ -9,7 +9,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.4.0-alpha09' classpath 'com.android.tools.build:gradle:7.4.0-alpha10'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.6.1" classpath "com.diffplug.spotless:spotless-plugin-gradle:6.6.1"