playback: hide playbackstatemanaager impl
Make PlaybackStateManager an interface instead of a direct implementation. Part of a rework to implement dependency injection in-app.
This commit is contained in:
parent
c655f7d39e
commit
bb2ea9df27
14 changed files with 543 additions and 551 deletions
|
@ -68,25 +68,11 @@ class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
|
|||
fun sanitize(song: Song) = find<Song>(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>(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>(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>(genre.uid)
|
||||
fun <T : MusicParent> sanitize(parent: T) = find<T>(parent.uid)
|
||||
|
||||
/**
|
||||
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -73,6 +73,7 @@ abstract class PersistenceDatabase : RoomDatabase() {
|
|||
PersistenceDatabase::class.java,
|
||||
"auxio_playback_persistence.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.fallbackToDestructiveMigrationFrom(1)
|
||||
.fallbackToDestructiveMigrationOnDowngrade()
|
||||
.build()
|
||||
INSTANCE = newInstance
|
||||
|
|
|
@ -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<QueueHeapItem>
|
||||
val mapping: List<QueueMappingItem>
|
||||
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<Int>()
|
||||
val shuffledMapping = mutableListOf<Int>()
|
||||
|
@ -82,8 +78,9 @@ private class RealPersistenceRepository(private val context: Context) : Persiste
|
|||
}
|
||||
|
||||
val parent = playbackState.parentUid?.let { library.find<MusicParent>(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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Song>
|
||||
|
||||
/**
|
||||
* 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<Song?>,
|
||||
val orderedMapping: List<Int>,
|
||||
val shuffledMapping: List<Int>,
|
||||
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<Song>()
|
||||
@Volatile private var orderedMapping = mutableListOf<Int>()
|
||||
@Volatile private var shuffledMapping = mutableListOf<Int>()
|
||||
/** 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<Song>): ChangeResult {
|
||||
fun playNext(songs: List<Song>): 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<Song>): ChangeResult {
|
||||
fun addToQueue(songs: List<Song>): 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<Int?>()
|
||||
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<Song?>,
|
||||
val orderedMapping: List<Int>,
|
||||
val shuffledMapping: List<Int>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Song>())
|
||||
/** The current queue. */
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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<Listener>()
|
||||
@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<Song>, 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<Song>, 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<Song>) {
|
||||
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<Song>)
|
||||
|
||||
/**
|
||||
* 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<Song>) {
|
||||
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<Song>)
|
||||
|
||||
/**
|
||||
* 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<PlaybackStateManager.Listener>()
|
||||
@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<Song>, 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<Song>) {
|
||||
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<Song>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue