playback: ramshack initial gapless playback impl

This commit is contained in:
Alexander Capehart 2024-01-07 15:52:19 -07:00
parent 5d5356e46e
commit 26d14ec6e1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
15 changed files with 1043 additions and 785 deletions

View file

@ -31,7 +31,7 @@ import javax.inject.Inject
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.InternalPlayer import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.system.PlaybackService import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.isNight
@ -76,7 +76,7 @@ class MainActivity : AppCompatActivity() {
if (!startIntentAction(intent)) { if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state. // No intent action to do, just restore the previously saved state.
playbackModel.startAction(InternalPlayer.Action.RestoreState) playbackModel.playDeferred(DeferredPlayback.RestoreState)
} }
} }
@ -137,15 +137,15 @@ class MainActivity : AppCompatActivity() {
val action = val action =
when (intent.action) { when (intent.action) {
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false) Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
else -> { else -> {
logW("Unexpected intent ${intent.action}") logW("Unexpected intent ${intent.action}")
return false return false
} }
} }
logD("Translated intent to $action") logD("Translated intent to $action")
playbackModel.startAction(action) playbackModel.playDeferred(action)
return true return true
} }

View file

@ -35,7 +35,6 @@ import kotlinx.coroutines.Job
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.contentResolverSafe
@ -139,22 +138,23 @@ class IndexerService :
logD("Music changed, updating shared objects") logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers // Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear() imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected // // Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to // // to a listener as it is bad practice for a shared object to attach to
// the listener system of another. // // the listener system of another.
playbackManager.toSavedState()?.let { savedState -> // playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState( // playbackManager.applySavedState(
PlaybackStateManager.SavedState( // PlaybackStateManager.SavedState(
parent = // parent =
savedState.parent?.let { musicRepository.find(it.uid) as? MusicParent }, // savedState.parent?.let { musicRepository.find(it.uid) as?
queueState = // MusicParent },
savedState.queueState.remap { song -> // queueState =
deviceLibrary.findSong(requireNotNull(song).uid) // savedState.queueState.remap { song ->
}, // deviceLibrary.findSong(requireNotNull(song).uid)
positionMs = savedState.positionMs, // },
repeatMode = savedState.repeatMode), // positionMs = savedState.positionMs,
true) // repeatMode = savedState.repeatMode),
} // true)
// }
} }
override fun onIndexingStateChanged() { override fun onIndexingStateChanged() {

View file

@ -36,9 +36,10 @@ import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.persist.PersistenceRepository
import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackEvent
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
@ -129,51 +130,52 @@ constructor(
playbackSettings.unregisterListener(this) playbackSettings.unregisterListener(this)
} }
override fun onIndexMoved(queue: Queue) { override fun onPlaybackEvent(event: PlaybackEvent) {
logD("Index moved, updating current song") when (event) {
_song.value = queue.currentSong is PlaybackEvent.IndexMoved -> {
} logD("Index moved, updating current song")
_song.value = event.currentSong
override fun onQueueChanged(queue: Queue, change: Queue.Change) { }
// Other types of queue changes preserve the current song. is PlaybackEvent.QueueChanged -> {
if (change.type == Queue.Change.Type.SONG) { // Other types of queue changes preserve the current song.
logD("Queue changed, updating current song") if (event.change.type == QueueChange.Type.SONG) {
_song.value = queue.currentSong logD("Queue changed, updating current song")
} _song.value = event.currentSong
}
override fun onQueueReordered(queue: Queue) {
logD("Queue completely changed, updating current song")
_isShuffled.value = queue.isShuffled
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
logD("New playback started, updating playback information")
_song.value = queue.currentSong
_parent.value = parent
_isShuffled.value = queue.isShuffled
}
override fun onStateChanged(state: InternalPlayer.State) {
logD("Player state changed, starting new position polling")
_isPlaying.value = state.isPlaying
// Still need to update the position now due to co-routine launch delays
_positionDs.value = state.calculateElapsedPositionMs().msToDs()
// Replace the previous position co-routine with a new one that uses the new
// state information.
lastPositionJob?.cancel()
lastPositionJob =
viewModelScope.launch {
while (true) {
_positionDs.value = state.calculateElapsedPositionMs().msToDs()
// Wait a deci-second for the next position tick.
delay(100)
} }
} }
} is PlaybackEvent.QueueReordered -> {
logD("Queue completely changed, updating current song")
override fun onRepeatChanged(repeatMode: RepeatMode) { _isShuffled.value = event.isShuffled
_repeatMode.value = repeatMode }
is PlaybackEvent.NewPlayback -> {
logD("New playback started, updating playback information")
_song.value = event.currentSong
_parent.value = event.parent
_isShuffled.value = event.isShuffled
}
is PlaybackEvent.ProgressionChanged -> {
logD("Progression changed, starting new position polling")
_isPlaying.value = event.progression.isPlaying
// Still need to update the position now due to co-routine launch delays
_positionDs.value = event.progression.calculateElapsedPositionMs().msToDs()
// Replace the previous position co-routine with a new one that uses the new
// state information.
lastPositionJob?.cancel()
lastPositionJob =
viewModelScope.launch {
while (true) {
_positionDs.value =
event.progression.calculateElapsedPositionMs().msToDs()
// Wait a deci-second for the next position tick.
delay(100)
}
}
}
is PlaybackEvent.RepeatModeChanged -> {
logD("Repeat mode changed, updating current mode")
_repeatMode.value = event.repeatMode
}
}
} }
override fun onBarActionChanged() { override fun onBarActionChanged() {
@ -223,8 +225,7 @@ constructor(
playFromGenreImpl(song, genre, isImplicitlyShuffled()) playFromGenreImpl(song, genre, isImplicitlyShuffled())
} }
private fun isImplicitlyShuffled() = private fun isImplicitlyShuffled() = playbackManager.isShuffled && playbackSettings.keepShuffle
playbackManager.queue.isShuffled && playbackSettings.keepShuffle
private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) { private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) {
when (with) { when (with) {
@ -416,9 +417,9 @@ constructor(
* *
* @param action The [InternalPlayer.Action] to perform eventually. * @param action The [InternalPlayer.Action] to perform eventually.
*/ */
fun startAction(action: InternalPlayer.Action) { fun playDeferred(action: DeferredPlayback) {
logD("Starting action $action") logD("Starting action $action")
playbackManager.startAction(action) playbackManager.playDeferred(action)
} }
// --- PLAYER FUNCTIONS --- // --- PLAYER FUNCTIONS ---
@ -572,13 +573,13 @@ constructor(
/** Toggle [isPlaying] (i.e from playing to paused) */ /** Toggle [isPlaying] (i.e from playing to paused) */
fun togglePlaying() { fun togglePlaying() {
logD("Toggling playing state") logD("Toggling playing state")
playbackManager.setPlaying(!playbackManager.playerState.isPlaying) playbackManager.playing(!playbackManager.progression.isPlaying)
} }
/** Toggle [isShuffled] (ex. from on to off) */ /** Toggle [isShuffled] (ex. from on to off) */
fun toggleShuffled() { fun toggleShuffled() {
logD("Toggling shuffled state") logD("Toggling shuffled state")
playbackManager.reorder(!playbackManager.queue.isShuffled) playbackManager.reorder(!playbackManager.isShuffled)
} }
/** /**
@ -588,7 +589,7 @@ constructor(
*/ */
fun toggleRepeatMode() { fun toggleRepeatMode() {
logD("Toggling repeat mode") logD("Toggling repeat mode")
playbackManager.repeatMode = playbackManager.repeatMode.increment() playbackManager.repeatMode(playbackManager.repeatMode.increment())
} }
// --- UI CONTROL --- // --- UI CONTROL ---

View file

@ -21,7 +21,6 @@ package org.oxycblt.auxio.playback.persist
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
@ -76,17 +75,19 @@ constructor(
val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent } val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent }
logD("Successfully read playback state") logD("Successfully read playback state")
return PlaybackStateManager.SavedState( // return PlaybackStateManager.SavedState(
parent = parent, // parent = parent,
queueState = // queueState =
Queue.SavedState( // Queue.SavedState(
heap.map { deviceLibrary.findSong(it.uid) }, // heap.map { deviceLibrary.findSong(it.uid) },
orderedMapping, // orderedMapping,
shuffledMapping, // shuffledMapping,
playbackState.index, // playbackState.index,
playbackState.songUid), // playbackState.songUid),
positionMs = playbackState.positionMs, // positionMs = playbackState.positionMs,
repeatMode = playbackState.repeatMode) // repeatMode = playbackState.repeatMode)
return null
} }
override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean { override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean {

View file

@ -24,9 +24,10 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackEvent
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -51,7 +52,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
val scrollTo: Event<Int> val scrollTo: Event<Int>
get() = _scrollTo get() = _scrollTo
private val _index = MutableStateFlow(playbackManager.queue.index) private val _index = MutableStateFlow(playbackManager.resolveQueue().index)
/** The index of the currently playing song in the queue. */ /** The index of the currently playing song in the queue. */
val index: StateFlow<Int> val index: StateFlow<Int>
get() = _index get() = _index
@ -60,42 +61,45 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
playbackManager.addListener(this) playbackManager.addListener(this)
} }
override fun onIndexMoved(queue: Queue) { override fun onPlaybackEvent(event: PlaybackEvent) {
logD("Index moved, synchronizing and scrolling to new position") when (event) {
_scrollTo.put(queue.index) is PlaybackEvent.IndexMoved -> {
_index.value = queue.index logD("Index moved, synchronizing and scrolling to new position")
} _scrollTo.put(event.index)
_index.value = event.index
override fun onQueueChanged(queue: Queue, change: Queue.Change) { }
// Queue changed trivially due to item mo -> Diff queue, stay at current index. is PlaybackEvent.QueueChanged -> {
logD("Updating queue display") // Queue changed trivially due to item mo -> Diff queue, stay at current index.
_queueInstructions.put(change.instructions) logD("Updating queue display")
_queue.value = queue.resolve() _queueInstructions.put(event.change.instructions)
if (change.type != Queue.Change.Type.MAPPING) { _queue.value = event.queue.queue
// Index changed, make sure it remains updated without actually scrolling to it. if (event.change.type != QueueChange.Type.MAPPING) {
logD("Index changed with queue, synchronizing new position") // Index changed, make sure it remains updated without actually scrolling to it.
_index.value = queue.index logD("Index changed with queue, synchronizing new position")
_index.value = event.queue.index
}
}
is PlaybackEvent.QueueReordered -> {
// Queue changed completely -> Replace queue, update index
logD("Queue changed completely, replacing queue and position")
_queueInstructions.put(UpdateInstructions.Replace(0))
_scrollTo.put(event.queue.index)
_queue.value = event.queue.queue
_index.value = event.queue.index
}
is PlaybackEvent.NewPlayback -> {
// Entirely new queue -> Replace queue, update index
logD("New playback, replacing queue and position")
_queueInstructions.put(UpdateInstructions.Replace(0))
_scrollTo.put(event.queue.index)
_queue.value = event.queue.queue
_index.value = event.queue.index
}
is PlaybackEvent.RepeatModeChanged,
is PlaybackEvent.ProgressionChanged -> {}
} }
} }
override fun onQueueReordered(queue: Queue) {
// Queue changed completely -> Replace queue, update index
logD("Queue changed completely, replacing queue and position")
_queueInstructions.put(UpdateInstructions.Replace(0))
_scrollTo.put(queue.index)
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index
logD("New playback, replacing queue and position")
_queueInstructions.put(UpdateInstructions.Replace(0))
_scrollTo.put(queue.index)
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
playbackManager.removeListener(this) playbackManager.removeListener(this)

View file

@ -27,11 +27,11 @@ import java.nio.ByteBuffer
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.pow import kotlin.math.pow
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackEvent
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
@ -70,26 +70,35 @@ constructor(
// --- OVERRIDES --- // --- OVERRIDES ---
override fun onIndexMoved(queue: Queue) { override fun onPlaybackEvent(event: PlaybackEvent) {
logD("Index moved, updating current song") when (event) {
applyReplayGain(queue.currentSong) is PlaybackEvent.IndexMoved -> {
} logD("Index moved, updating current song")
applyReplayGain(event.currentSong)
override fun onQueueChanged(queue: Queue, change: Queue.Change) { }
// Other types of queue changes preserve the current song. is PlaybackEvent.QueueChanged -> {
if (change.type == Queue.Change.Type.SONG) { // Queue changed trivially due to item mo -> Diff queue, stay at current index.
applyReplayGain(queue.currentSong) logD("Updating queue display")
// Other types of queue changes preserve the current song.
if (event.change.type == QueueChange.Type.SONG) {
applyReplayGain(event.currentSong)
}
}
is PlaybackEvent.NewPlayback -> {
logD("New playback started, updating playback information")
applyReplayGain(event.currentSong)
}
is PlaybackEvent.ProgressionChanged,
is PlaybackEvent.QueueReordered,
is PlaybackEvent.RepeatModeChanged -> {
// Nothing to do
}
} }
} }
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
logD("New playback started, updating playback information")
applyReplayGain(queue.currentSong)
}
override fun onReplayGainSettingsChanged() { override fun onReplayGainSettingsChanged() {
// ReplayGain config changed, we need to set it up again. // ReplayGain config changed, we need to set it up again.
applyReplayGain(playbackManager.queue.currentSong) applyReplayGain(playbackManager.currentSong)
} }
// --- REPLAYGAIN PARSING --- // --- REPLAYGAIN PARSING ---
@ -131,7 +140,7 @@ constructor(
logD("Using dynamic strategy") logD("Using dynamic strategy")
gain.album?.takeIf { gain.album?.takeIf {
playbackManager.parent is Album && playbackManager.parent is Album &&
playbackManager.queue.currentSong?.album == playbackManager.parent playbackManager.currentSong?.album == playbackManager.parent
} }
?: gain.track ?: gain.track
} }

View file

@ -1,183 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* InternalPlayer.kt is part of Auxio.
*
* 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 android.os.SystemClock
import android.support.v4.media.session.PlaybackStateCompat
import org.oxycblt.auxio.music.Song
/**
* An interface for internal audio playback. This can be used to coordinate what occurs in the
* background playback task.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface InternalPlayer {
/** The ID of the audio session started by this instance. */
val audioSessionId: Int
/** Whether the player should rewind before skipping back. */
val shouldRewindWithPrev: Boolean
/**
* Load a new [Song] into the internal player.
*
* @param song The [Song] to load, or null if playback should stop entirely.
* @param play Whether to start playing when the [Song] is loaded.
*/
fun loadSong(song: Song?, play: Boolean)
/**
* Called when an [Action] has been queued and this [InternalPlayer] is available to handle it.
*
* @param action The [Action] to perform.
* @return true if the action was handled, false otherwise.
*/
fun performAction(action: Action): Boolean
/**
* Get a [State] corresponding to the current player state.
*
* @param durationMs The duration of the currently playing track, in milliseconds. Required
* since the internal player cannot obtain an accurate duration itself.
*/
fun getState(durationMs: Long): State
/**
* Seek to a given position in the internal player.
*
* @param positionMs The position to seek to, in milliseconds.
*/
fun seekTo(positionMs: Long)
/**
* Set whether the player should play or not.
*
* @param isPlaying Whether to play or pause the current playback.
*/
fun setPlaying(isPlaying: Boolean)
/** Possible long-running background tasks handled by the background playback task. */
sealed interface Action {
/** Restore the previously saved playback state. */
data object RestoreState : Action
/**
* Start shuffled playback of the entire music library. Analogous to the "Shuffle All"
* shortcut.
*/
data object ShuffleAll : Action
/**
* Start playing an audio file at the given [Uri].
*
* @param uri The [Uri] of the audio file to start playing.
*/
data class Open(val uri: Uri) : Action
}
/**
* A representation of the current state of audio playback. Use [from] to create an instance.
*/
class State
private constructor(
/** Whether the player is actively playing audio or set to play audio in the future. */
val isPlaying: Boolean,
/** Whether the player is actively playing audio in this moment. */
private val isAdvancing: Boolean,
/** The position when this instance was created, in milliseconds. */
private val initPositionMs: Long,
/** The time this instance was created, as a unix epoch timestamp. */
private val creationTime: Long
) {
/**
* Calculate the "real" playback position this instance contains, in milliseconds.
*
* @return If paused, the original position will be returned. Otherwise, it will be the
* original position plus the time elapsed since this state was created.
*/
fun calculateElapsedPositionMs() =
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 instance into a [PlaybackStateCompat].
*
* @param builder The [PlaybackStateCompat.Builder] to mutate.
* @return The same [PlaybackStateCompat.Builder] for easy chaining.
*/
fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
builder.setState(
// State represents the user's preference, not the actual player state.
// Doing this produces a better experience in the media control UI.
if (isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
},
initPositionMs,
if (isAdvancing) {
1f
} else {
// Not advancing, so don't move the position.
0f
},
creationTime)
// Equality ignores the creation time to prevent functionally identical states
// from being non-equal.
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.
*
* @param isPlaying Whether the player is actively playing audio or set to play audio in
* the future.
* @param isAdvancing Whether the player is actively playing audio in this moment.
* @param positionMs The current position of the player.
*/
fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
State(
isPlaying,
// Minor sanity check: Make sure that advancing can't occur if already paused.
isPlaying && isAdvancing,
positionMs,
SystemClock.elapsedRealtime())
}
}
}

View file

@ -0,0 +1,217 @@
/*
* Copyright (c) 2024 Auxio Project
* PlaybackStateHolder.kt is part of Auxio.
*
* 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 android.os.SystemClock
import android.support.v4.media.session.PlaybackStateCompat
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
interface PlaybackStateHolder {
val currentSong: Song?
val repeatMode: RepeatMode
val progression: Progression
val audioSessionId: Int
val parent: MusicParent?
val isShuffled: Boolean
fun resolveQueue(): Queue
fun newPlayback(
queue: List<Song>,
start: Song?,
parent: MusicParent?,
shuffled: Boolean,
play: Boolean
)
fun playing(playing: Boolean)
fun seekTo(positionMs: Long)
fun repeatMode(repeatMode: RepeatMode)
fun next()
fun prev()
fun goto(index: Int)
fun playNext(songs: List<Song>)
fun addToQueue(songs: List<Song>)
fun move(from: Int, to: Int)
fun remove(at: Int)
fun reorder(shuffled: Boolean)
fun handleDeferred(action: DeferredPlayback): Boolean
}
/**
* Represents the possible changes that can occur during certain queue mutation events.
*
* @param type The [Type] of the change to the internal queue state.
* @param instructions The update done to the resolved queue list.
*/
data class QueueChange(val type: Type, val instructions: UpdateInstructions) {
enum class Type {
/** Only the mapping has changed. */
MAPPING,
/** The mapping has changed, and the index also changed to align with it. */
INDEX,
/**
* The current song has changed, possibly alongside the mapping and index depending on the
* context.
*/
SONG
}
}
/** Possible long-running background tasks handled by the background playback task. */
sealed interface DeferredPlayback {
/** Restore the previously saved playback state. */
data object RestoreState : DeferredPlayback
/**
* Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut.
*/
data object ShuffleAll : DeferredPlayback
/**
* Start playing an audio file at the given [Uri].
*
* @param uri The [Uri] of the audio file to start playing.
*/
data class Open(val uri: Uri) : DeferredPlayback
}
data class Queue(val index: Int, val queue: List<Song>) {
companion object {
fun nil() = Queue(-1, emptyList())
}
}
data class SavedQueue(
val heap: List<Song?>,
val orderedMapping: List<Int>,
val shuffledMapping: List<Int>,
val index: Int,
val songUid: Music.UID,
)
/** A representation of the current state of audio playback. Use [from] to create an instance. */
class Progression
private constructor(
/** Whether the player is actively playing audio or set to play audio in the future. */
val isPlaying: Boolean,
/** Whether the player is actively playing audio in this moment. */
private val isAdvancing: Boolean,
/** The position when this instance was created, in milliseconds. */
private val initPositionMs: Long,
/** The time this instance was created, as a unix epoch timestamp. */
private val creationTime: Long
) {
/**
* Calculate the "real" playback position this instance contains, in milliseconds.
*
* @return If paused, the original position will be returned. Otherwise, it will be the original
* position plus the time elapsed since this state was created.
*/
fun calculateElapsedPositionMs() =
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 instance into a [PlaybackStateCompat].
*
* @param builder The [PlaybackStateCompat.Builder] to mutate.
* @return The same [PlaybackStateCompat.Builder] for easy chaining.
*/
fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
builder.setState(
// State represents the user's preference, not the actual player state.
// Doing this produces a better experience in the media control UI.
if (isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
},
initPositionMs,
if (isAdvancing) {
1f
} else {
// Not advancing, so don't move the position.
0f
},
creationTime)
// Equality ignores the creation time to prevent functionally identical states
// from being non-equal.
override fun equals(other: Any?) =
other is Progression &&
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.
*
* @param isPlaying Whether the player is actively playing audio or set to play audio in the
* future.
* @param isAdvancing Whether the player is actively playing audio in this moment.
* @param positionMs The current position of the player.
*/
fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
Progression(
isPlaying,
// Minor sanity check: Make sure that advancing can't occur if already paused.
isPlaying && isAdvancing,
positionMs,
SystemClock.elapsedRealtime())
fun nil() = Progression(false, false, 0, SystemClock.elapsedRealtime())
}
}

View file

@ -22,8 +22,6 @@ import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
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.queue.MutableQueue
import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -40,19 +38,26 @@ import org.oxycblt.auxio.util.logW
* PlaybackService. * PlaybackService.
* *
* Internal consumers should usually use [Listener], however the component that manages the player * Internal consumers should usually use [Listener], however the component that manages the player
* itself should instead use [InternalPlayer]. * itself should instead use [PlaybackStateHolder].
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface PlaybackStateManager { interface PlaybackStateManager {
/** The current [Queue]. */ /** The current [Progression] state. */
val queue: Queue val progression: Progression
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
val currentSong: Song?
val repeatMode: RepeatMode
val audioSessionId: Int
val parent: MusicParent? val parent: MusicParent?
/** The current [InternalPlayer] state. */
val playerState: InternalPlayer.State val isShuffled: Boolean
/** The current [RepeatMode] */
var repeatMode: RepeatMode fun resolveQueue(): Queue
/** The audio session ID of the internal player. Null if no internal player exists. */ /** The audio session ID of the internal player. Null if no internal player exists. */
val currentAudioSessionId: Int? val currentAudioSessionId: Int?
@ -75,23 +80,25 @@ interface PlaybackStateManager {
fun removeListener(listener: Listener) fun removeListener(listener: Listener)
/** /**
* Register an [InternalPlayer] for this instance. This instance will handle translating the * Register an [PlaybackStateHolder] for this instance. This instance will handle translating
* current playback state into audio playback. There can be only one [InternalPlayer] at a time. * the current playback state into audio playback. There can be only one [PlaybackStateHolder]
* Will invoke [InternalPlayer] methods to initialize the instance with the current state. * at a time. Will invoke [PlaybackStateHolder] methods to initialize the instance with the
* current state.
* *
* @param internalPlayer The [InternalPlayer] to register. Will do nothing if already * @param stateHolder The [PlaybackStateHolder] to register. Will do nothing if already
* registered. * registered.
*/ */
fun registerInternalPlayer(internalPlayer: InternalPlayer) fun registerStateHolder(stateHolder: PlaybackStateHolder)
/** /**
* Unregister the [InternalPlayer] from this instance, prevent it from receiving any further * Unregister the [PlaybackStateHolder] from this instance, prevent it from receiving any
* commands. * further commands.
* *
* @param internalPlayer The [InternalPlayer] to unregister. Must be the current * @param stateHolder The [PlaybackStateHolder] to unregister. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder]
* implementation.
*/ */
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) fun unregisterStateHolder(stateHolder: PlaybackStateHolder)
/** /**
* Start new playback. * Start new playback.
@ -173,36 +180,33 @@ interface PlaybackStateManager {
*/ */
fun reorder(shuffled: Boolean) fun reorder(shuffled: Boolean)
/** fun dispatchEvent(stateHolder: PlaybackStateHolder, vararg events: PlaybackEvent)
* Synchronize the state of this instance with the current [InternalPlayer].
*
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/
fun synchronizeState(internalPlayer: InternalPlayer)
/** /**
* Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually. * Start a [DeferredPlayback] for the current [PlaybackStateHolder] to handle eventually.
* *
* @param action The [InternalPlayer.Action] to perform. * @param action The [DeferredPlayback] to perform.
*/ */
fun startAction(action: InternalPlayer.Action) fun playDeferred(action: DeferredPlayback)
/** /**
* Request that the pending [InternalPlayer.Action] (if any) be passed to the given * Request that the pending [PlaybackStateHolder.Action] (if any) be passed to the given
* [InternalPlayer]. * [PlaybackStateHolder].
* *
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current * @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder]
* implementation.
*/ */
fun requestAction(internalPlayer: InternalPlayer) fun requestAction(stateHolder: PlaybackStateHolder)
/** /**
* Update whether playback is ongoing or not. * Update whether playback is ongoing or not.
* *
* @param isPlaying Whether playback is ongoing or not. * @param isPlaying Whether playback is ongoing or not.
*/ */
fun setPlaying(isPlaying: Boolean) fun playing(isPlaying: Boolean)
fun repeatMode(repeatMode: RepeatMode)
/** /**
* Seek to the given position in the currently playing [Song]. * Seek to the given position in the currently playing [Song].
@ -236,103 +240,78 @@ interface PlaybackStateManager {
* [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener]. * [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener].
*/ */
interface Listener { interface Listener {
/** fun onPlaybackEvent(event: PlaybackEvent)
* Called when the position of the currently playing item has changed, changing the current
* [Song], but no other queue attribute has changed.
*
* @param queue The new [Queue].
*/
fun onIndexMoved(queue: Queue) {}
/**
* Called when the [Queue] changed in a manner outlined by the given [Queue.Change].
*
* @param queue The new [Queue].
* @param change The type of [Queue.Change] that occurred.
*/
fun onQueueChanged(queue: Queue, change: Queue.Change) {}
/**
* Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but
* the currently playing [Song] has not.
*
* @param queue The new [Queue].
*/
fun onQueueReordered(queue: Queue) {}
/**
* Called when a new playback configuration was created.
*
* @param queue The new [Queue].
* @param parent The new [MusicParent] being played from, or null if playing from all songs.
*/
fun onNewPlayback(queue: Queue, parent: MusicParent?) {}
/**
* Called when the state of the [InternalPlayer] changes.
*
* @param state The new state of the [InternalPlayer].
*/
fun onStateChanged(state: InternalPlayer.State) {}
/**
* Called when the [RepeatMode] changes.
*
* @param repeatMode The new [RepeatMode].
*/
fun onRepeatChanged(repeatMode: RepeatMode) {}
} }
/** /**
* A condensed representation of the playback state that can be persisted. * A condensed representation of the playback state that can be persisted.
* *
* @param parent The [MusicParent] item currently being played from. * @param parent The [MusicParent] item currently being played from.
* @param queueState The [Queue.SavedState] * @param queueState The [SavedQueue]
* @param positionMs The current position in the currently played song, in ms * @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode]. * @param repeatMode The current [RepeatMode].
*/ */
data class SavedState( data class SavedState(
val parent: MusicParent?, val parent: MusicParent?,
val queueState: Queue.SavedState, val queueState: SavedQueue,
val positionMs: Long, val positionMs: Long,
val repeatMode: RepeatMode, val repeatMode: RepeatMode,
) )
} }
sealed interface PlaybackEvent {
class IndexMoved(val currentSong: Song?, val index: Int) : PlaybackEvent
class QueueChanged(val currentSong: Song?, val queue: Queue, val change: QueueChange) :
PlaybackEvent
class QueueReordered(val queue: Queue, val isShuffled: Boolean) : PlaybackEvent
class NewPlayback(
val currentSong: Song,
val queue: Queue,
val parent: MusicParent?,
val isShuffled: Boolean,
) : PlaybackEvent
class ProgressionChanged(val progression: Progression) : PlaybackEvent
class RepeatModeChanged(val repeatMode: RepeatMode) : PlaybackEvent
}
class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
private val listeners = mutableListOf<PlaybackStateManager.Listener>() private val listeners = mutableListOf<PlaybackStateManager.Listener>()
@Volatile private var internalPlayer: InternalPlayer? = null @Volatile private var stateHolder: PlaybackStateHolder? = null
@Volatile private var pendingAction: InternalPlayer.Action? = null @Volatile private var pendingDeferredPlayback: DeferredPlayback? = null
@Volatile private var isInitialized = false @Volatile private var isInitialized = false
override val queue = MutableQueue() /** The current [Progression] state. */
@Volatile override val progression: Progression
override var parent: MusicParent? = null get() = stateHolder?.progression ?: Progression.nil()
private set
@Volatile override val currentSong: Song?
override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) get() = stateHolder?.currentSong
private set
@Volatile override val repeatMode: RepeatMode
override var repeatMode = RepeatMode.NONE get() = stateHolder?.repeatMode ?: RepeatMode.NONE
set(value) {
field = value override val audioSessionId: Int
notifyRepeatModeChanged() get() = stateHolder?.audioSessionId ?: -1
}
override val parent: MusicParent?
get() = stateHolder?.parent
override val isShuffled: Boolean
get() = stateHolder?.isShuffled ?: false
override fun resolveQueue() = stateHolder?.resolveQueue() ?: Queue.nil()
override val currentAudioSessionId: Int? override val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId get() = stateHolder?.audioSessionId
@Synchronized @Synchronized
override fun addListener(listener: PlaybackStateManager.Listener) { override fun addListener(listener: PlaybackStateManager.Listener) {
logD("Adding $listener to listeners") logD("Adding $listener to listeners")
if (isInitialized) {
listener.onNewPlayback(queue, parent)
listener.onRepeatChanged(repeatMode)
listener.onStateChanged(playerState)
}
listeners.add(listener) listeners.add(listener)
} }
@ -345,286 +324,211 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
} }
@Synchronized @Synchronized
override fun registerInternalPlayer(internalPlayer: InternalPlayer) { override fun registerStateHolder(stateHolder: PlaybackStateHolder) {
if (this.internalPlayer != null) { if (this.stateHolder != null) {
logW("Internal player is already registered") logW("Internal player is already registered")
return return
} }
logD("Registering internal player $internalPlayer") this.stateHolder = stateHolder
if (isInitialized) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
// See if there's any action that has been queued.
requestAction(internalPlayer)
// Once initialized, try to synchronize with the player state it has created.
synchronizeState(internalPlayer)
}
this.internalPlayer = internalPlayer
} }
@Synchronized @Synchronized
override fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { override fun unregisterStateHolder(stateHolder: PlaybackStateHolder) {
if (this.internalPlayer !== internalPlayer) { if (this.stateHolder !== stateHolder) {
logW("Given internal player did not match current internal player") logW("Given internal player did not match current internal player")
return return
} }
logD("Unregistering internal player $internalPlayer") logD("Unregistering internal player $stateHolder")
this.internalPlayer = null this.stateHolder = null
} }
// --- PLAYING FUNCTIONS --- // --- PLAYING FUNCTIONS ---
@Synchronized @Synchronized
override fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) { override fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
val internalPlayer = internalPlayer ?: return val stateHolder = stateHolder ?: return
logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]") logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]")
// Set up parent and queue
this.parent = parent
this.queue.start(song, queue, shuffled)
// Notify components of changes
notifyNewPlayback()
internalPlayer.loadSong(this.queue.currentSong, true)
// Played something, so we are initialized now // Played something, so we are initialized now
isInitialized = true isInitialized = true
stateHolder.newPlayback(queue, song, parent, shuffled, true)
} }
// --- QUEUE FUNCTIONS --- // --- QUEUE FUNCTIONS ---
@Synchronized @Synchronized
override fun next() { override fun next() {
val internalPlayer = internalPlayer ?: return val stateHolder = stateHolder ?: return
var play = true logD("Going to next song")
if (!queue.goto(queue.index + 1)) { stateHolder.next()
queue.goto(0)
play = repeatMode == RepeatMode.ALL
logD("At end of queue, wrapping around to position 0 [play=$play]")
} else {
logD("Moving to next song")
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, play)
} }
@Synchronized @Synchronized
override fun prev() { override fun prev() {
val internalPlayer = internalPlayer ?: return val stateHolder = stateHolder ?: return
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] logD("Going to previous song")
if (internalPlayer.shouldRewindWithPrev) { stateHolder.prev()
logD("Rewinding current song")
rewind()
setPlaying(true)
} else {
logD("Moving to previous song")
if (!queue.goto(queue.index - 1)) {
queue.goto(0)
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
} }
@Synchronized @Synchronized
override fun goto(index: Int) { override fun goto(index: Int) {
val internalPlayer = internalPlayer ?: return val stateHolder = stateHolder ?: return
if (queue.goto(index)) { logD("Going to index $index")
logD("Moving to $index") stateHolder.goto(index)
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
} else {
logW("$index was not in bounds, could not move to it")
}
} }
@Synchronized @Synchronized
override fun playNext(songs: List<Song>) { override fun playNext(songs: List<Song>) {
if (queue.currentSong == null) { if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback") logD("Nothing playing, short-circuiting to new playback")
play(songs[0], null, songs, false) play(songs[0], null, songs, false)
} else { } else {
val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to start of queue") logD("Adding ${songs.size} songs to start of queue")
notifyQueueChanged(queue.addToTop(songs)) stateHolder.playNext(songs)
} }
} }
@Synchronized @Synchronized
override fun addToQueue(songs: List<Song>) { override fun addToQueue(songs: List<Song>) {
if (queue.currentSong == null) { if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback") logD("Nothing playing, short-circuiting to new playback")
play(songs[0], null, songs, false) play(songs[0], null, songs, false)
} else { } else {
val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to end of queue") logD("Adding ${songs.size} songs to end of queue")
notifyQueueChanged(queue.addToBottom(songs)) stateHolder.addToQueue(songs)
} }
} }
@Synchronized @Synchronized
override fun moveQueueItem(src: Int, dst: Int) { override fun moveQueueItem(src: Int, dst: Int) {
val stateHolder = stateHolder ?: return
logD("Moving item $src to position $dst") logD("Moving item $src to position $dst")
notifyQueueChanged(queue.move(src, dst)) stateHolder.move(src, dst)
} }
@Synchronized @Synchronized
override fun removeQueueItem(at: Int) { override fun removeQueueItem(at: Int) {
val internalPlayer = internalPlayer ?: return val stateHolder = stateHolder ?: return
logD("Removing item at $at") logD("Removing item at $at")
val change = queue.remove(at) stateHolder.remove(at)
if (change.type == Queue.Change.Type.SONG) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
}
notifyQueueChanged(change)
} }
@Synchronized @Synchronized
override fun reorder(shuffled: Boolean) { override fun reorder(shuffled: Boolean) {
val stateHolder = stateHolder ?: return
logD("Reordering queue [shuffled=$shuffled]") logD("Reordering queue [shuffled=$shuffled]")
queue.reorder(shuffled) stateHolder.reorder(shuffled)
notifyQueueReordered()
} }
// --- INTERNAL PLAYER FUNCTIONS --- // --- INTERNAL PLAYER FUNCTIONS ---
@Synchronized @Synchronized
override fun synchronizeState(internalPlayer: InternalPlayer) { override fun playDeferred(action: DeferredPlayback) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { val stateHolder = stateHolder
logW("Given internal player did not match current internal player") if (stateHolder == null || !stateHolder.handleDeferred(action)) {
return
}
val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0)
if (newState != playerState) {
playerState = newState
notifyStateChanged()
}
}
@Synchronized
override fun startAction(action: InternalPlayer.Action) {
val internalPlayer = internalPlayer
if (internalPlayer == null || !internalPlayer.performAction(action)) {
logD("Internal player not present or did not consume action, waiting") logD("Internal player not present or did not consume action, waiting")
pendingAction = action pendingDeferredPlayback = action
} }
} }
@Synchronized @Synchronized
override fun requestAction(internalPlayer: InternalPlayer) { override fun requestAction(stateHolder: PlaybackStateHolder) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
logW("Given internal player did not match current internal player") logW("Given internal player did not match current internal player")
return return
} }
if (pendingAction?.let(internalPlayer::performAction) == true) { if (pendingDeferredPlayback?.let(stateHolder::handleDeferred) == true) {
logD("Pending action consumed") logD("Pending action consumed")
pendingAction = null pendingDeferredPlayback = null
} }
} }
@Synchronized @Synchronized
override fun setPlaying(isPlaying: Boolean) { override fun playing(isPlaying: Boolean) {
val stateHolder = stateHolder ?: return
logD("Updating playing state to $isPlaying") logD("Updating playing state to $isPlaying")
internalPlayer?.setPlaying(isPlaying) stateHolder.playing(isPlaying)
}
@Synchronized
override fun repeatMode(repeatMode: RepeatMode) {
val stateHolder = stateHolder ?: return
logD("Updating repeat mode to $repeatMode")
stateHolder.repeatMode(repeatMode)
} }
@Synchronized @Synchronized
override fun seekTo(positionMs: Long) { override fun seekTo(positionMs: Long) {
val stateHolder = stateHolder ?: return
logD("Seeking to ${positionMs}ms") logD("Seeking to ${positionMs}ms")
internalPlayer?.seekTo(positionMs) stateHolder.seekTo(positionMs)
}
@Synchronized
override fun dispatchEvent(stateHolder: PlaybackStateHolder, vararg events: PlaybackEvent) {
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
logW("Given internal player did not match current internal player")
return
}
events.forEach { event ->
logD("Dispatching event $event")
listeners.forEach { it.onPlaybackEvent(event) }
}
} }
// --- PERSISTENCE FUNCTIONS --- // --- PERSISTENCE FUNCTIONS ---
@Synchronized @Synchronized override fun toSavedState() = null
override fun toSavedState() = // queue.toSavedState()?.let {
queue.toSavedState()?.let { // PlaybackStateManager.SavedState(
PlaybackStateManager.SavedState( // parent = parent,
parent = parent, // queueState = it,
queueState = it, // positionMs = progression.calculateElapsedPositionMs(),
positionMs = playerState.calculateElapsedPositionMs(), // repeatMode = repeatMode)
repeatMode = repeatMode) // }
}
@Synchronized @Synchronized
override fun applySavedState( override fun applySavedState(
savedState: PlaybackStateManager.SavedState, savedState: PlaybackStateManager.SavedState,
destructive: Boolean destructive: Boolean
) { ) {
if (isInitialized && !destructive) { // if (isInitialized && !destructive) {
logW("Already initialized, cannot apply saved state") // logW("Already initialized, cannot apply saved state")
return // return
} // }
val internalPlayer = internalPlayer ?: return // val stateHolder = stateHolder ?: return
logD("Applying state $savedState") // logD("Applying state $savedState")
//
val lastSong = queue.currentSong // val lastSong = queue.currentSong
parent = savedState.parent // parent = savedState.parent
queue.applySavedState(savedState.queueState) // queue.applySavedState(savedState.queueState)
repeatMode = savedState.repeatMode // repeatMode = savedState.repeatMode
notifyNewPlayback() // notifyNewPlayback()
//
// Check if we need to reload the player with a new music file, or if we can just leave // // Check if we need to reload the player with a new music file, or if we can just
// it be. Specifically done so we don't pause on music updates that don't really change // leave
// what's playing (ex. playlist editing) // // it be. Specifically done so we don't pause on music updates that don't really
if (lastSong != queue.currentSong) { // change
logD("Song changed, must reload player") // // what's playing (ex. playlist editing)
// Continuing playback while also possibly doing drastic state updates is // if (lastSong != queue.currentSong) {
// a bad idea, so pause. // logD("Song changed, must reload player")
internalPlayer.loadSong(queue.currentSong, false) // // Continuing playback while also possibly doing drastic state updates is
if (queue.currentSong != null) { // // a bad idea, so pause.
logD("Seeking to saved position ${savedState.positionMs}ms") // stateHolder.loadSong(queue.currentSong, false)
// Internal player may have reloaded the media item, re-seek to the previous // if (queue.currentSong != null) {
// position // logD("Seeking to saved position ${savedState.positionMs}ms")
seekTo(savedState.positionMs) // // Internal player may have reloaded the media item, re-seek to the
} // previous
} // // position
// seekTo(savedState.positionMs)
// }
// }
isInitialized = true isInitialized = true
} }
// --- CALLBACKS ---
private fun notifyIndexMoved() {
logD("Dispatching index change")
for (callback in listeners) {
callback.onIndexMoved(queue)
}
}
private fun notifyQueueChanged(change: Queue.Change) {
logD("Dispatching queue change $change")
for (callback in listeners) {
callback.onQueueChanged(queue, change)
}
}
private fun notifyQueueReordered() {
logD("Dispatching queue reordering")
for (callback in listeners) {
callback.onQueueReordered(queue)
}
}
private fun notifyNewPlayback() {
logD("Dispatching new playback")
for (callback in listeners) {
callback.onNewPlayback(queue, parent)
}
}
private fun notifyStateChanged() {
logD("Dispatching player state change")
for (callback in listeners) {
callback.onStateChanged(playerState)
}
}
private fun notifyRepeatModeChanged() {
logD("Dispatching repeat mode change")
for (callback in listeners) {
callback.onRepeatChanged(repeatMode)
}
}
} }

View file

@ -29,18 +29,10 @@ import java.util.*
* *
* @author media3 team, Alexander Capehart (OxygenCobalt) * @author media3 team, Alexander Capehart (OxygenCobalt)
*/ */
class BetterShuffleOrder class BetterShuffleOrder private constructor(private val shuffled: IntArray) : ShuffleOrder {
private constructor(private val shuffled: IntArray, private val random: Random) : ShuffleOrder {
private val indexInShuffled: IntArray = IntArray(shuffled.size) private val indexInShuffled: IntArray = IntArray(shuffled.size)
/** constructor(length: Int, startIndex: Int) : this(createShuffledList(length, startIndex))
* Creates an instance with a specified length.
*
* @param length The length of the shuffle order.
*/
constructor(length: Int) : this(length, Random())
constructor(length: Int, random: Random) : this(createShuffledList(length, random), random)
init { init {
for (i in shuffled.indices) { for (i in shuffled.indices) {
@ -88,7 +80,7 @@ private constructor(private val shuffled: IntArray, private val random: Random)
for (i in 0 until insertionCount) { for (i in 0 until insertionCount) {
newShuffled[pivot + i + 1] = insertionIndex + i + 1 newShuffled[pivot + i + 1] = insertionIndex + i + 1
} }
return BetterShuffleOrder(newShuffled, Random(random.nextLong())) return BetterShuffleOrder(newShuffled)
} }
override fun cloneAndRemove(indexFrom: Int, indexToExclusive: Int): ShuffleOrder { override fun cloneAndRemove(indexFrom: Int, indexToExclusive: Int): ShuffleOrder {
@ -104,21 +96,27 @@ private constructor(private val shuffled: IntArray, private val random: Random)
else shuffled[i] else shuffled[i]
} }
} }
return BetterShuffleOrder(newShuffled, Random(random.nextLong())) return BetterShuffleOrder(newShuffled)
} }
override fun cloneAndClear(): ShuffleOrder { override fun cloneAndClear(): ShuffleOrder {
return BetterShuffleOrder(0, Random(random.nextLong())) return BetterShuffleOrder(0, -1)
} }
companion object { companion object {
private fun createShuffledList(length: Int, random: Random): IntArray { private fun createShuffledList(length: Int, startIndex: Int): IntArray {
val shuffled = IntArray(length) val shuffled = IntArray(length)
for (i in 0 until length) { for (i in 0 until length) {
val swapIndex = random.nextInt(i + 1) val swapIndex = (0..i).random()
shuffled[i] = shuffled[swapIndex] shuffled[i] = shuffled[swapIndex]
shuffled[swapIndex] = i shuffled[swapIndex] = i
} }
if (startIndex != -1) {
val startIndexInShuffled = shuffled.indexOf(startIndex)
val temp = shuffled[0]
shuffled[0] = shuffled[startIndexInShuffled]
shuffled[startIndexInShuffled] = temp
}
return shuffled return shuffled
} }
} }

View file

@ -0,0 +1,177 @@
/*
* Copyright (c) 2024 Auxio Project
* ExoPlayerExt.kt is part of Auxio.
*
* 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.system
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD
val ExoPlayer.song
get() = currentMediaItem?.song
fun ExoPlayer.orderedQueue(queue: Collection<Song>, start: Song?) {
clearMediaItems()
shuffleModeEnabled = false
setShuffleOrder(DefaultShuffleOrder(mediaItemCount))
setMediaItems(queue.map { it.toMediaItem() })
val startIndex = queue.indexOf(start)
if (startIndex != -1) {
seekTo(startIndex, C.TIME_UNSET)
} else {
throw IllegalArgumentException("Start song not in queue")
}
}
fun ExoPlayer.shuffledQueue(queue: Collection<Song>, start: Song?) {
// A fun thing about ShuffleOrder is that ExoPlayer will use cloneAndInsert to both add
// MediaItems AND repopulate MediaItems (?!?!?!?!). As a result, we have to use the default
// shuffle order and it's stupid cloneAndInsert implementation to add the songs, and then
// switch back to our implementation that actually works in normal use.
setShuffleOrder(DefaultShuffleOrder(mediaItemCount))
setMediaItems(queue.map { it.toMediaItem() })
shuffleModeEnabled = true
val startIndex =
if (start != null) {
queue.indexOf(start).also { check(it != -1) { "Start song not in queue" } }
} else {
-1
}
setShuffleOrder(BetterShuffleOrder(queue.size, startIndex))
seekTo(currentTimeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET)
}
val ExoPlayer.currentIndex: Int
get() {
val queue = unscrambleQueue { index -> index }
if (queue.isEmpty()) {
return C.INDEX_UNSET
}
return queue.indexOf(currentMediaItemIndex)
}
val ExoPlayer.repeat: RepeatMode
get() =
when (repeatMode) {
Player.REPEAT_MODE_OFF -> RepeatMode.NONE
Player.REPEAT_MODE_ONE -> RepeatMode.TRACK
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
else -> throw IllegalStateException("Unknown repeat mode: $repeatMode")
}
fun ExoPlayer.reorder(shuffled: Boolean) {
logD("Reordering queue to $shuffled")
if (shuffled) {
// Have to manually refresh the shuffle seed.
setShuffleOrder(BetterShuffleOrder(mediaItemCount, currentMediaItemIndex))
}
shuffleModeEnabled = shuffled
}
fun ExoPlayer.playNext(songs: List<Song>) {
addMediaItems(nextMediaItemIndex, songs.map { it.toMediaItem() })
}
fun ExoPlayer.addToQueue(songs: List<Song>) {
addMediaItems(songs.map { it.toMediaItem() })
}
fun ExoPlayer.goto(index: Int) {
val queue = unscrambleQueue { index -> index }
if (queue.isEmpty()) {
return
}
val trueIndex = queue[index]
seekTo(trueIndex, C.TIME_UNSET)
}
fun ExoPlayer.move(from: Int, to: Int) {
val queue = unscrambleQueue { index -> index }
if (queue.isEmpty()) {
return
}
val trueFrom = queue[from]
val trueTo = queue[to]
moveMediaItem(trueFrom, trueTo)
}
fun ExoPlayer.remove(at: Int) {
val queue = unscrambleQueue { index -> index }
if (queue.isEmpty()) {
return
}
val trueIndex = queue[at]
removeMediaItem(trueIndex)
}
fun ExoPlayer.resolveQueue(): List<Song> {
return unscrambleQueue { index -> getMediaItemAt(index).song }
}
inline fun <T> ExoPlayer.unscrambleQueue(mapper: (Int) -> T): List<T> {
val timeline = currentTimeline
if (timeline.isEmpty()) {
return emptyList()
}
val queue = mutableListOf<T>()
// Add the active queue item.
val currentMediaItemIndex = currentMediaItemIndex
queue.add(mapper(currentMediaItemIndex))
// Fill queue alternating with next and/or previous queue items.
var firstMediaItemIndex = currentMediaItemIndex
var lastMediaItemIndex = currentMediaItemIndex
val shuffleModeEnabled = shuffleModeEnabled
while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) {
// Begin with next to have a longer tail than head if an even sized queue needs to be
// trimmed.
if (lastMediaItemIndex != C.INDEX_UNSET) {
lastMediaItemIndex =
timeline.getNextWindowIndex(
lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
if (lastMediaItemIndex != C.INDEX_UNSET) {
queue.add(mapper(lastMediaItemIndex))
}
}
if (firstMediaItemIndex != C.INDEX_UNSET) {
firstMediaItemIndex =
timeline.getPreviousWindowIndex(
firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
if (firstMediaItemIndex != C.INDEX_UNSET) {
queue.add(0, mapper(firstMediaItemIndex))
}
}
}
return queue
}
fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build()
private val MediaItem.song: Song
get() = requireNotNull(localConfiguration).tag as Song

View file

@ -39,7 +39,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
// TODO: Figure this out // TODO: Figure this out
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (playbackManager.queue.currentSong != null) { if (playbackManager.currentSong != null) {
// We have a song, so we can assume that the service will start a foreground state. // We have a song, so we can assume that the service will start a foreground state.
// At least, I hope. Again, *this is why we don't do this*. I cannot describe how // At least, I hope. Again, *this is why we don't do this*. I cannot describe how
// stupid this is with the state of foreground services on modern android. One // stupid this is with the state of foreground services on modern android. One

View file

@ -38,9 +38,10 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackEvent
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Queue
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -117,65 +118,65 @@ constructor(
// --- PLAYBACKSTATEMANAGER OVERRIDES --- // --- PLAYBACKSTATEMANAGER OVERRIDES ---
override fun onIndexMoved(queue: Queue) { override fun onPlaybackEvent(event: PlaybackEvent) {
updateMediaMetadata(queue.currentSong, playbackManager.parent) when (event) {
invalidateSessionState() is PlaybackEvent.IndexMoved -> {
} updateMediaMetadata(event.currentSong, playbackManager.parent)
invalidateSessionState()
}
is PlaybackEvent.QueueChanged -> {
updateQueue(event.queue)
when (event.change.type) {
// Nothing special to do with mapping changes.
QueueChange.Type.MAPPING -> {}
// Index changed, ensure playback state's index changes.
QueueChange.Type.INDEX -> invalidateSessionState()
// Song changed, ensure metadata changes.
QueueChange.Type.SONG ->
updateMediaMetadata(event.currentSong, playbackManager.parent)
}
}
is PlaybackEvent.QueueReordered -> {
updateQueue(event.queue)
invalidateSessionState()
mediaSession.setShuffleMode(
if (event.isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
is PlaybackEvent.NewPlayback -> {
updateMediaMetadata(event.currentSong, event.parent)
updateQueue(event.queue)
invalidateSessionState()
}
is PlaybackEvent.ProgressionChanged -> {
invalidateSessionState()
notification.updatePlaying(playbackManager.progression.isPlaying)
if (!bitmapProvider.isBusy) {
listener?.onPostNotification(notification)
}
}
is PlaybackEvent.RepeatModeChanged -> {
mediaSession.setRepeatMode(
when (event.repeatMode) {
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
})
override fun onQueueChanged(queue: Queue, change: Queue.Change) { invalidateSecondaryAction()
updateQueue(queue) }
when (change.type) {
// Nothing special to do with mapping changes.
Queue.Change.Type.MAPPING -> {}
// Index changed, ensure playback state's index changes.
Queue.Change.Type.INDEX -> invalidateSessionState()
// Song changed, ensure metadata changes.
Queue.Change.Type.SONG -> updateMediaMetadata(queue.currentSong, playbackManager.parent)
} }
} }
override fun onQueueReordered(queue: Queue) {
updateQueue(queue)
invalidateSessionState()
mediaSession.setShuffleMode(
if (queue.isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
updateMediaMetadata(queue.currentSong, parent)
updateQueue(queue)
invalidateSessionState()
}
override fun onStateChanged(state: InternalPlayer.State) {
invalidateSessionState()
notification.updatePlaying(playbackManager.playerState.isPlaying)
if (!bitmapProvider.isBusy) {
listener?.onPostNotification(notification)
}
}
override fun onRepeatChanged(repeatMode: RepeatMode) {
mediaSession.setRepeatMode(
when (repeatMode) {
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
})
invalidateSecondaryAction()
}
// --- SETTINGS OVERRIDES --- // --- SETTINGS OVERRIDES ---
override fun onImageSettingsChanged() { override fun onImageSettingsChanged() {
// Need to reload the metadata cover. // Need to reload the metadata cover.
updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
} }
override fun onNotificationActionChanged() { override fun onNotificationActionChanged() {
@ -211,11 +212,11 @@ constructor(
} }
override fun onPlay() { override fun onPlay() {
playbackManager.setPlaying(true) playbackManager.playing(true)
} }
override fun onPause() { override fun onPause() {
playbackManager.setPlaying(false) playbackManager.playing(false)
} }
override fun onSkipToNext() { override fun onSkipToNext() {
@ -236,17 +237,17 @@ constructor(
override fun onRewind() { override fun onRewind() {
playbackManager.rewind() playbackManager.rewind()
playbackManager.setPlaying(true) playbackManager.playing(true)
} }
override fun onSetRepeatMode(repeatMode: Int) { override fun onSetRepeatMode(repeatMode: Int) {
playbackManager.repeatMode = playbackManager.repeatMode(
when (repeatMode) { when (repeatMode) {
PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL
PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL
PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK
else -> RepeatMode.NONE else -> RepeatMode.NONE
} })
} }
override fun onSetShuffleMode(shuffleMode: Int) { override fun onSetShuffleMode(shuffleMode: Int) {
@ -356,7 +357,7 @@ constructor(
*/ */
private fun updateQueue(queue: Queue) { private fun updateQueue(queue: Queue) {
val queueItems = val queueItems =
queue.resolve().mapIndexed { i, song -> queue.queue.mapIndexed { i, song ->
val description = val description =
MediaDescriptionCompat.Builder() MediaDescriptionCompat.Builder()
// Media ID should not be the item index but rather the UID, // Media ID should not be the item index but rather the UID,
@ -381,13 +382,15 @@ constructor(
private fun invalidateSessionState() { private fun invalidateSessionState() {
logD("Updating media session playback state") logD("Updating media session playback state")
val queue = playbackManager.resolveQueue()
val state = val state =
// InternalPlayer.State handles position/state information. // InternalPlayer.State handles position/state information.
playbackManager.playerState playbackManager.progression
.intoPlaybackState(PlaybackStateCompat.Builder()) .intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS) .setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here. // Active queue ID corresponds to the indices we populated prior, use them here.
.setActiveQueueItemId(playbackManager.queue.index.toLong()) .setActiveQueueItemId(queue.index.toLong())
// Android 13+ relies on custom actions in the notification. // Android 13+ relies on custom actions in the notification.
@ -399,7 +402,7 @@ constructor(
PlaybackStateCompat.CustomAction.Builder( PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_INVERT_SHUFFLE, PlaybackService.ACTION_INVERT_SHUFFLE,
context.getString(R.string.desc_shuffle), context.getString(R.string.desc_shuffle),
if (playbackManager.queue.isShuffled) { if (playbackManager.isShuffled) {
R.drawable.ic_shuffle_on_24 R.drawable.ic_shuffle_on_24
} else { } else {
R.drawable.ic_shuffle_off_24 R.drawable.ic_shuffle_off_24
@ -435,7 +438,7 @@ constructor(
when (playbackSettings.notificationAction) { when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE -> { ActionMode.SHUFFLE -> {
logD("Using shuffle notification action") logD("Using shuffle notification action")
notification.updateShuffled(playbackManager.queue.isShuffled) notification.updateShuffled(playbackManager.isShuffled)
} }
else -> { else -> {
logD("Using repeat mode notification action") logD("Using repeat mode notification action")

View file

@ -48,13 +48,20 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.persist.PersistenceRepository
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.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackEvent
import org.oxycblt.auxio.playback.state.PlaybackStateHolder
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.Queue
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -82,7 +89,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider
class PlaybackService : class PlaybackService :
Service(), Service(),
Player.Listener, Player.Listener,
InternalPlayer, PlaybackStateHolder,
MediaSessionComponent.Listener, MediaSessionComponent.Listener,
MusicRepository.UpdateListener { MusicRepository.UpdateListener {
// Player components // Player components
@ -148,7 +155,7 @@ class PlaybackService :
foregroundManager = ForegroundManager(this) foregroundManager = ForegroundManager(this)
// Initialize any listener-dependent components last as we wouldn't want a listener race // Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize. // condition to cause us to load music before we were fully initialize.
playbackManager.registerInternalPlayer(this) playbackManager.registerStateHolder(this)
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
mediaSessionComponent.registerListener(this) mediaSessionComponent.registerListener(this)
@ -189,8 +196,8 @@ class PlaybackService :
foregroundManager.release() foregroundManager.release()
// Pause just in case this destruction was unexpected. // Pause just in case this destruction was unexpected.
playbackManager.setPlaying(false) playbackManager.playing(false)
playbackManager.unregisterInternalPlayer(this) playbackManager.unregisterStateHolder(this)
musicRepository.removeUpdateListener(this) musicRepository.removeUpdateListener(this)
unregisterReceiver(systemReceiver) unregisterReceiver(systemReceiver)
@ -210,101 +217,221 @@ class PlaybackService :
logD("Service destroyed") logD("Service destroyed")
} }
// --- CONTROLLER OVERRIDES --- // --- PLAYBACKSTATEHOLDER OVERRIDES ---
override val currentSong
get() = player.song
override val repeatMode
get() = player.repeat
override val progression: Progression
get() =
player.song?.let {
Progression.from(
player.playWhenReady,
player.isPlaying,
// The position value can be below zero or past the expected duration, make
// sure we handle that.
player.currentPosition.coerceAtLeast(0).coerceAtMost(it.durationMs))
}
?: Progression.nil()
override val audioSessionId: Int override val audioSessionId: Int
get() = player.audioSessionId get() = player.audioSessionId
override val shouldRewindWithPrev: Boolean override var parent: MusicParent? = null
get() = playbackSettings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun getState(durationMs: Long) = override val isShuffled
InternalPlayer.State.from( get() = player.shuffleModeEnabled
player.playWhenReady,
player.isPlaying,
// The position value can be below zero or past the expected duration, make
// sure we handle that.
player.currentPosition.coerceAtLeast(0).coerceAtMost(durationMs))
override fun loadSong(song: Song?, play: Boolean) { override fun resolveQueue(): Queue =
if (song == null) { player.song?.let { Queue(player.currentIndex, player.resolveQueue()) } ?: Queue.nil()
// No song, stop playback and foreground state.
logD("Nothing playing, stopping playback") override fun newPlayback(
// For some reason the player does not mark playWhenReady as false when stopped, queue: List<Song>,
// which then completely breaks any re-initialization if playback starts again. start: Song?,
// So we manually set it to false here. parent: MusicParent?,
player.playWhenReady = false shuffled: Boolean,
player.stop() play: Boolean
stopAndSave() ) {
return this.parent = parent
if (shuffled) {
player.shuffledQueue(queue, start)
} else {
player.orderedQueue(queue, start)
} }
logD("Loading $song")
player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare() player.prepare()
player.playWhenReady = play player.playWhenReady = play
playbackManager.dispatchEvent(
this,
PlaybackEvent.NewPlayback(
requireNotNull(player.song) {
"Inconsistency detected: Player does not have song despite being populated"
},
Queue(player.currentIndex, player.resolveQueue()),
parent,
isShuffled))
}
override fun playing(playing: Boolean) {
player.playWhenReady = playing
}
override fun repeatMode(repeatMode: RepeatMode) {
player.repeatMode =
when (repeatMode) {
RepeatMode.NONE -> Player.REPEAT_MODE_OFF
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
}
} }
override fun seekTo(positionMs: Long) { override fun seekTo(positionMs: Long) {
logD("Seeking to ${positionMs}ms")
player.seekTo(positionMs) player.seekTo(positionMs)
} }
override fun setPlaying(isPlaying: Boolean) { override fun next() {
logD("Updating player state to $isPlaying") player.seekToNext()
player.playWhenReady = isPlaying player.play()
}
override fun prev() {
player.seekToPrevious()
player.play()
}
override fun goto(index: Int) {
player.goto(index)
player.play()
}
override fun reorder(shuffled: Boolean) {
player.reorder(shuffled)
playbackManager.dispatchEvent(
this,
PlaybackEvent.QueueReordered(
Queue(
player.currentIndex,
player.resolveQueue(),
),
shuffled))
}
override fun addToQueue(songs: List<Song>) {
val insertAt = player.currentIndex + 1
player.addToQueue(songs)
playbackManager.dispatchEvent(
this,
PlaybackEvent.QueueChanged(
requireNotNull(player.song) {
"Inconsistency detected: Player does not have song despite being populated"
},
Queue(player.currentIndex, player.resolveQueue()),
QueueChange(
QueueChange.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size))))
}
override fun playNext(songs: List<Song>) {
val insertAt = player.currentIndex + 1
player.playNext(songs)
// TODO: Re-add queue changes
playbackManager.dispatchEvent(
this,
PlaybackEvent.QueueChanged(
requireNotNull(player.song) {
"Inconsistency detected: Player does not have song despite being populated"
},
Queue(player.currentIndex, player.resolveQueue()),
QueueChange(
QueueChange.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size))))
}
override fun move(from: Int, to: Int) {
val oldIndex = player.currentIndex
player.move(from, to)
val changeType =
if (player.currentIndex != oldIndex) {
QueueChange.Type.INDEX
} else {
QueueChange.Type.MAPPING
}
playbackManager.dispatchEvent(
this,
PlaybackEvent.QueueChanged(
requireNotNull(player.song) {
"Inconsistency detected: Player does not have song despite being populated"
},
Queue(player.currentIndex, player.resolveQueue()),
QueueChange(changeType, UpdateInstructions.Diff)))
}
override fun remove(at: Int) {
val oldUnscrambledIndex = player.currentIndex
val oldScrambledIndex = player.currentMediaItemIndex
player.remove(at)
val newUnscrambledIndex = player.currentIndex
val newScrambledIndex = player.currentMediaItemIndex
val changeType =
when {
oldScrambledIndex != newScrambledIndex -> QueueChange.Type.SONG
oldUnscrambledIndex != newUnscrambledIndex -> QueueChange.Type.INDEX
else -> QueueChange.Type.MAPPING
}
playbackManager.dispatchEvent(
this,
PlaybackEvent.QueueChanged(
requireNotNull(player.song) {
"Inconsistency detected: Player does not have song despite being populated"
},
Queue(player.currentIndex, player.resolveQueue()),
QueueChange(changeType, UpdateInstructions.Diff)))
} }
// --- PLAYER OVERRIDES --- // --- PLAYER OVERRIDES ---
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
if (player.playWhenReady) {
// Mark that we have started playing so that the notification can now be posted.
hasPlayed = true
logD("Player has started playing")
if (!openAudioEffectSession) {
// Convention to start an audioeffect session on play/pause rather than
// start/stop
logD("Opening audio effect session")
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = true
}
} else if (openAudioEffectSession) {
// Make sure to close the audio session when we stop playback.
logD("Closing audio effect session")
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = false
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) {
playbackManager.dispatchEvent(
this, PlaybackEvent.IndexMoved(player.song, player.currentIndex))
}
}
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events) super.onEvents(player, events)
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
if (player.playWhenReady) {
// Mark that we have started playing so that the notification can now be posted.
hasPlayed = true
logD("Player has started playing")
if (!openAudioEffectSession) {
// Convention to start an audioeffect session on play/pause rather than
// start/stop
logD("Opening audio effect session")
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = true
}
} else if (openAudioEffectSession) {
// Make sure to close the audio session when we stop playback.
logD("Closing audio effect session")
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = false
}
}
// Any change to the analogous isPlaying, isAdvancing, or positionMs values require
// us to synchronize with a new state.
if (events.containsAny( if (events.containsAny(
Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_POSITION_DISCONTINUITY)) { Player.EVENT_POSITION_DISCONTINUITY)) {
logD("Player state changed, must synchronize state") logD("Player state changed, must synchronize state")
playbackManager.synchronizeState(this) playbackManager.dispatchEvent(this, PlaybackEvent.ProgressionChanged(progression))
}
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) {
// Player ended, repeat the current track if we are configured to.
if (playbackManager.repeatMode == RepeatMode.TRACK) {
logD("Looping current track")
playbackManager.rewind()
// May be configured to pause when we repeat a track.
if (playbackSettings.pauseOnRepeat) {
logD("Pausing track on loop")
playbackManager.setPlaying(false)
}
} else {
logD("Track ended, moving to next track")
playbackManager.next()
}
} }
} }
@ -347,7 +474,7 @@ class PlaybackService :
} }
} }
override fun performAction(action: InternalPlayer.Action): Boolean { override fun handleDeferred(action: DeferredPlayback): Boolean {
val deviceLibrary = val deviceLibrary =
musicRepository.deviceLibrary musicRepository.deviceLibrary
// No library, cannot do anything. // No library, cannot do anything.
@ -355,7 +482,7 @@ class PlaybackService :
when (action) { when (action) {
// Restore state -> Start a new restoreState job // Restore state -> Start a new restoreState job
is InternalPlayer.Action.RestoreState -> { is DeferredPlayback.RestoreState -> {
logD("Restoring playback state") logD("Restoring playback state")
restoreScope.launch { restoreScope.launch {
persistenceRepository.readState()?.let { persistenceRepository.readState()?.let {
@ -366,20 +493,20 @@ class PlaybackService :
} }
} }
// Shuffle all -> Start new playback from all songs // Shuffle all -> Start new playback from all songs
is InternalPlayer.Action.ShuffleAll -> { is DeferredPlayback.ShuffleAll -> {
logD("Shuffling all tracks") logD("Shuffling all tracks")
playbackManager.play( playbackManager.play(
null, null, listSettings.songSort.songs(deviceLibrary.songs), true) null, null, listSettings.songSort.songs(deviceLibrary.songs), true)
} }
// Open -> Try to find the Song for the given file and then play it from all songs // Open -> Try to find the Song for the given file and then play it from all songs
is InternalPlayer.Action.Open -> { is DeferredPlayback.Open -> {
logD("Opening specified file") logD("Opening specified file")
deviceLibrary.findSongForUri(application, action.uri)?.let { song -> deviceLibrary.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play( playbackManager.play(
song, song,
null, null,
listSettings.songSort.songs(deviceLibrary.songs), listSettings.songSort.songs(deviceLibrary.songs),
playbackManager.queue.isShuffled && playbackSettings.keepShuffle) player.shuffleModeEnabled && playbackSettings.keepShuffle)
} }
} }
} }
@ -437,15 +564,15 @@ class PlaybackService :
// --- AUXIO EVENTS --- // --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> { ACTION_PLAY_PAUSE -> {
logD("Received play event") logD("Received play event")
playbackManager.setPlaying(!playbackManager.playerState.isPlaying) playbackManager.playing(!playbackManager.progression.isPlaying)
} }
ACTION_INC_REPEAT_MODE -> { ACTION_INC_REPEAT_MODE -> {
logD("Received repeat mode event") logD("Received repeat mode event")
playbackManager.repeatMode = playbackManager.repeatMode.increment() playbackManager.repeatMode(playbackManager.repeatMode.increment())
} }
ACTION_INVERT_SHUFFLE -> { ACTION_INVERT_SHUFFLE -> {
logD("Received shuffle event") logD("Received shuffle event")
playbackManager.reorder(!playbackManager.queue.isShuffled) playbackManager.reorder(!playbackManager.isShuffled)
} }
ACTION_SKIP_PREV -> { ACTION_SKIP_PREV -> {
logD("Received skip previous event") logD("Received skip previous event")
@ -457,7 +584,7 @@ class PlaybackService :
} }
ACTION_EXIT -> { ACTION_EXIT -> {
logD("Received exit event") logD("Received exit event")
playbackManager.setPlaying(false) playbackManager.playing(false)
stopAndSave() stopAndSave()
} }
WidgetProvider.ACTION_WIDGET_UPDATE -> { WidgetProvider.ACTION_WIDGET_UPDATE -> {
@ -472,17 +599,17 @@ class PlaybackService :
// which would result in unexpected playback. Work around it by dropping the first // which would result in unexpected playback. Work around it by dropping the first
// call to this function, which should come from that Intent. // call to this function, which should come from that Intent.
if (playbackSettings.headsetAutoplay && if (playbackSettings.headsetAutoplay &&
playbackManager.queue.currentSong != null && playbackManager.currentSong != null &&
initialHeadsetPlugEventHandled) { initialHeadsetPlugEventHandled) {
logD("Device connected, resuming") logD("Device connected, resuming")
playbackManager.setPlaying(true) playbackManager.playing(true)
} }
} }
private fun pauseFromHeadsetPlug() { private fun pauseFromHeadsetPlug() {
if (playbackManager.queue.currentSong != null) { if (playbackManager.currentSong != null) {
logD("Device disconnected, pausing") logD("Device disconnected, pausing")
playbackManager.setPlaying(false) playbackManager.playing(false)
} }
} }
} }

View file

@ -29,11 +29,11 @@ import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackEvent
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
@ -64,7 +64,7 @@ constructor(
/** Update [WidgetProvider] with the current playback state. */ /** Update [WidgetProvider] with the current playback state. */
fun update() { fun update() {
val song = playbackManager.queue.currentSong val song = playbackManager.currentSong
if (song == null) { if (song == null) {
logD("No song, resetting widget") logD("No song, resetting widget")
widgetProvider.update(context, uiSettings, null) widgetProvider.update(context, uiSettings, null)
@ -72,9 +72,9 @@ constructor(
} }
// Note: Store these values here so they remain consistent once the bitmap is loaded. // Note: Store these values here so they remain consistent once the bitmap is loaded.
val isPlaying = playbackManager.playerState.isPlaying val isPlaying = playbackManager.progression.isPlaying
val repeatMode = playbackManager.repeatMode val repeatMode = playbackManager.repeatMode
val isShuffled = playbackManager.queue.isShuffled val isShuffled = playbackManager.isShuffled
logD("Updating widget with new playback state") logD("Updating widget with new playback state")
bitmapProvider.load( bitmapProvider.load(
@ -135,16 +135,16 @@ constructor(
// --- CALLBACKS --- // --- CALLBACKS ---
// Respond to all major song or player changes that will affect the widget override fun onPlaybackEvent(event: PlaybackEvent) {
override fun onIndexMoved(queue: Queue) = update() if (event is PlaybackEvent.NewPlayback ||
event is PlaybackEvent.ProgressionChanged ||
override fun onQueueReordered(queue: Queue) = update() (event is PlaybackEvent.QueueChanged && event.change.type == QueueChange.Type.SONG) ||
event is PlaybackEvent.QueueReordered ||
override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update() event is PlaybackEvent.IndexMoved ||
event is PlaybackEvent.RepeatModeChanged) {
override fun onStateChanged(state: InternalPlayer.State) = update() update()
}
override fun onRepeatChanged(repeatMode: RepeatMode) = update() }
// Respond to settings changes that will affect the widget // Respond to settings changes that will affect the widget
override fun onRoundModeChanged() = update() override fun onRoundModeChanged() = update()
@ -156,7 +156,7 @@ constructor(
* *
* @param song [Queue.currentSong] * @param song [Queue.currentSong]
* @param cover A pre-loaded album cover [Bitmap] for [song]. * @param cover A pre-loaded album cover [Bitmap] for [song].
* @param isPlaying [PlaybackStateManager.playerState] * @param isPlaying [PlaybackStateManager.progression]
* @param repeatMode [PlaybackStateManager.repeatMode] * @param repeatMode [PlaybackStateManager.repeatMode]
* @param isShuffled [Queue.isShuffled] * @param isShuffled [Queue.isShuffled]
*/ */