playback: reimplement state saving

This commit is contained in:
Alexander Capehart 2024-01-13 18:34:17 -07:00
parent 1d63ad5b7b
commit bd240f967e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 440 additions and 320 deletions

View file

@ -138,23 +138,18 @@ class IndexerService :
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// // Clear invalid models from PlaybackStateManager. This is not connected
// // to a listener as it is bad practice for a shared object to attach to
// // the listener system of another.
// playbackManager.toSavedState()?.let { savedState ->
// playbackManager.applySavedState(
// PlaybackStateManager.SavedState(
// parent =
// savedState.parent?.let { musicRepository.find(it.uid) as?
// MusicParent },
// queueState =
// savedState.queueState.remap { song ->
// deviceLibrary.findSong(requireNotNull(song).uid)
// },
// positionMs = savedState.positionMs,
// repeatMode = savedState.repeatMode),
// true)
// }
// Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
savedState.copy(
heap =
savedState.heap.map { song ->
song?.let { deviceLibrary.findSong(it.uid) }
}),
true)
}
}
override fun onIndexingStateChanged() {

View file

@ -37,8 +37,8 @@ import org.oxycblt.auxio.playback.state.RepeatMode
* @author Alexander Capehart
*/
@Database(
entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class],
version = 32,
entities = [PlaybackState::class, QueueHeapItem::class, QueueShuffledMappingItem::class],
version = 38,
exportSchema = false)
@TypeConverters(Music.UID.TypeConverters::class)
abstract class PersistenceDatabase : RoomDatabase() {
@ -109,15 +109,16 @@ interface QueueDao {
/**
* Get the previously persisted queue mapping.
*
* @return A list of persisted [QueueMappingItem]s wrapping each heap item.
* @return A list of persisted [QueueShuffledMappingItem]s wrapping each heap item.
*/
@Query("SELECT * FROM QueueMappingItem") suspend fun getMapping(): List<QueueMappingItem>
@Query("SELECT * FROM QueueShuffledMappingItem")
suspend fun getShuffledMapping(): List<QueueShuffledMappingItem>
/** Delete any previously persisted queue heap entries. */
@Query("DELETE FROM QueueHeapItem") suspend fun nukeHeap()
/** Delete any previously persisted queue mapping entries. */
@Query("DELETE FROM QueueMappingItem") suspend fun nukeMapping()
@Query("DELETE FROM QueueShuffledMappingItem") suspend fun nukeShuffledMapping()
/**
* Insert new heap entries into the database.
@ -129,10 +130,10 @@ interface QueueDao {
/**
* Insert new mapping entries into the database.
*
* @param mapping The list of wrapped [QueueMappingItem] to insert.
* @param mapping The list of wrapped [QueueShuffledMappingItem] to insert.
*/
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertMapping(mapping: List<QueueMappingItem>)
suspend fun insertShuffledMapping(mapping: List<QueueShuffledMappingItem>)
}
// TODO: Figure out how to get RepeatMode to map to an int instead of a string
@ -148,5 +149,4 @@ data class PlaybackState(
@Entity data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID)
@Entity
data class QueueMappingItem(@PrimaryKey val id: Int, val orderedIndex: Int, val shuffledIndex: Int)
@Entity data class QueueShuffledMappingItem(@PrimaryKey val id: Int, val index: Int)

View file

@ -53,48 +53,37 @@ constructor(
override suspend fun readState(): PlaybackStateManager.SavedState? {
val deviceLibrary = musicRepository.deviceLibrary ?: return null
val playbackState: PlaybackState
val heap: List<QueueHeapItem>
val mapping: List<QueueMappingItem>
val heapItems: List<QueueHeapItem>
val mappingItems: List<QueueShuffledMappingItem>
try {
playbackState = playbackStateDao.getState() ?: return null
heap = queueDao.getHeap()
mapping = queueDao.getMapping()
heapItems = queueDao.getHeap()
mappingItems = queueDao.getShuffledMapping()
} catch (e: Exception) {
logE("Unable read playback state")
logE(e.stackTraceToString())
return null
}
val orderedMapping = mutableListOf<Int>()
val shuffledMapping = mutableListOf<Int>()
for (entry in mapping) {
orderedMapping.add(entry.orderedIndex)
shuffledMapping.add(entry.shuffledIndex)
}
val heap = heapItems.map { deviceLibrary.findSong(it.uid) }
val shuffledMapping = mappingItems.map { it.index }
val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent }
logD("Successfully read playback state")
// return PlaybackStateManager.SavedState(
// parent = parent,
// queueState =
// Queue.SavedState(
// heap.map { deviceLibrary.findSong(it.uid) },
// orderedMapping,
// shuffledMapping,
// playbackState.index,
// playbackState.songUid),
// positionMs = playbackState.positionMs,
// repeatMode = playbackState.repeatMode)
return null
return PlaybackStateManager.SavedState(
positionMs = playbackState.positionMs,
repeatMode = playbackState.repeatMode,
parent = parent,
heap = heap,
shuffledMapping = shuffledMapping,
index = playbackState.index,
songUid = playbackState.songUid)
}
override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean {
try {
playbackStateDao.nukeState()
queueDao.nukeHeap()
queueDao.nukeMapping()
queueDao.nukeShuffledMapping()
} catch (e: Exception) {
logE("Unable to clear previous state")
logE(e.stackTraceToString())
@ -107,29 +96,23 @@ constructor(
val playbackState =
PlaybackState(
id = 0,
index = state.queueState.index,
index = state.index,
positionMs = state.positionMs,
repeatMode = state.repeatMode,
songUid = state.queueState.songUid,
songUid = state.songUid,
parentUid = state.parent?.uid)
// Convert the remaining queue information do their database-specific counterparts.
val heap =
state.queueState.heap.mapIndexed { i, song ->
QueueHeapItem(i, requireNotNull(song).uid)
}
state.heap.mapIndexed { i, song -> QueueHeapItem(i, requireNotNull(song).uid) }
val mapping =
state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed {
i,
pair ->
QueueMappingItem(i, pair.first, pair.second)
}
val shuffledMapping =
state.shuffledMapping.mapIndexed { i, index -> QueueShuffledMappingItem(i, index) }
try {
playbackStateDao.insertState(playbackState)
queueDao.insertHeap(heap)
queueDao.insertMapping(mapping)
queueDao.insertShuffledMapping(shuffledMapping)
} catch (e: Exception) {
logE("Unable to write new state")
logE(e.stackTraceToString())

View file

@ -22,7 +22,6 @@ 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
@ -33,21 +32,13 @@ interface PlaybackStateHolder {
val parent: MusicParent?
fun resolveQueue(): List<Song>
fun resolveIndex(): Int
fun resolveQueue(): RawQueue
val isShuffled: Boolean
val audioSessionId: Int
fun newPlayback(
queue: List<Song>,
start: Song?,
parent: MusicParent?,
shuffled: Boolean,
play: Boolean
)
fun newPlayback(queue: List<Song>, start: Song?, parent: MusicParent?, shuffled: Boolean)
fun playing(playing: Boolean)
@ -61,32 +52,65 @@ interface PlaybackStateHolder {
fun goto(index: Int)
fun playNext(songs: List<Song>)
fun playNext(songs: List<Song>, ack: StateAck.PlayNext)
fun addToQueue(songs: List<Song>)
fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue)
fun move(from: Int, to: Int)
fun move(from: Int, to: Int, ack: StateAck.Move)
fun remove(at: Int)
fun remove(at: Int, ack: StateAck.Remove)
fun reorder(shuffled: Boolean)
fun shuffled(shuffled: Boolean)
fun handleDeferred(action: DeferredPlayback): Boolean
fun applySavedState(parent: MusicParent?, rawQueue: RawQueue)
}
sealed interface StateEvent {
data object IndexMoved : StateEvent
sealed interface StateAck {
data object IndexMoved : StateAck
data class QueueChanged(val instructions: UpdateInstructions, val songChanged: Boolean) :
StateEvent
data class PlayNext(val at: Int, val size: Int) : StateAck
data object QueueReordered : StateEvent
data class AddToQueue(val at: Int, val size: Int) : StateAck
data object NewPlayback : StateEvent
data class Move(val from: Int, val to: Int) : StateAck
data object ProgressionChanged : StateEvent
data class Remove(val index: Int) : StateAck
data object RepeatModeChanged : StateEvent
data object QueueReordered : StateAck
data object NewPlayback : StateAck
data object ProgressionChanged : StateAck
data object RepeatModeChanged : StateAck
}
data class RawQueue(
val heap: List<Song>,
val shuffledMapping: List<Int>,
val heapIndex: Int,
) {
val isShuffled = shuffledMapping.isNotEmpty()
fun resolveSongs() =
if (isShuffled) {
shuffledMapping.map { heap[it] }
} else {
heap
}
fun resolveIndex() =
if (isShuffled) {
shuffledMapping.indexOf(heapIndex)
} else {
heapIndex
}
companion object {
fun nil() = RawQueue(emptyList(), emptyList(), -1)
}
}
/**
@ -129,20 +153,6 @@ sealed interface DeferredPlayback {
data class Open(val uri: Uri) : DeferredPlayback
}
data class Queue(val songs: List<Song>, val index: Int) {
companion object {
fun nil() = Queue(emptyList(), -1)
}
}
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(

View file

@ -20,6 +20,8 @@ package org.oxycblt.auxio.playback.state
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig
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
import org.oxycblt.auxio.util.logD
@ -180,7 +182,16 @@ interface PlaybackStateManager {
*/
fun shuffled(shuffled: Boolean)
fun dispatchEvent(stateHolder: PlaybackStateHolder, event: StateEvent)
/**
* Acknowledges that an event has happened that modified the state held by the current
* [PlaybackStateHolder].
*
* @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current
* [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder]
* implementation.
* @param ack The [StateAck] to acknowledge.
*/
fun ack(stateHolder: PlaybackStateHolder, ack: StateAck)
/**
* Start a [DeferredPlayback] for the current [PlaybackStateHolder] to handle eventually.
@ -243,30 +254,37 @@ interface PlaybackStateManager {
/**
* Called when the position of the currently playing item has changed, changing the current
* [Song], but no other queue attribute has changed.
*
* @param index The new index of the currently playing [Song].
*/
fun onIndexMoved(index: Int) {}
/**
* Called when the [Queue] changed in a manner outlined by the given [Queue.Change].
* 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.
* @param queue The songs of the new queue.
* @param index The new index of the currently playing [Song].
* @param change The [QueueChange] that occurred.
*/
fun onQueueChanged(queue: List<Song>, index: Int, change: QueueChange) {}
/**
* Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but
* 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].
* @param queue The songs of the new queue.
* @param index The new index of the currently playing [Song].
* @param isShuffled Whether the queue is shuffled or not.
*/
fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {}
/**
* 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.
* @param parent The [MusicParent] item currently being played from.
* @param queue The queue of [Song]s to play from.
* @param index The index of the currently playing [Song].
* @param isShuffled Whether the queue is shuffled or not.
*/
fun onNewPlayback(
parent: MusicParent?,
@ -294,15 +312,17 @@ interface PlaybackStateManager {
* A condensed representation of the playback state that can be persisted.
*
* @param parent The [MusicParent] item currently being played from.
* @param queueState The [SavedQueue]
* @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode].
*/
data class SavedState(
val parent: MusicParent?,
val queueState: SavedQueue,
val positionMs: Long,
val repeatMode: RepeatMode,
val parent: MusicParent?,
val heap: List<Song?>,
val shuffledMapping: List<Int>,
val index: Int,
val songUid: Music.UID,
)
}
@ -314,6 +334,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
val queue: List<Song>,
val index: Int,
val isShuffled: Boolean,
val rawQueue: RawQueue
)
private val listeners = mutableListOf<PlaybackStateManager.Listener>()
@ -327,7 +348,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
queue = emptyList(),
index = -1,
isShuffled = false,
)
rawQueue = RawQueue.nil())
@Volatile private var stateHolder: PlaybackStateHolder? = null
@Volatile private var pendingDeferredPlayback: DeferredPlayback? = null
@Volatile private var isInitialized = false
@ -408,7 +429,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]")
// Played something, so we are initialized now
isInitialized = true
stateHolder.newPlayback(queue, song, parent, shuffled, true)
stateHolder.newPlayback(queue, song, parent, shuffled)
}
// --- QUEUE FUNCTIONS ---
@ -418,6 +439,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
val stateHolder = stateHolder ?: return
logD("Going to next song")
stateHolder.next()
stateHolder.playing(true)
}
@Synchronized
@ -425,6 +447,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
val stateHolder = stateHolder ?: return
logD("Going to previous song")
stateHolder.prev()
stateHolder.playing(true)
}
@Synchronized
@ -432,6 +455,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
val stateHolder = stateHolder ?: return
logD("Going to index $index")
stateHolder.goto(index)
stateHolder.playing(true)
}
@Synchronized
@ -442,7 +466,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
} else {
val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to start of queue")
stateHolder.playNext(songs)
stateHolder.playNext(songs, StateAck.PlayNext(stateMirror.index + 1, songs.size))
}
}
@ -454,7 +478,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
} else {
val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to end of queue")
stateHolder.addToQueue(songs)
stateHolder.addToQueue(songs, StateAck.AddToQueue(stateMirror.index + 1, songs.size))
}
}
@ -462,21 +486,21 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
override fun moveQueueItem(src: Int, dst: Int) {
val stateHolder = stateHolder ?: return
logD("Moving item $src to position $dst")
stateHolder.move(src, dst)
stateHolder.move(src, dst, StateAck.Move(src, dst))
}
@Synchronized
override fun removeQueueItem(at: Int) {
val stateHolder = stateHolder ?: return
logD("Removing item at $at")
stateHolder.remove(at)
stateHolder.remove(at, StateAck.Remove(at))
}
@Synchronized
override fun shuffled(shuffled: Boolean) {
val stateHolder = stateHolder ?: return
logD("Reordering queue [shuffled=$shuffled]")
stateHolder.reorder(shuffled)
stateHolder.shuffled(shuffled)
}
// --- INTERNAL PLAYER FUNCTIONS ---
@ -525,57 +549,113 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
}
@Synchronized
override fun dispatchEvent(stateHolder: PlaybackStateHolder, event: StateEvent) {
override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
logW("Given internal player did not match current internal player")
return
}
when (event) {
is StateEvent.IndexMoved -> {
stateMirror =
stateMirror.copy(
index = stateHolder.resolveIndex(),
)
when (ack) {
is StateAck.IndexMoved -> {
val rawQueue = stateHolder.resolveQueue()
stateMirror = stateMirror.copy(index = rawQueue.resolveIndex(), rawQueue = rawQueue)
listeners.forEach { it.onIndexMoved(stateMirror.index) }
}
is StateEvent.QueueChanged -> {
val instructions = event.instructions
val newIndex = stateHolder.resolveIndex()
val changeType =
when {
event.songChanged -> {
QueueChange.Type.SONG
}
stateMirror.index != newIndex -> QueueChange.Type.INDEX
else -> QueueChange.Type.MAPPING
}
stateMirror = stateMirror.copy(queue = stateHolder.resolveQueue(), index = newIndex)
val change = QueueChange(changeType, instructions)
is StateAck.PlayNext -> {
val rawQueue = stateHolder.resolveQueue()
val change =
QueueChange(QueueChange.Type.MAPPING, UpdateInstructions.Add(ack.at, ack.size))
stateMirror =
stateMirror.copy(
queue = rawQueue.resolveSongs(),
rawQueue = rawQueue,
)
listeners.forEach {
it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
}
}
is StateEvent.QueueReordered -> {
is StateAck.AddToQueue -> {
val rawQueue = stateHolder.resolveQueue()
val change =
QueueChange(QueueChange.Type.MAPPING, UpdateInstructions.Add(ack.at, ack.size))
stateMirror =
stateMirror.copy(
queue = stateHolder.resolveQueue(),
index = stateHolder.resolveIndex(),
isShuffled = stateHolder.isShuffled,
queue = rawQueue.resolveSongs(),
rawQueue = rawQueue,
)
listeners.forEach {
it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
}
}
is StateAck.Move -> {
val rawQueue = stateHolder.resolveQueue()
val newIndex = rawQueue.resolveIndex()
val change =
QueueChange(
if (stateMirror.index != newIndex) QueueChange.Type.INDEX
else QueueChange.Type.MAPPING,
UpdateInstructions.Move(ack.from, ack.to))
stateMirror =
stateMirror.copy(
queue = rawQueue.resolveSongs(),
index = newIndex,
rawQueue = rawQueue,
)
listeners.forEach {
it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
}
}
is StateAck.Remove -> {
val rawQueue = stateHolder.resolveQueue()
val newIndex = rawQueue.resolveIndex()
val change =
QueueChange(
when {
ack.index == stateMirror.index -> QueueChange.Type.SONG
stateMirror.index != newIndex -> QueueChange.Type.INDEX
else -> QueueChange.Type.MAPPING
},
UpdateInstructions.Remove(ack.index, 1))
stateMirror =
stateMirror.copy(
queue = rawQueue.resolveSongs(),
index = newIndex,
rawQueue = rawQueue,
)
if (change.type == QueueChange.Type.SONG) {
playing(true)
}
listeners.forEach {
it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
}
}
is StateAck.QueueReordered -> {
val rawQueue = stateHolder.resolveQueue()
stateMirror =
stateMirror.copy(
queue = rawQueue.resolveSongs(),
index = rawQueue.resolveIndex(),
isShuffled = stateHolder.isShuffled,
rawQueue = rawQueue)
listeners.forEach {
it.onQueueReordered(
stateMirror.queue, stateMirror.index, stateMirror.isShuffled)
}
}
is StateEvent.NewPlayback -> {
is StateAck.NewPlayback -> {
val rawQueue = stateHolder.resolveQueue()
stateMirror =
stateMirror.copy(
parent = stateHolder.parent,
queue = stateHolder.resolveQueue(),
index = stateHolder.resolveIndex(),
queue = rawQueue.resolveSongs(),
index = rawQueue.resolveIndex(),
isShuffled = stateHolder.isShuffled,
)
rawQueue = rawQueue)
listeners.forEach {
it.onNewPlayback(
stateMirror.parent,
@ -584,14 +664,14 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
stateMirror.isShuffled)
}
}
is StateEvent.ProgressionChanged -> {
is StateAck.ProgressionChanged -> {
stateMirror =
stateMirror.copy(
progression = stateHolder.progression,
)
listeners.forEach { it.onProgressionChanged(stateMirror.progression) }
}
is StateEvent.RepeatModeChanged -> {
is StateAck.RepeatModeChanged -> {
stateMirror =
stateMirror.copy(
repeatMode = stateHolder.repeatMode,
@ -603,51 +683,99 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
// --- PERSISTENCE FUNCTIONS ---
@Synchronized override fun toSavedState() = null
// queue.toSavedState()?.let {
// PlaybackStateManager.SavedState(
// parent = parent,
// queueState = it,
// positionMs = progression.calculateElapsedPositionMs(),
// repeatMode = repeatMode)
// }
@Synchronized
override fun toSavedState(): PlaybackStateManager.SavedState? {
val currentSong = currentSong ?: return null
return PlaybackStateManager.SavedState(
positionMs = stateMirror.progression.calculateElapsedPositionMs(),
repeatMode = stateMirror.repeatMode,
parent = stateMirror.parent,
heap = stateMirror.rawQueue.heap,
shuffledMapping = stateMirror.rawQueue.shuffledMapping,
index = stateMirror.index,
songUid = currentSong.uid,
)
}
@Synchronized
override fun applySavedState(
savedState: PlaybackStateManager.SavedState,
destructive: Boolean
) {
// if (isInitialized && !destructive) {
// logW("Already initialized, cannot apply saved state")
// return
// }
// val stateHolder = stateHolder ?: return
// logD("Applying state $savedState")
//
// val lastSong = queue.currentSong
// parent = savedState.parent
// queue.applySavedState(savedState.queueState)
// repeatMode = savedState.repeatMode
// notifyNewPlayback()
//
// // Check if we need to reload the player with a new music file, or if we can just
// leave
// // it be. Specifically done so we don't pause on music updates that don't really
// change
// // what's playing (ex. playlist editing)
// if (lastSong != queue.currentSong) {
// logD("Song changed, must reload player")
// // Continuing playback while also possibly doing drastic state updates is
// // a bad idea, so pause.
// stateHolder.loadSong(queue.currentSong, false)
// if (queue.currentSong != null) {
// logD("Seeking to saved position ${savedState.positionMs}ms")
// // Internal player may have reloaded the media item, re-seek to the
// previous
// // position
// seekTo(savedState.positionMs)
// }
// }
if (isInitialized && !destructive) {
logW("Already initialized, cannot apply saved state")
return
}
// The heap may not be the same if the song composition changed between state saves/reloads.
// This also means that we must modify the shuffled mapping as well, in what it points to
// and it's general composition.
val heap = mutableListOf<Song>()
val adjustments = mutableListOf<Int?>()
var currentShift = 0
for (song in savedState.heap) {
if (song != null) {
heap.add(song)
adjustments.add(currentShift)
} else {
adjustments.add(null)
currentShift -= 1
}
}
logD("Created adjustment mapping [max shift=$currentShift]")
val shuffledMapping =
savedState.shuffledMapping.mapNotNullTo(mutableListOf()) { index ->
adjustments[index]?.let { index + it }
}
// Make sure we re-align the index to point to the previously playing song.
fun pointingAtSong(): Boolean {
val currentSong =
if (shuffledMapping.isNotEmpty()) {
shuffledMapping.getOrNull(savedState.index)?.let { heap.getOrNull(it) }
} else {
heap.getOrNull(savedState.index)
}
return currentSong?.uid == savedState.songUid
}
var index = savedState.index
while (pointingAtSong() && index > -1) {
index--
}
logD("Corrected index: ${savedState.index} -> $index")
check(shuffledMapping.all { it in heap.indices }) {
"Queue inconsistency detected: Shuffled mapping indices out of heap bounds"
}
val rawQueue =
RawQueue(
heap = heap,
shuffledMapping = savedState.shuffledMapping,
heapIndex =
if (savedState.shuffledMapping.isNotEmpty()) {
savedState.shuffledMapping[savedState.index]
} else {
savedState.index
})
val oldStateMirror = stateMirror
if (oldStateMirror.rawQueue != rawQueue) {
logD("Queue changed, must reload player")
stateHolder?.applySavedState(parent, rawQueue)
}
if (oldStateMirror.progression.calculateElapsedPositionMs() != savedState.positionMs) {
logD("Seeking to saved position ${savedState.positionMs}ms")
stateHolder?.seekTo(savedState.positionMs)
}
isInitialized = true
}
}

View file

@ -29,7 +29,7 @@ import java.util.*
*
* @author media3 team, Alexander Capehart (OxygenCobalt)
*/
class BetterShuffleOrder private constructor(private val shuffled: IntArray) : ShuffleOrder {
class BetterShuffleOrder constructor(private val shuffled: IntArray) : ShuffleOrder {
private val indexInShuffled: IntArray = IntArray(shuffled.size)
constructor(length: Int, startIndex: Int) : this(createShuffledList(length, startIndex))

View file

@ -23,15 +23,19 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RawQueue
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD
val ExoPlayer.song
get() = currentMediaItem?.song
fun ExoPlayer.resolveIndex() = unscrambleQueueIndices().indexOf(currentMediaItemIndex)
fun ExoPlayer.resolveQueue() = unscrambleQueueIndices().map { getMediaItemAt(it).song }
fun ExoPlayer.resolveQueue(): RawQueue {
val heap = (0 until mediaItemCount).map { getMediaItemAt(it).song }
val shuffledMapping = if (shuffleModeEnabled) unscrambleQueueIndices() else emptyList()
logD(shuffledMapping)
return RawQueue(heap, shuffledMapping, currentMediaItemIndex)
}
val ExoPlayer.repeat: RepeatMode
get() =
@ -57,10 +61,6 @@ fun ExoPlayer.orderedQueue(queue: Collection<Song>, start: Song?) {
}
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.
setMediaItems(queue.map { it.toMediaItem() })
shuffleModeEnabled = true
val startIndex =
@ -73,11 +73,22 @@ fun ExoPlayer.shuffledQueue(queue: Collection<Song>, start: Song?) {
seekTo(currentTimeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET)
}
fun ExoPlayer.reorder(shuffled: Boolean) {
fun ExoPlayer.applyQueue(rawQueue: RawQueue) {
setMediaItems(rawQueue.heap.map { it.toMediaItem() })
if (rawQueue.isShuffled) {
shuffleModeEnabled = true
setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
} else {
shuffleModeEnabled = false
}
seekTo(rawQueue.heapIndex, C.TIME_UNSET)
}
fun ExoPlayer.shuffled(shuffled: Boolean) {
logD("Reordering queue to $shuffled")
shuffleModeEnabled = shuffled
if (shuffled) {
// Have to manually refresh the shuffle seed.
// Have to manually refresh the shuffle seed and anchor it to the new current songs
setShuffleOrder(BetterShuffleOrder(mediaItemCount, currentMediaItemIndex))
}
}

View file

@ -48,7 +48,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.BuildConfig
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.Song
@ -59,8 +58,9 @@ import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackStateHolder
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.RawQueue
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.state.StateEvent
import org.oxycblt.auxio.playback.state.StateAck
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@ -237,8 +237,6 @@ class PlaybackService :
override val isShuffled
get() = player.shuffleModeEnabled
override fun resolveIndex() = player.resolveIndex()
override fun resolveQueue() = player.resolveQueue()
override val audioSessionId: Int
@ -248,8 +246,7 @@ class PlaybackService :
queue: List<Song>,
start: Song?,
parent: MusicParent?,
shuffled: Boolean,
play: Boolean
shuffled: Boolean
) {
this.parent = parent
if (shuffled) {
@ -258,8 +255,8 @@ class PlaybackService :
player.orderedQueue(queue, start)
}
player.prepare()
player.playWhenReady = play
playbackManager.dispatchEvent(this, StateEvent.NewPlayback)
player.play()
playbackManager.ack(this, StateAck.NewPlayback)
}
override fun playing(playing: Boolean) {
@ -274,7 +271,7 @@ class PlaybackService :
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
}
playbackManager.dispatchEvent(this, StateEvent.RepeatModeChanged)
playbackManager.ack(this, StateAck.RepeatModeChanged)
}
override fun seekTo(positionMs: Long) {
@ -283,137 +280,42 @@ class PlaybackService :
override fun next() {
player.seekToNext()
playbackManager.dispatchEvent(this, StateEvent.IndexMoved)
player.play()
playbackManager.ack(this, StateAck.IndexMoved)
}
override fun prev() {
player.seekToPrevious()
playbackManager.dispatchEvent(this, StateEvent.IndexMoved)
player.play()
playbackManager.ack(this, StateAck.IndexMoved)
}
override fun goto(index: Int) {
player.goto(index)
playbackManager.dispatchEvent(this, StateEvent.IndexMoved)
player.play()
playbackManager.ack(this, StateAck.IndexMoved)
}
override fun reorder(shuffled: Boolean) {
player.reorder(shuffled)
playbackManager.dispatchEvent(this, StateEvent.QueueReordered)
override fun shuffled(shuffled: Boolean) {
player.shuffled(shuffled)
playbackManager.ack(this, StateAck.QueueReordered)
}
override fun addToQueue(songs: List<Song>) {
val insertAt = playbackManager.index + 1
player.addToQueue(songs)
playbackManager.dispatchEvent(
this, StateEvent.QueueChanged(UpdateInstructions.Add(insertAt, songs.size), false))
}
override fun playNext(songs: List<Song>) {
val insertAt = playbackManager.index + 1
override fun playNext(songs: List<Song>, ack: StateAck.PlayNext) {
player.playNext(songs)
playbackManager.dispatchEvent(
this, StateEvent.QueueChanged(UpdateInstructions.Add(insertAt, songs.size), false))
playbackManager.ack(this, ack)
}
override fun move(from: Int, to: Int) {
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
player.addToQueue(songs)
playbackManager.ack(this, ack)
}
override fun move(from: Int, to: Int, ack: StateAck.Move) {
player.move(from, to)
playbackManager.dispatchEvent(
this, StateEvent.QueueChanged(UpdateInstructions.Move(from, to), false))
playbackManager.ack(this, ack)
}
override fun remove(at: Int) {
val oldIndex = player.currentMediaItemIndex
override fun remove(at: Int, ack: StateAck.Remove) {
player.remove(at)
val newIndex = player.currentMediaItemIndex
playbackManager.dispatchEvent(
this, StateEvent.QueueChanged(UpdateInstructions.Remove(at, 1), oldIndex != newIndex))
}
// --- 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, StateEvent.IndexMoved)
}
}
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.containsAny(
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_POSITION_DISCONTINUITY)) {
logD("Player state changed, must synchronize state")
playbackManager.dispatchEvent(this, StateEvent.ProgressionChanged)
}
}
override fun onPlayerError(error: PlaybackException) {
// TODO: Replace with no skipping and a notification instead
// If there's any issue, just go to the next song.
logE("Player error occured")
logE(error.stackTraceToString())
playbackManager.next()
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
// We now have a library, see if we have anything we need to do.
logD("Library obtained, requesting action")
playbackManager.requestAction(this)
}
}
// --- OTHER FUNCTIONS ---
private fun broadcastAudioEffectAction(event: String) {
logD("Broadcasting AudioEffect event: $event")
sendBroadcast(
Intent(event)
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
}
private fun stopAndSave() {
// This session has ended, so we need to reset this flag for when the next session starts.
hasPlayed = false
if (foregroundManager.tryStopForeground()) {
// Now that we have ended the foreground state (and thus music playback), we'll need
// to save the current state as it's not long until this service (and likely the whole
// app) is killed.
logD("Saving playback state")
saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) }
}
playbackManager.ack(this, ack)
}
override fun handleDeferred(action: DeferredPlayback): Boolean {
@ -456,6 +358,97 @@ class PlaybackService :
return true
}
override fun applySavedState(parent: MusicParent?, rawQueue: RawQueue) {
this.parent = parent
player.applyQueue(rawQueue)
player.prepare()
playbackManager.ack(this, StateAck.NewPlayback)
}
// --- 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.ack(this, StateAck.IndexMoved)
}
}
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.containsAny(
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_POSITION_DISCONTINUITY)) {
logD("Player state changed, must synchronize state")
playbackManager.ack(this, StateAck.ProgressionChanged)
}
}
override fun onPlayerError(error: PlaybackException) {
// TODO: Replace with no skipping and a notification instead
// If there's any issue, just go to the next song.
logE("Player error occured")
logE(error.stackTraceToString())
playbackManager.next()
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
// We now have a library, see if we have anything we need to do.
logD("Library obtained, requesting action")
playbackManager.requestAction(this)
}
}
// --- OTHER FUNCTIONS ---
private fun broadcastAudioEffectAction(event: String) {
logD("Broadcasting AudioEffect event: $event")
sendBroadcast(
Intent(event)
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
}
private fun stopAndSave() {
// This session has ended, so we need to reset this flag for when the next session starts.
hasPlayed = false
if (foregroundManager.tryStopForeground()) {
// Now that we have ended the foreground state (and thus music playback), we'll need
// to save the current state as it's not long until this service (and likely the whole
// app) is killed.
logD("Saving playback state")
saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) }
}
}
// --- MEDIASESSIONCOMPONENT OVERRIDES ---
override fun onPostNotification(notification: NotificationComponent) {