From bb2ea9df2703da6d6517d6fbc9b91fe5c3d1b5c5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 29 Jan 2023 15:46:41 -0700 Subject: [PATCH] playback: hide playbackstatemanaager impl Make PlaybackStateManager an interface instead of a direct implementation. Part of a rework to implement dependency injection in-app. --- .../oxycblt/auxio/music/library/Library.kt | 20 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 1 - .../auxio/music/system/IndexerService.kt | 15 +- .../auxio/playback/PlaybackViewModel.kt | 22 +- .../playback/persist/PersistenceDatabase.kt | 1 + .../playback/persist/PersistenceRepository.kt | 76 +- .../org/oxycblt/auxio/playback/queue/Queue.kt | 166 ++-- .../auxio/playback/queue/QueueViewModel.kt | 2 +- .../replaygain/ReplayGainAudioProcessor.kt | 2 +- .../playback/state/PlaybackStateManager.kt | 771 +++++++++--------- .../playback/system/MediaButtonReceiver.kt | 2 +- .../playback/system/MediaSessionComponent.kt | 2 +- .../auxio/playback/system/PlaybackService.kt | 10 +- .../oxycblt/auxio/widgets/WidgetComponent.kt | 4 +- 14 files changed, 543 insertions(+), 551 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt index d8a42b40a..0ee5dc736 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt @@ -68,25 +68,11 @@ class Library(rawSongs: List, settings: MusicSettings) { fun sanitize(song: Song) = find(song.uid) /** - * Convert a [Album] from an another library into a [Album] in this [Library]. - * @param album The [Album] to convert. + * Convert a [MusicParent] from an another library into a [MusicParent] in this [Library]. + * @param parent The [MusicParent] to convert. * @return The analogous [Album] in this [Library], or null if it does not exist. */ - fun sanitize(album: Album) = find(album.uid) - - /** - * Convert a [Artist] from an another library into a [Artist] in this [Library]. - * @param artist The [Artist] to convert. - * @return The analogous [Artist] in this [Library], or null if it does not exist. - */ - fun sanitize(artist: Artist) = find(artist.uid) - - /** - * Convert a [Genre] from an another library into a [Genre] in this [Library]. - * @param genre The [Genre] to convert. - * @return The analogous [Genre] in this [Library], or null if it does not exist. - */ - fun sanitize(genre: Genre) = find(genre.uid) + fun sanitize(parent: T) = find(parent.uid) /** * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index 14b604325..abcd89d23 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -24,7 +24,6 @@ import android.os.Build import androidx.core.content.ContextCompat import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig 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 6358cb9ce..b03506337 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 @@ -56,7 +56,7 @@ import org.oxycblt.auxio.util.logD class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { private val indexer = Indexer.getInstance() private val musicStore = MusicStore.getInstance() - private val playbackManager = PlaybackStateManager.getInstance() + private val playbackManager = PlaybackStateManager.get() private val serviceJob = Job() private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private var currentIndexJob: Job? = null @@ -139,7 +139,18 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // 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.sanitize(newLibrary) + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + PlaybackStateManager.SavedState( + parent = savedState.parent?.let(newLibrary::sanitize), + queueState = + savedState.queueState.remap { song -> + newLibrary.sanitize(requireNotNull(song)) + }, + positionMs = savedState.positionMs, + repeatMode = savedState.repeatMode), + true) + } } // Forward the new library to MusicStore to continue the update process. musicStore.library = newLibrary diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 797165942..8a4726b8a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -38,7 +38,7 @@ class PlaybackViewModel(application: Application) : AndroidViewModel(application), PlaybackStateManager.Listener { private val musicSettings = MusicSettings.from(application) private val playbackSettings = PlaybackSettings.from(application) - private val playbackManager = PlaybackStateManager.getInstance() + private val playbackManager = PlaybackStateManager.get() private val persistenceRepository = PersistenceRepository.from(application) private val musicStore = MusicStore.getInstance() private var lastPositionJob: Job? = null @@ -430,8 +430,7 @@ class PlaybackViewModel(application: Application) : */ fun savePlaybackState(onDone: (Boolean) -> Unit) { viewModelScope.launch { - val saved = playbackManager.saveState(persistenceRepository) - onDone(saved) + onDone(persistenceRepository.saveState(playbackManager.toSavedState())) } } @@ -440,10 +439,7 @@ class PlaybackViewModel(application: Application) : * @param onDone Called when the wipe is completed with true if successful, and false otherwise. */ fun wipePlaybackState(onDone: (Boolean) -> Unit) { - viewModelScope.launch { - val wiped = playbackManager.wipeState(persistenceRepository) - onDone(wiped) - } + viewModelScope.launch { onDone(persistenceRepository.saveState(null)) } } /** @@ -453,8 +449,16 @@ class PlaybackViewModel(application: Application) : */ fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) { viewModelScope.launch { - val restored = playbackManager.restoreState(persistenceRepository, true) - onDone(restored) + val library = musicStore.library + if (library != null) { + val savedState = persistenceRepository.readState(library) + if (savedState != null) { + playbackManager.applySavedState(savedState, true) + onDone(true) + return@launch + } + } + onDone(false) } } 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 767103873..cd335501a 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 @@ -73,6 +73,7 @@ abstract class PersistenceDatabase : RoomDatabase() { PersistenceDatabase::class.java, "auxio_playback_persistence.db") .fallbackToDestructiveMigration() + .fallbackToDestructiveMigrationFrom(1) .fallbackToDestructiveMigrationOnDowngrade() .build() INSTANCE = newInstance 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 018ebcb32..032267515 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 @@ -21,8 +21,9 @@ import android.content.Context import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.playback.queue.Queue -import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE /** * Manages the persisted playback state in a structured manner. @@ -30,30 +31,16 @@ import org.oxycblt.auxio.util.logD */ interface PersistenceRepository { /** - * Read the previously persisted [SavedState]. - * @param library The [Library] required to de-serialize the [SavedState]. + * Read the previously persisted [PlaybackStateManager.SavedState]. + * @param library The [Library] required to de-serialize the [PlaybackStateManager.SavedState]. */ - suspend fun readState(library: Library): SavedState? + suspend fun readState(library: Library): PlaybackStateManager.SavedState? /** - * Persist a new [SavedState]. - * @param state The [SavedState] to persist. + * Persist a new [PlaybackStateManager.SavedState]. + * @param state The [PlaybackStateManager.SavedState] to persist. */ - suspend fun saveState(state: SavedState?) - - /** - * A condensed representation of the playback state that can be persisted. - * @param parent The [MusicParent] item currently being played from. - * @param queueState The [Queue.SavedState] - * @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: Queue.SavedState, - val positionMs: Long, - val repeatMode: RepeatMode, - ) + suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean companion object { /** @@ -69,10 +56,19 @@ private class RealPersistenceRepository(private val context: Context) : Persiste private val playbackStateDao: PlaybackStateDao by lazy { database.playbackStateDao() } private val queueDao: QueueDao by lazy { database.queueDao() } - override suspend fun readState(library: Library): PersistenceRepository.SavedState? { - val playbackState = playbackStateDao.getState() ?: return null - val heap = queueDao.getHeap() - val mapping = queueDao.getMapping() + override suspend fun readState(library: Library): PlaybackStateManager.SavedState? { + val playbackState: PlaybackState + val heap: List + val mapping: List + try { + playbackState = playbackStateDao.getState() ?: return null + heap = queueDao.getHeap() + mapping = queueDao.getMapping() + } catch (e: Exception) { + logE("Unable to load playback state data") + logE(e.stackTraceToString()) + return null + } val orderedMapping = mutableListOf() val shuffledMapping = mutableListOf() @@ -82,8 +78,9 @@ private class RealPersistenceRepository(private val context: Context) : Persiste } val parent = playbackState.parentUid?.let { library.find(it) } + logD("Read playback state") - return PersistenceRepository.SavedState( + return PlaybackStateManager.SavedState( parent = parent, queueState = Queue.SavedState( @@ -96,12 +93,18 @@ private class RealPersistenceRepository(private val context: Context) : Persiste repeatMode = playbackState.repeatMode) } - override suspend fun saveState(state: PersistenceRepository.SavedState?) { + override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean { // Only bother saving a state if a song is actively playing from one. // This is not the case with a null state. - playbackStateDao.nukeState() - queueDao.nukeHeap() - queueDao.nukeMapping() + try { + playbackStateDao.nukeState() + queueDao.nukeHeap() + queueDao.nukeMapping() + } catch (e: Exception) { + logE("Unable to clear previous state") + logE(e.stackTraceToString()) + return false + } logD("Cleared state") if (state != null) { // Transform saved state into raw state, which can then be written to the database. @@ -113,22 +116,29 @@ private class RealPersistenceRepository(private val context: Context) : Persiste repeatMode = state.repeatMode, songUid = state.queueState.songUid, parentUid = state.parent?.uid) - playbackStateDao.insertState(playbackState) // Convert the remaining queue information do their database-specific counterparts. val heap = state.queueState.heap.mapIndexed { i, song -> QueueHeapItem(i, requireNotNull(song).uid) } - queueDao.insertHeap(heap) val mapping = state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed { i, pair -> QueueMappingItem(i, pair.first, pair.second) } - queueDao.insertMapping(mapping) + try { + playbackStateDao.insertState(playbackState) + queueDao.insertHeap(heap) + queueDao.insertMapping(mapping) + } catch (e: Exception) { + logE("Unable to write new state") + logE(e.stackTraceToString()) + return false + } logD("Wrote state") } + return true } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt index 2419203b7..c13ae22d7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt @@ -36,30 +36,86 @@ import org.oxycblt.auxio.music.Song * * @author OxygenCobalt */ -class Queue { +interface Queue { + val index: Int + val currentSong: Song? + val isShuffled: Boolean + /** + * Resolve this queue into a more conventional list of [Song]s. + * @return A list of [Song] corresponding to the current queue mapping. + */ + fun resolve(): List + + /** + * Represents the possible changes that can occur during certain queue mutation events. The + * precise meanings of these differ somewhat depending on the type of mutation done. + */ + enum class ChangeResult { + /** 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 + } + + /** + * An immutable representation of the queue state. + * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with + * null values to represent [Song]s that were "lost" from the heap without having to change + * other values. + * @param orderedMapping The mapping of the [heap] to an ordered queue. + * @param shuffledMapping The mapping of the [heap] to a shuffled queue. + * @param index The index of the currently playing [Song] at the time of serialization. + * @param songUid The [Music.UID] of the [Song] that was originally at [index]. + */ + class SavedState( + val heap: List, + val orderedMapping: List, + val shuffledMapping: List, + val index: Int, + val songUid: Music.UID, + ) { + /** + * Remaps the [heap] of this instance based on the given mapping function and copies it into + * a new [SavedState]. + * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This + * **MUST** be the same size as the original heap. [Song] instances that could not be + * converted should be replaced with null in the new heap. + * @throws IllegalStateException If the invariant specified by [transform] is violated. + */ + inline fun remap(transform: (Song?) -> Song?) = + SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid) + } +} + +class EditableQueue : Queue { @Volatile private var heap = mutableListOf() @Volatile private var orderedMapping = mutableListOf() @Volatile private var shuffledMapping = mutableListOf() /** The index of the currently playing [Song] in the current mapping. */ @Volatile - var index = -1 + override var index = -1 private set /** The currently playing [Song]. */ - val currentSong: Song? + override val currentSong: Song? get() = shuffledMapping .ifEmpty { orderedMapping.ifEmpty { null } } ?.getOrNull(index) ?.let(heap::get) /** Whether this queue is shuffled. */ - val isShuffled: Boolean + override val isShuffled: Boolean get() = shuffledMapping.isNotEmpty() /** * Resolve this queue into a more conventional list of [Song]s. * @return A list of [Song] corresponding to the current queue mapping. */ - fun resolve() = + override fun resolve() = if (currentSong != null) { shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } } else { @@ -137,11 +193,11 @@ class Queue { * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there * was no prior playback and these enqueued [Song]s start new playback. */ - fun playNext(songs: List): ChangeResult { + fun playNext(songs: List): Queue.ChangeResult { if (orderedMapping.isEmpty()) { // No playback, start playing these songs. start(songs[0], songs, false) - return ChangeResult.SONG + return Queue.ChangeResult.SONG } val heapIndices = songs.map(::addSongToHeap) @@ -156,20 +212,21 @@ class Queue { orderedMapping.addAll(index + 1, heapIndices) } check() - return ChangeResult.MAPPING + return Queue.ChangeResult.MAPPING } /** * Add [Song]s to the end of the queue. Will start playback if nothing is playing. * @param songs The [Song]s to add. - * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there - * was no prior playback and these enqueued [Song]s start new playback. + * @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or + * [Queue.ChangeResult.SONG] if there was no prior playback and these enqueued [Song]s start new + * playback. */ - fun addToQueue(songs: List): ChangeResult { + fun addToQueue(songs: List): Queue.ChangeResult { if (orderedMapping.isEmpty()) { // No playback, start playing these songs. start(songs[0], songs, false) - return ChangeResult.SONG + return Queue.ChangeResult.SONG } val heapIndices = songs.map(::addSongToHeap) @@ -179,18 +236,18 @@ class Queue { shuffledMapping.addAll(heapIndices) } check() - return ChangeResult.MAPPING + return Queue.ChangeResult.MAPPING } /** * Move a [Song] at the given position to a new position. * @param src The position of the [Song] to move. * @param dst The destination position of the [Song]. - * @return [ChangeResult.MAPPING] if the move occurred after the current index, - * [ChangeResult.INDEX] if the move occurred before or at the current index, requiring it to be - * mutated. + * @return [Queue.ChangeResult.MAPPING] if the move occurred after the current index, + * [Queue.ChangeResult.INDEX] if the move occurred before or at the current index, requiring it + * to be mutated. */ - fun move(src: Int, dst: Int): ChangeResult { + fun move(src: Int, dst: Int): Queue.ChangeResult { if (shuffledMapping.isNotEmpty()) { // Move songs only in the shuffled mapping. There is no sane analogous form of // this for the ordered mapping. @@ -210,21 +267,21 @@ class Queue { else -> { // Nothing to do. check() - return ChangeResult.MAPPING + return Queue.ChangeResult.MAPPING } } check() - return ChangeResult.INDEX + return Queue.ChangeResult.INDEX } /** * Remove a [Song] at the given position. * @param at The position of the [Song] to remove. - * @return [ChangeResult.MAPPING] if the removed [Song] was after the current index, - * [ChangeResult.INDEX] if the removed [Song] was before the current index, and - * [ChangeResult.SONG] if the currently playing [Song] was removed. + * @return [Queue.ChangeResult.MAPPING] if the removed [Song] was after the current index, + * [Queue.ChangeResult.INDEX] if the removed [Song] was before the current index, and + * [Queue.ChangeResult.SONG] if the currently playing [Song] was removed. */ - fun remove(at: Int): ChangeResult { + fun remove(at: Int): Queue.ChangeResult { if (shuffledMapping.isNotEmpty()) { // Remove the specified index in the shuffled mapping and the analogous song in the // ordered mapping. @@ -242,34 +299,34 @@ class Queue { val result = when { // We just removed the currently playing song. - index == at -> ChangeResult.SONG + index == at -> Queue.ChangeResult.SONG // Index was ahead of removed song, shift back to preserve consistency. index > at -> { index -= 1 - ChangeResult.INDEX + Queue.ChangeResult.INDEX } // Nothing to do - else -> ChangeResult.MAPPING + else -> Queue.ChangeResult.MAPPING } check() return result } /** - * Convert the current state of this instance into a [SavedState]. - * @return A new [SavedState] reflecting the exact state of the queue when called. + * Convert the current state of this instance into a [Queue.SavedState]. + * @return A new [Queue.SavedState] reflecting the exact state of the queue when called. */ fun toSavedState() = currentSong?.let { song -> - SavedState( + Queue.SavedState( heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid) } /** - * Update this instance from the given [SavedState]. - * @param savedState A [SavedState] with a valid queue representation. + * Update this instance from the given [Queue.SavedState]. + * @param savedState A [Queue.SavedState] with a valid queue representation. */ - fun applySavedState(savedState: SavedState) { + fun applySavedState(savedState: Queue.SavedState) { val adjustments = mutableListOf() var currentShift = 0 for (song in savedState.heap) { @@ -345,49 +402,4 @@ class Queue { "Queue inconsistency detected: Shuffled mapping indices out of heap bounds" } } - - /** - * An immutable representation of the queue state. - * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with - * null values to represent [Song]s that were "lost" from the heap without having to change - * other values. - * @param orderedMapping The mapping of the [heap] to an ordered queue. - * @param shuffledMapping The mapping of the [heap] to a shuffled queue. - * @param index The index of the currently playing [Song] at the time of serialization. - * @param songUid The [Music.UID] of the [Song] that was originally at [index]. - */ - class SavedState( - val heap: List, - val orderedMapping: List, - val shuffledMapping: List, - val index: Int, - val songUid: Music.UID, - ) { - /** - * Remaps the [heap] of this instance based on the given mapping function and copies it into - * a new [SavedState]. - * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This - * **MUST** be the same size as the original heap. [Song] instances that could not be - * converted should be replaced with null in the new heap. - * @throws IllegalStateException If the invariant specified by [transform] is violated. - */ - inline fun remap(transform: (Song?) -> Song?) = - SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid) - } - - /** - * Represents the possible changes that can occur during certain queue mutation events. The - * precise meanings of these differ somewhat depending on the type of mutation done. - */ - enum class ChangeResult { - /** 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 - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 947a9490f..4013f9bb3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -31,7 +31,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager * @author Alexander Capehart (OxygenCobalt) */ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { - private val playbackManager = PlaybackStateManager.getInstance() + private val playbackManager = PlaybackStateManager.get() private val _queue = MutableStateFlow(listOf()) /** The current queue. */ diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index ec84ef92a..f716935e1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.logD */ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { - private val playbackManager = PlaybackStateManager.getInstance() + private val playbackManager = PlaybackStateManager.get() private val playbackSettings = PlaybackSettings.from(context) private var lastFormat: Format? = null 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 fabeda51a..f9beb297d 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Auxio Project + * Copyright (c) 2023 Auxio Project * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,19 +17,13 @@ package org.oxycblt.auxio.playback.state -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.playback.persist.PersistenceRepository +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.queue.EditableQueue import org.oxycblt.auxio.playback.queue.Queue -import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW -import org.oxycblt.auxio.util.unlikelyToBeNull /** * Core playback state controller class. @@ -38,48 +32,29 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * MediaSession is poorly designed. This class instead ful-fills this role. * * This should ***NOT*** be used outside of the playback module. - * - If you want to use the playback state in the UI, use - * [org.oxycblt.auxio.playback.PlaybackViewModel] as it can withstand volatile UIs. + * - If you want to use the playback state in the UI, use PlaybackViewModel as it can withstand + * volatile UIs. * - If you want to use the playback state with the ExoPlayer instance or system-side things, use - * [org.oxycblt.auxio.playback.system.PlaybackService]. + * PlaybackService. * * Internal consumers should usually use [Listener], however the component that manages the player * itself should instead use [InternalPlayer]. * - * All access should be done with [PlaybackStateManager.getInstance]. + * All access should be done with [get]. * * @author Alexander Capehart (OxygenCobalt) */ -class PlaybackStateManager private constructor() { - private val musicStore = MusicStore.getInstance() - private val listeners = mutableListOf() - @Volatile private var internalPlayer: InternalPlayer? = null - @Volatile private var pendingAction: InternalPlayer.Action? = null - @Volatile private var isInitialized = false - +interface PlaybackStateManager { /** The current [Queue]. */ - val queue = Queue() + val queue: Queue /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ - @Volatile - var parent: MusicParent? = null // FIXME: Parent is interpreted wrong when nothing is playing. - private set - + val parent: MusicParent? /** The current [InternalPlayer] state. */ - @Volatile - var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) - private set + val playerState: InternalPlayer.State /** The current [RepeatMode] */ - @Volatile - var repeatMode = RepeatMode.NONE - set(value) { - field = value - notifyRepeatModeChanged() - } - /** - * The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable. - */ + var repeatMode: RepeatMode + /** The audio session ID of the internal player. Null if no internal player exists. */ val currentAudioSessionId: Int? - get() = internalPlayer?.audioSessionId /** * Add a [Listener] to this instance. This can be used to receive changes in the playback state. @@ -87,16 +62,7 @@ class PlaybackStateManager private constructor() { * @param listener The [Listener] to add. * @see Listener */ - @Synchronized - fun addListener(listener: Listener) { - if (isInitialized) { - listener.onNewPlayback(queue, parent) - listener.onRepeatChanged(repeatMode) - listener.onStateChanged(playerState) - } - - listeners.add(listener) - } + fun addListener(listener: Listener) /** * Remove a [Listener] from this instance, preventing it from receiving any further updates. @@ -104,10 +70,7 @@ class PlaybackStateManager private constructor() { * the first place. * @see Listener */ - @Synchronized - fun removeListener(listener: Listener) { - listeners.remove(listener) - } + fun removeListener(listener: Listener) /** * Register an [InternalPlayer] for this instance. This instance will handle translating the @@ -116,42 +79,15 @@ class PlaybackStateManager private constructor() { * @param internalPlayer The [InternalPlayer] to register. Will do nothing if already * registered. */ - @Synchronized - fun registerInternalPlayer(internalPlayer: InternalPlayer) { - if (this.internalPlayer != null) { - logW("Internal player is already registered") - return - } - - 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 - } + fun registerInternalPlayer(internalPlayer: InternalPlayer) /** - * Unregister the [InternalPlayer] from this instance, prevent it from recieving any further + * Unregister the [InternalPlayer] from this instance, prevent it from receiving any further * commands. * @param internalPlayer The [InternalPlayer] to unregister. Must be the current * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. */ - @Synchronized - fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { - if (this.internalPlayer !== internalPlayer) { - logW("Given internal player did not match current internal player") - return - } - - this.internalPlayer = null - } - - // --- PLAYING FUNCTIONS --- + fun unregisterInternalPlayer(internalPlayer: InternalPlayer) /** * Start new playback. @@ -161,190 +97,81 @@ class PlaybackStateManager private constructor() { * collection of "All [Song]s". * @param shuffled Whether to shuffle or not. */ - @Synchronized - fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { - val internalPlayer = internalPlayer ?: return - // 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 - isInitialized = true - } - - // --- QUEUE FUNCTIONS --- + fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) /** * Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there is no * [Song] ahead to skip to. */ - @Synchronized - fun next() { - val internalPlayer = internalPlayer ?: return - var play = true - if (!queue.goto(queue.index + 1)) { - queue.goto(0) - play = repeatMode == RepeatMode.ALL - } - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, play) - } + fun next() /** * Go to the previous [Song] in the queue. Will rewind if there are no previous [Song]s to skip * to, or if configured to do so. */ - @Synchronized - fun prev() { - val internalPlayer = internalPlayer ?: return - - // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] - if (internalPlayer.shouldRewindWithPrev) { - rewind() - setPlaying(true) - } else { - if (!queue.goto(queue.index - 1)) { - queue.goto(0) - } - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, true) - } - } + fun prev() /** * Play a [Song] at the given position in the queue. * @param index The position of the [Song] in the queue to start playing. */ - @Synchronized - fun goto(index: Int) { - val internalPlayer = internalPlayer ?: return - if (queue.goto(index)) { - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, true) - } - } - - /** - * Add a [Song] to the top of the queue. - * @param song The [Song] to add. - */ - @Synchronized fun playNext(song: Song) = playNext(listOf(song)) + fun goto(index: Int) /** * Add [Song]s to the top of the queue. * @param songs The [Song]s to add. */ - @Synchronized - fun playNext(songs: List) { - val internalPlayer = internalPlayer ?: return - when (queue.playNext(songs)) { - Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) - Queue.ChangeResult.SONG -> { - // Enqueueing actually started a new playback session from all songs. - parent = null - internalPlayer.loadSong(queue.currentSong, true) - notifyNewPlayback() - } - Queue.ChangeResult.INDEX -> error("Unreachable") - } - } + fun playNext(songs: List) /** - * Add a [Song] to the end of the queue. + * Add a [Song] to the top of the queue. * @param song The [Song] to add. */ - @Synchronized fun addToQueue(song: Song) = addToQueue(listOf(song)) + fun playNext(song: Song) = playNext(listOf(song)) /** * Add [Song]s to the end of the queue. * @param songs The [Song]s to add. */ - @Synchronized - fun addToQueue(songs: List) { - val internalPlayer = internalPlayer ?: return - when (queue.addToQueue(songs)) { - Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) - Queue.ChangeResult.SONG -> { - // Enqueueing actually started a new playback session from all songs. - parent = null - internalPlayer.loadSong(queue.currentSong, true) - notifyNewPlayback() - } - Queue.ChangeResult.INDEX -> error("Unreachable") - } - } + fun addToQueue(songs: List) + + /** + * Add a [Song] to the end of the queue. + * @param song The [Song] to add. + */ + fun addToQueue(song: Song) = addToQueue(listOf(song)) /** * Move a [Song] in the queue. * @param src The position of the [Song] to move in the queue. * @param dst The destination position in the queue. */ - @Synchronized - fun moveQueueItem(src: Int, dst: Int) { - logD("Moving item $src to position $dst") - notifyQueueChanged(queue.move(src, dst)) - } + fun moveQueueItem(src: Int, dst: Int) /** * Remove a [Song] from the queue. * @param at The position of the [Song] to remove in the queue. */ - @Synchronized - fun removeQueueItem(at: Int) { - val internalPlayer = internalPlayer ?: return - logD("Removing item at $at") - val change = queue.remove(at) - if (change == Queue.ChangeResult.SONG) { - internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) - } - notifyQueueChanged(change) - } + fun removeQueueItem(at: Int) /** * (Re)shuffle or (Re)order this instance. * @param shuffled Whether to shuffle the queue or not. */ - @Synchronized - fun reorder(shuffled: Boolean) { - queue.reorder(shuffled) - notifyQueueReordered() - } - - // --- INTERNAL PLAYER FUNCTIONS --- + fun reorder(shuffled: Boolean) /** * 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. */ - @Synchronized - fun synchronizeState(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { - logW("Given internal player did not match current internal player") - return - } - - val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0) - if (newState != playerState) { - playerState = newState - notifyStateChanged() - } - } + fun synchronizeState(internalPlayer: InternalPlayer) /** * Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually. * @param action The [InternalPlayer.Action] to perform. */ - @Synchronized - 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") - pendingAction = action - } - } + fun startAction(action: InternalPlayer.Action) /** * Request that the pending [InternalPlayer.Action] (if any) be passed to the given @@ -352,213 +179,37 @@ class PlaybackStateManager private constructor() { * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. */ - @Synchronized - fun requestAction(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { - logW("Given internal player did not match current internal player") - return - } - - if (pendingAction?.let(internalPlayer::performAction) == true) { - logD("Pending action consumed") - pendingAction = null - } - } + fun requestAction(internalPlayer: InternalPlayer) /** * Update whether playback is ongoing or not. * @param isPlaying Whether playback is ongoing or not. */ - fun setPlaying(isPlaying: Boolean) { - internalPlayer?.setPlaying(isPlaying) - } + fun setPlaying(isPlaying: Boolean) /** * Seek to the given position in the currently playing [Song]. * @param positionMs The position to seek to, in milliseconds. */ - @Synchronized - fun seekTo(positionMs: Long) { - internalPlayer?.seekTo(positionMs) - } + fun seekTo(positionMs: Long) /** Rewind to the beginning of the currently playing [Song]. */ fun rewind() = seekTo(0) - // --- PERSISTENCE FUNCTIONS --- + /** + * Converts the current state of this instance into a [SavedState]. + * @return An immutable [SavedState] that is analogous to the current state, or null if nothing + * is currently playing. + */ + fun toSavedState(): SavedState? /** - * Restore the previously saved state (if any) and apply it to the playback state. - * @param repository The [PersistenceRepository] to load from. - * @param force Whether to do a restore regardless of any prior playback state. - * @return If the state was restored, false otherwise. + * Restores this instance from the given [SavedState]. + * @param savedState The [SavedState] to restore from. + * @param destructive Whether to disregard the prior playback state and overwrite it with this + * [SavedState]. */ - suspend fun restoreState(repository: PersistenceRepository, force: Boolean): Boolean { - if (isInitialized && !force) { - // Already initialized and not forcing a restore, nothing to do. - return false - } - - val library = musicStore.library ?: return false - val internalPlayer = internalPlayer ?: return false - val state = - try { - withContext(Dispatchers.IO) { repository.readState(library) } - } catch (e: Exception) { - logE("Unable to restore playback state.") - logE(e.stackTraceToString()) - return false - } - - // Translate the state we have just read into a usable playback state for this - // instance. - return synchronized(this) { - // State could have changed while we were loading, so check if we were initialized - // now before applying the state. - if (state != null && (!isInitialized || force)) { - parent = state.parent - queue.applySavedState(state.queueState) - repeatMode = state.repeatMode - notifyNewPlayback() - notifyRepeatModeChanged() - // Continuing playback after drastic state updates is a bad idea, so pause. - internalPlayer.loadSong(queue.currentSong, false) - internalPlayer.seekTo(state.positionMs) - isInitialized = true - true - } else { - false - } - } - } - - /** - * Save the current state. - * @param database The [PersistenceRepository] to save the state to. - * @return If state was saved, false otherwise. - */ - suspend fun saveState(database: PersistenceRepository): Boolean { - logD("Saving state to DB") - // Create the saved state from the current playback state. - val state = - synchronized(this) { - queue.toSavedState()?.let { - PersistenceRepository.SavedState( - parent = parent, - queueState = it, - positionMs = playerState.calculateElapsedPositionMs(), - repeatMode = repeatMode) - } - } - return try { - withContext(Dispatchers.IO) { database.saveState(state) } - true - } catch (e: Exception) { - logE("Unable to save playback state.") - logE(e.stackTraceToString()) - false - } - } - - /** - * Clear the current state. - * @param repository The [PersistenceRepository] to clear the state from - * @return If the state was cleared, false otherwise. - */ - suspend fun wipeState(repository: PersistenceRepository) = - try { - logD("Wiping state") - withContext(Dispatchers.IO) { repository.saveState(null) } - true - } catch (e: Exception) { - logE("Unable to wipe playback state.") - logE(e.stackTraceToString()) - false - } - - /** - * Update the playback state to align with a new [Library]. - * @param newLibrary The new [Library] that was recently loaded. - */ - @Synchronized - fun sanitize(newLibrary: Library) { - if (!isInitialized) { - // Nothing playing, nothing to do. - logD("Not initialized, no need to sanitize") - return - } - - val internalPlayer = internalPlayer ?: return - - logD("Sanitizing state") - - // While we could just save and reload the state, we instead sanitize the state - // at runtime for better performance (and to sidestep a co-routine on behalf of the caller). - - // Sanitize parent - parent = - parent?.let { - when (it) { - is Album -> newLibrary.sanitize(it) - is Artist -> newLibrary.sanitize(it) - is Genre -> newLibrary.sanitize(it) - } - } - - // Sanitize the queue. - queue.toSavedState()?.let { state -> - queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) }) - } - - notifyNewPlayback() - - val oldPosition = playerState.calculateElapsedPositionMs() - // Continuing playback while also possibly doing drastic state updates is - // a bad idea, so pause. - internalPlayer.loadSong(queue.currentSong, false) - if (queue.currentSong != null) { - // Internal player may have reloaded the media item, re-seek to the previous position - seekTo(oldPosition) - } - } - - // --- CALLBACKS --- - - private fun notifyIndexMoved() { - for (callback in listeners) { - callback.onIndexMoved(queue) - } - } - - private fun notifyQueueChanged(change: Queue.ChangeResult) { - for (callback in listeners) { - callback.onQueueChanged(queue, change) - } - } - - private fun notifyQueueReordered() { - for (callback in listeners) { - callback.onQueueReordered(queue) - } - } - - private fun notifyNewPlayback() { - for (callback in listeners) { - callback.onNewPlayback(queue, parent) - } - } - - private fun notifyStateChanged() { - for (callback in listeners) { - callback.onStateChanged(playerState) - } - } - - private fun notifyRepeatModeChanged() { - for (callback in listeners) { - callback.onRepeatChanged(repeatMode) - } - } + fun applySavedState(savedState: SavedState, destructive: Boolean) /** * The interface for receiving updates from [PlaybackStateManager]. Add the listener to @@ -606,6 +257,20 @@ class PlaybackStateManager private constructor() { fun onRepeatChanged(repeatMode: RepeatMode) {} } + /** + * A condensed representation of the playback state that can be persisted. + * @param parent The [MusicParent] item currently being played from. + * @param queueState The [Queue.SavedState] + * @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: Queue.SavedState, + val positionMs: Long, + val repeatMode: RepeatMode, + ) + companion object { @Volatile private var INSTANCE: PlaybackStateManager? = null @@ -613,7 +278,7 @@ class PlaybackStateManager private constructor() { * Get a singleton instance. * @return The (possibly newly-created) singleton instance. */ - fun getInstance(): PlaybackStateManager { + fun get(): PlaybackStateManager { val currentInstance = INSTANCE if (currentInstance != null) { @@ -621,10 +286,310 @@ class PlaybackStateManager private constructor() { } synchronized(this) { - val newInstance = PlaybackStateManager() + val newInstance = RealPlaybackStateManager() INSTANCE = newInstance return newInstance } } } } + +private class RealPlaybackStateManager : PlaybackStateManager { + private val listeners = mutableListOf() + @Volatile private var internalPlayer: InternalPlayer? = null + @Volatile private var pendingAction: InternalPlayer.Action? = null + @Volatile private var isInitialized = false + + override val queue = EditableQueue() + @Volatile + override var parent: MusicParent? = + null // FIXME: Parent is interpreted wrong when nothing is playing. + private set + @Volatile + override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) + private set + @Volatile + override var repeatMode = RepeatMode.NONE + set(value) { + field = value + notifyRepeatModeChanged() + } + override val currentAudioSessionId: Int? + get() = internalPlayer?.audioSessionId + + @Synchronized + override fun addListener(listener: PlaybackStateManager.Listener) { + if (isInitialized) { + listener.onNewPlayback(queue, parent) + listener.onRepeatChanged(repeatMode) + listener.onStateChanged(playerState) + } + + listeners.add(listener) + } + + @Synchronized + override fun removeListener(listener: PlaybackStateManager.Listener) { + listeners.remove(listener) + } + + @Synchronized + override fun registerInternalPlayer(internalPlayer: InternalPlayer) { + if (this.internalPlayer != null) { + logW("Internal player is already registered") + return + } + + 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 + override fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { + if (this.internalPlayer !== internalPlayer) { + logW("Given internal player did not match current internal player") + return + } + + this.internalPlayer = null + } + + // --- PLAYING FUNCTIONS --- + + @Synchronized + override fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { + val internalPlayer = internalPlayer ?: return + // 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 + isInitialized = true + } + + // --- QUEUE FUNCTIONS --- + + @Synchronized + override fun next() { + val internalPlayer = internalPlayer ?: return + var play = true + if (!queue.goto(queue.index + 1)) { + queue.goto(0) + play = repeatMode == RepeatMode.ALL + } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, play) + } + + @Synchronized + override fun prev() { + val internalPlayer = internalPlayer ?: return + + // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] + if (internalPlayer.shouldRewindWithPrev) { + rewind() + setPlaying(true) + } else { + if (!queue.goto(queue.index - 1)) { + queue.goto(0) + } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) + } + } + + @Synchronized + override fun goto(index: Int) { + val internalPlayer = internalPlayer ?: return + if (queue.goto(index)) { + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) + } + } + + @Synchronized + override fun playNext(songs: List) { + val internalPlayer = internalPlayer ?: return + when (queue.playNext(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() + } + Queue.ChangeResult.INDEX -> error("Unreachable") + } + } + + @Synchronized + override fun addToQueue(songs: List) { + val internalPlayer = internalPlayer ?: return + when (queue.addToQueue(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() + } + Queue.ChangeResult.INDEX -> error("Unreachable") + } + } + + @Synchronized + override fun moveQueueItem(src: Int, dst: Int) { + logD("Moving item $src to position $dst") + notifyQueueChanged(queue.move(src, dst)) + } + + @Synchronized + override fun removeQueueItem(at: Int) { + val internalPlayer = internalPlayer ?: return + logD("Removing item at $at") + val change = queue.remove(at) + if (change == Queue.ChangeResult.SONG) { + internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) + } + notifyQueueChanged(change) + } + + @Synchronized + override fun reorder(shuffled: Boolean) { + queue.reorder(shuffled) + notifyQueueReordered() + } + + // --- INTERNAL PLAYER FUNCTIONS --- + + @Synchronized + override fun synchronizeState(internalPlayer: InternalPlayer) { + if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { + logW("Given internal player did not match current internal player") + 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") + pendingAction = action + } + } + + @Synchronized + override fun requestAction(internalPlayer: InternalPlayer) { + if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { + logW("Given internal player did not match current internal player") + return + } + + if (pendingAction?.let(internalPlayer::performAction) == true) { + logD("Pending action consumed") + pendingAction = null + } + } + + @Synchronized + override fun setPlaying(isPlaying: Boolean) { + internalPlayer?.setPlaying(isPlaying) + } + + @Synchronized + override fun seekTo(positionMs: Long) { + internalPlayer?.seekTo(positionMs) + } + + // --- PERSISTENCE FUNCTIONS --- + + @Synchronized + override fun toSavedState() = + queue.toSavedState()?.let { + PlaybackStateManager.SavedState( + parent = parent, + queueState = it, + positionMs = playerState.calculateElapsedPositionMs(), + repeatMode = repeatMode) + } + + @Synchronized + override fun applySavedState( + savedState: PlaybackStateManager.SavedState, + destructive: Boolean + ) { + if (isInitialized && !destructive) { + return + } + val internalPlayer = internalPlayer ?: return + logD("Restoring state $savedState") + + parent = savedState.parent + queue.applySavedState(savedState.queueState) + repeatMode = savedState.repeatMode + notifyNewPlayback() + + // Continuing playback while also possibly doing drastic state updates is + // a bad idea, so pause. + internalPlayer.loadSong(queue.currentSong, false) + if (queue.currentSong != null) { + // Internal player may have reloaded the media item, re-seek to the previous position + seekTo(savedState.positionMs) + } + } + + // --- CALLBACKS --- + + private fun notifyIndexMoved() { + for (callback in listeners) { + callback.onIndexMoved(queue) + } + } + + private fun notifyQueueChanged(change: Queue.ChangeResult) { + for (callback in listeners) { + callback.onQueueChanged(queue, change) + } + } + + private fun notifyQueueReordered() { + for (callback in listeners) { + callback.onQueueReordered(queue) + } + } + + private fun notifyNewPlayback() { + for (callback in listeners) { + callback.onNewPlayback(queue, parent) + } + } + + private fun notifyStateChanged() { + for (callback in listeners) { + callback.onStateChanged(playerState) + } + } + + private fun notifyRepeatModeChanged() { + for (callback in listeners) { + callback.onRepeatChanged(repeatMode) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt index 090c81162..0f0b26e8e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager */ class MediaButtonReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - val playbackManager = PlaybackStateManager.getInstance() + val playbackManager = PlaybackStateManager.get() if (playbackManager.queue.currentSong != null) { // 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 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index 22bf52611..257f7f298 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -59,7 +59,7 @@ class MediaSessionComponent(private val context: Context, private val listener: setQueueTitle(context.getString(R.string.lbl_queue)) } - private val playbackManager = PlaybackStateManager.getInstance() + private val playbackManager = PlaybackStateManager.get() private val playbackSettings = PlaybackSettings.from(context) private val notification = NotificationComponent(context, mediaSession.sessionToken) 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 e063b7c99..51e542d3b 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 @@ -91,7 +91,7 @@ class PlaybackService : private val systemReceiver = PlaybackReceiver() // Managers - private val playbackManager = PlaybackStateManager.getInstance() + private val playbackManager = PlaybackStateManager.get() private val musicStore = MusicStore.getInstance() private lateinit var musicSettings: MusicSettings private lateinit var playbackSettings: PlaybackSettings @@ -333,7 +333,7 @@ class PlaybackService : // 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 { playbackManager.saveState(persistenceRepository) } + saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) } } } @@ -348,7 +348,11 @@ class PlaybackService : when (action) { // Restore state -> Start a new restoreState job is InternalPlayer.Action.RestoreState -> { - restoreScope.launch { playbackManager.restoreState(persistenceRepository, false) } + restoreScope.launch { + persistenceRepository.readState(library)?.let { + playbackManager.applySavedState(it, false) + } + } } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 18261b40b..a80ca7706 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -44,7 +44,7 @@ import org.oxycblt.auxio.util.logD */ class WidgetComponent(private val context: Context) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { - private val playbackManager = PlaybackStateManager.getInstance() + private val playbackManager = PlaybackStateManager.get() private val uiSettings = UISettings.from(context) private val imageSettings = ImageSettings.from(context) private val widgetProvider = WidgetProvider() @@ -133,7 +133,7 @@ class WidgetComponent(private val context: Context) : * @param cover A pre-loaded album cover [Bitmap] for [song]. * @param isPlaying [PlaybackStateManager.playerState] * @param repeatMode [PlaybackStateManager.repeatMode] - * @param isShuffled [PlaybackStateManager.isShuffled] + * @param isShuffled [Queue.isShuffled] */ data class PlaybackState( val song: Song,