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.
This commit is contained in:
parent
a9bbdff25d
commit
e5d7cdc340
9 changed files with 212 additions and 172 deletions
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -52,6 +52,8 @@ import org.oxycblt.auxio.util.logW
|
|||
*/
|
||||
class PlaybackStateManager private constructor() {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val callbacks = mutableListOf<Callback>()
|
||||
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<Callback>()
|
||||
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<Song>, 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) {}
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Song>, 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) {
|
||||
|
|
Loading…
Reference in a new issue