diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 3e506967e..83f8d5f80 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -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() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index 82ba84ddb..adceb581f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -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 + @Query("SELECT * FROM QueueShuffledMappingItem") + suspend fun getShuffledMapping(): List /** 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) + suspend fun insertShuffledMapping(mapping: List) } // 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) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt index 4ba4423ea..a291cc175 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt @@ -53,48 +53,37 @@ constructor( override suspend fun readState(): PlaybackStateManager.SavedState? { val deviceLibrary = musicRepository.deviceLibrary ?: return null val playbackState: PlaybackState - val heap: List - val mapping: List + val heapItems: List + val mappingItems: List 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() - val shuffledMapping = mutableListOf() - 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()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index c3b2fbbab..ef8175755 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -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 - - fun resolveIndex(): Int + fun resolveQueue(): RawQueue val isShuffled: Boolean val audioSessionId: Int - fun newPlayback( - queue: List, - start: Song?, - parent: MusicParent?, - shuffled: Boolean, - play: Boolean - ) + fun newPlayback(queue: List, start: Song?, parent: MusicParent?, shuffled: Boolean) fun playing(playing: Boolean) @@ -61,32 +52,65 @@ interface PlaybackStateHolder { fun goto(index: Int) - fun playNext(songs: List) + fun playNext(songs: List, ack: StateAck.PlayNext) - fun addToQueue(songs: List) + fun addToQueue(songs: List, 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, + val shuffledMapping: List, + 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, val index: Int) { - companion object { - fun nil() = Queue(emptyList(), -1) - } -} - -data class SavedQueue( - val heap: List, - val orderedMapping: List, - val shuffledMapping: List, - 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( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index e6356bba2..b38c63c0b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -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, 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, 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, + val shuffledMapping: List, + val index: Int, + val songUid: Music.UID, ) } @@ -314,6 +334,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val queue: List, val index: Int, val isShuffled: Boolean, + val rawQueue: RawQueue ) private val listeners = mutableListOf() @@ -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() + val adjustments = mutableListOf() + 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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt index cc921c277..099931baf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt @@ -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)) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt index d22bf6376..0ae7ce70f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt @@ -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, start: Song?) { } fun ExoPlayer.shuffledQueue(queue: Collection, 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, 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)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 6c973c167..64c5d2ffd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -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, 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) { - val insertAt = playbackManager.index + 1 - player.addToQueue(songs) - playbackManager.dispatchEvent( - this, StateEvent.QueueChanged(UpdateInstructions.Add(insertAt, songs.size), false)) - } - - override fun playNext(songs: List) { - val insertAt = playbackManager.index + 1 + override fun playNext(songs: List, 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, 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) {