From 26d14ec6e1ab42c86c85a59e9428e91024fba96c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 7 Jan 2024 15:52:19 -0700 Subject: [PATCH] playback: ramshack initial gapless playback impl --- .../java/org/oxycblt/auxio/MainActivity.kt | 10 +- .../auxio/music/system/IndexerService.kt | 34 +- .../auxio/playback/PlaybackViewModel.kt | 105 ++-- .../playback/persist/PersistenceRepository.kt | 25 +- .../auxio/playback/queue/QueueViewModel.kt | 74 +-- .../replaygain/ReplayGainAudioProcessor.kt | 45 +- .../auxio/playback/state/InternalPlayer.kt | 183 ------- .../playback/state/PlaybackStateHolder.kt | 217 ++++++++ .../playback/state/PlaybackStateManager.kt | 468 +++++++----------- .../playback/system/BetterShuffleOrder.kt | 28 +- .../auxio/playback/system/ExoPlayerExt.kt | 177 +++++++ .../playback/system/MediaButtonReceiver.kt | 2 +- .../playback/system/MediaSessionComponent.kt | 129 ++--- .../auxio/playback/system/PlaybackService.kt | 299 +++++++---- .../oxycblt/auxio/widgets/WidgetComponent.kt | 32 +- 15 files changed, 1043 insertions(+), 785 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index c98d89cdd..18af927b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -31,7 +31,7 @@ import javax.inject.Inject import org.oxycblt.auxio.databinding.ActivityMainBinding import org.oxycblt.auxio.music.system.IndexerService import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.playback.state.InternalPlayer +import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.system.PlaybackService import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isNight @@ -76,7 +76,7 @@ class MainActivity : AppCompatActivity() { if (!startIntentAction(intent)) { // No intent action to do, just restore the previously saved state. - playbackModel.startAction(InternalPlayer.Action.RestoreState) + playbackModel.playDeferred(DeferredPlayback.RestoreState) } } @@ -137,15 +137,15 @@ class MainActivity : AppCompatActivity() { val action = when (intent.action) { - Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false) - Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll + Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false) + Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll else -> { logW("Unexpected intent ${intent.action}") return false } } logD("Translated intent to $action") - playbackModel.startAction(action) + playbackModel.playDeferred(action) return true } 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 d3cad75c6..3e506967e 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 @@ -35,7 +35,6 @@ import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingState -import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.fs.contentResolverSafe @@ -139,22 +138,23 @@ class IndexerService : logD("Music changed, updating shared objects") // Wipe possibly-invalidated outdated covers imageLoader.memoryCache?.clear() - // Clear invalid models from PlaybackStateManager. This is not connected - // to a listener as it is bad practice for a shared object to attach to - // the listener system of another. - playbackManager.toSavedState()?.let { savedState -> - playbackManager.applySavedState( - PlaybackStateManager.SavedState( - parent = - savedState.parent?.let { musicRepository.find(it.uid) as? MusicParent }, - queueState = - savedState.queueState.remap { song -> - deviceLibrary.findSong(requireNotNull(song).uid) - }, - positionMs = savedState.positionMs, - repeatMode = savedState.repeatMode), - true) - } + // // Clear invalid models from PlaybackStateManager. This is not connected + // // to a listener as it is bad practice for a shared object to attach to + // // the listener system of another. + // playbackManager.toSavedState()?.let { savedState -> + // playbackManager.applySavedState( + // PlaybackStateManager.SavedState( + // parent = + // savedState.parent?.let { musicRepository.find(it.uid) as? + // MusicParent }, + // queueState = + // savedState.queueState.remap { song -> + // deviceLibrary.findSong(requireNotNull(song).uid) + // }, + // positionMs = savedState.positionMs, + // repeatMode = savedState.repeatMode), + // true) + // } } override fun onIndexingStateChanged() { 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 e497c96ad..b12287bfc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -36,9 +36,10 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.persist.PersistenceRepository -import org.oxycblt.auxio.playback.queue.Queue -import org.oxycblt.auxio.playback.state.InternalPlayer +import org.oxycblt.auxio.playback.state.DeferredPlayback +import org.oxycblt.auxio.playback.state.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -129,51 +130,52 @@ constructor( playbackSettings.unregisterListener(this) } - override fun onIndexMoved(queue: Queue) { - logD("Index moved, updating current song") - _song.value = queue.currentSong - } - - override fun onQueueChanged(queue: Queue, change: Queue.Change) { - // Other types of queue changes preserve the current song. - if (change.type == Queue.Change.Type.SONG) { - logD("Queue changed, updating current song") - _song.value = queue.currentSong - } - } - - override fun onQueueReordered(queue: Queue) { - logD("Queue completely changed, updating current song") - _isShuffled.value = queue.isShuffled - } - - override fun onNewPlayback(queue: Queue, parent: MusicParent?) { - logD("New playback started, updating playback information") - _song.value = queue.currentSong - _parent.value = parent - _isShuffled.value = queue.isShuffled - } - - override fun onStateChanged(state: InternalPlayer.State) { - logD("Player state changed, starting new position polling") - _isPlaying.value = state.isPlaying - // Still need to update the position now due to co-routine launch delays - _positionDs.value = state.calculateElapsedPositionMs().msToDs() - // Replace the previous position co-routine with a new one that uses the new - // state information. - lastPositionJob?.cancel() - lastPositionJob = - viewModelScope.launch { - while (true) { - _positionDs.value = state.calculateElapsedPositionMs().msToDs() - // Wait a deci-second for the next position tick. - delay(100) + override fun onPlaybackEvent(event: PlaybackEvent) { + when (event) { + is PlaybackEvent.IndexMoved -> { + logD("Index moved, updating current song") + _song.value = event.currentSong + } + is PlaybackEvent.QueueChanged -> { + // Other types of queue changes preserve the current song. + if (event.change.type == QueueChange.Type.SONG) { + logD("Queue changed, updating current song") + _song.value = event.currentSong } } - } - - override fun onRepeatChanged(repeatMode: RepeatMode) { - _repeatMode.value = repeatMode + is PlaybackEvent.QueueReordered -> { + logD("Queue completely changed, updating current song") + _isShuffled.value = event.isShuffled + } + is PlaybackEvent.NewPlayback -> { + logD("New playback started, updating playback information") + _song.value = event.currentSong + _parent.value = event.parent + _isShuffled.value = event.isShuffled + } + is PlaybackEvent.ProgressionChanged -> { + logD("Progression changed, starting new position polling") + _isPlaying.value = event.progression.isPlaying + // Still need to update the position now due to co-routine launch delays + _positionDs.value = event.progression.calculateElapsedPositionMs().msToDs() + // Replace the previous position co-routine with a new one that uses the new + // state information. + lastPositionJob?.cancel() + lastPositionJob = + viewModelScope.launch { + while (true) { + _positionDs.value = + event.progression.calculateElapsedPositionMs().msToDs() + // Wait a deci-second for the next position tick. + delay(100) + } + } + } + is PlaybackEvent.RepeatModeChanged -> { + logD("Repeat mode changed, updating current mode") + _repeatMode.value = event.repeatMode + } + } } override fun onBarActionChanged() { @@ -223,8 +225,7 @@ constructor( playFromGenreImpl(song, genre, isImplicitlyShuffled()) } - private fun isImplicitlyShuffled() = - playbackManager.queue.isShuffled && playbackSettings.keepShuffle + private fun isImplicitlyShuffled() = playbackManager.isShuffled && playbackSettings.keepShuffle private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) { when (with) { @@ -416,9 +417,9 @@ constructor( * * @param action The [InternalPlayer.Action] to perform eventually. */ - fun startAction(action: InternalPlayer.Action) { + fun playDeferred(action: DeferredPlayback) { logD("Starting action $action") - playbackManager.startAction(action) + playbackManager.playDeferred(action) } // --- PLAYER FUNCTIONS --- @@ -572,13 +573,13 @@ constructor( /** Toggle [isPlaying] (i.e from playing to paused) */ fun togglePlaying() { logD("Toggling playing state") - playbackManager.setPlaying(!playbackManager.playerState.isPlaying) + playbackManager.playing(!playbackManager.progression.isPlaying) } /** Toggle [isShuffled] (ex. from on to off) */ fun toggleShuffled() { logD("Toggling shuffled state") - playbackManager.reorder(!playbackManager.queue.isShuffled) + playbackManager.reorder(!playbackManager.isShuffled) } /** @@ -588,7 +589,7 @@ constructor( */ fun toggleRepeatMode() { logD("Toggling repeat mode") - playbackManager.repeatMode = playbackManager.repeatMode.increment() + playbackManager.repeatMode(playbackManager.repeatMode.increment()) } // --- UI CONTROL --- 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 00b1a8894..4ba4423ea 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,7 +21,6 @@ package org.oxycblt.auxio.playback.persist import javax.inject.Inject import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE @@ -76,17 +75,19 @@ constructor( val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent } logD("Successfully read playback state") - return PlaybackStateManager.SavedState( - parent = parent, - queueState = - Queue.SavedState( - heap.map { deviceLibrary.findSong(it.uid) }, - orderedMapping, - shuffledMapping, - playbackState.index, - playbackState.songUid), - positionMs = playbackState.positionMs, - repeatMode = playbackState.repeatMode) + // return PlaybackStateManager.SavedState( + // parent = parent, + // queueState = + // Queue.SavedState( + // heap.map { deviceLibrary.findSong(it.uid) }, + // orderedMapping, + // shuffledMapping, + // playbackState.index, + // playbackState.songUid), + // positionMs = playbackState.positionMs, + // repeatMode = playbackState.repeatMode) + + return null } override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean { 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 5b1edce73..8ec0b3cd8 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 @@ -24,9 +24,10 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.state.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD @@ -51,7 +52,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt val scrollTo: Event get() = _scrollTo - private val _index = MutableStateFlow(playbackManager.queue.index) + private val _index = MutableStateFlow(playbackManager.resolveQueue().index) /** The index of the currently playing song in the queue. */ val index: StateFlow get() = _index @@ -60,42 +61,45 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt playbackManager.addListener(this) } - override fun onIndexMoved(queue: Queue) { - logD("Index moved, synchronizing and scrolling to new position") - _scrollTo.put(queue.index) - _index.value = queue.index - } - - override fun onQueueChanged(queue: Queue, change: Queue.Change) { - // Queue changed trivially due to item mo -> Diff queue, stay at current index. - logD("Updating queue display") - _queueInstructions.put(change.instructions) - _queue.value = queue.resolve() - if (change.type != Queue.Change.Type.MAPPING) { - // Index changed, make sure it remains updated without actually scrolling to it. - logD("Index changed with queue, synchronizing new position") - _index.value = queue.index + override fun onPlaybackEvent(event: PlaybackEvent) { + when (event) { + is PlaybackEvent.IndexMoved -> { + logD("Index moved, synchronizing and scrolling to new position") + _scrollTo.put(event.index) + _index.value = event.index + } + is PlaybackEvent.QueueChanged -> { + // Queue changed trivially due to item mo -> Diff queue, stay at current index. + logD("Updating queue display") + _queueInstructions.put(event.change.instructions) + _queue.value = event.queue.queue + if (event.change.type != QueueChange.Type.MAPPING) { + // Index changed, make sure it remains updated without actually scrolling to it. + logD("Index changed with queue, synchronizing new position") + _index.value = event.queue.index + } + } + is PlaybackEvent.QueueReordered -> { + // Queue changed completely -> Replace queue, update index + logD("Queue changed completely, replacing queue and position") + _queueInstructions.put(UpdateInstructions.Replace(0)) + _scrollTo.put(event.queue.index) + _queue.value = event.queue.queue + _index.value = event.queue.index + } + is PlaybackEvent.NewPlayback -> { + // Entirely new queue -> Replace queue, update index + logD("New playback, replacing queue and position") + _queueInstructions.put(UpdateInstructions.Replace(0)) + _scrollTo.put(event.queue.index) + _queue.value = event.queue.queue + _index.value = event.queue.index + } + is PlaybackEvent.RepeatModeChanged, + is PlaybackEvent.ProgressionChanged -> {} } } - override fun onQueueReordered(queue: Queue) { - // Queue changed completely -> Replace queue, update index - logD("Queue changed completely, replacing queue and position") - _queueInstructions.put(UpdateInstructions.Replace(0)) - _scrollTo.put(queue.index) - _queue.value = queue.resolve() - _index.value = queue.index - } - - override fun onNewPlayback(queue: Queue, parent: MusicParent?) { - // Entirely new queue -> Replace queue, update index - logD("New playback, replacing queue and position") - _queueInstructions.put(UpdateInstructions.Replace(0)) - _scrollTo.put(queue.index) - _queue.value = queue.resolve() - _index.value = queue.index - } - override fun onCleared() { super.onCleared() playbackManager.removeListener(this) 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 bd4ef874c..ff3c27d78 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 @@ -27,11 +27,11 @@ import java.nio.ByteBuffer import javax.inject.Inject import kotlin.math.pow import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.queue.Queue +import org.oxycblt.auxio.playback.state.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.util.logD /** @@ -70,26 +70,35 @@ constructor( // --- OVERRIDES --- - override fun onIndexMoved(queue: Queue) { - logD("Index moved, updating current song") - applyReplayGain(queue.currentSong) - } - - override fun onQueueChanged(queue: Queue, change: Queue.Change) { - // Other types of queue changes preserve the current song. - if (change.type == Queue.Change.Type.SONG) { - applyReplayGain(queue.currentSong) + override fun onPlaybackEvent(event: PlaybackEvent) { + when (event) { + is PlaybackEvent.IndexMoved -> { + logD("Index moved, updating current song") + applyReplayGain(event.currentSong) + } + is PlaybackEvent.QueueChanged -> { + // Queue changed trivially due to item mo -> Diff queue, stay at current index. + logD("Updating queue display") + // Other types of queue changes preserve the current song. + if (event.change.type == QueueChange.Type.SONG) { + applyReplayGain(event.currentSong) + } + } + is PlaybackEvent.NewPlayback -> { + logD("New playback started, updating playback information") + applyReplayGain(event.currentSong) + } + is PlaybackEvent.ProgressionChanged, + is PlaybackEvent.QueueReordered, + is PlaybackEvent.RepeatModeChanged -> { + // Nothing to do + } } } - override fun onNewPlayback(queue: Queue, parent: MusicParent?) { - logD("New playback started, updating playback information") - applyReplayGain(queue.currentSong) - } - override fun onReplayGainSettingsChanged() { // ReplayGain config changed, we need to set it up again. - applyReplayGain(playbackManager.queue.currentSong) + applyReplayGain(playbackManager.currentSong) } // --- REPLAYGAIN PARSING --- @@ -131,7 +140,7 @@ constructor( logD("Using dynamic strategy") gain.album?.takeIf { playbackManager.parent is Album && - playbackManager.queue.currentSong?.album == playbackManager.parent + playbackManager.currentSong?.album == playbackManager.parent } ?: gain.track } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt deleted file mode 100644 index 0980a1575..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * InternalPlayer.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback.state - -import android.net.Uri -import android.os.SystemClock -import android.support.v4.media.session.PlaybackStateCompat -import org.oxycblt.auxio.music.Song - -/** - * An interface for internal audio playback. This can be used to coordinate what occurs in the - * background playback task. - * - * @author Alexander Capehart (OxygenCobalt) - */ -interface InternalPlayer { - /** The ID of the audio session started by this instance. */ - val audioSessionId: Int - - /** Whether the player should rewind before skipping back. */ - val shouldRewindWithPrev: Boolean - - /** - * Load a new [Song] into the internal player. - * - * @param song The [Song] to load, or null if playback should stop entirely. - * @param play Whether to start playing when the [Song] is loaded. - */ - fun loadSong(song: Song?, play: Boolean) - - /** - * Called when an [Action] has been queued and this [InternalPlayer] is available to handle it. - * - * @param action The [Action] to perform. - * @return true if the action was handled, false otherwise. - */ - fun performAction(action: Action): Boolean - - /** - * Get a [State] corresponding to the current player state. - * - * @param durationMs The duration of the currently playing track, in milliseconds. Required - * since the internal player cannot obtain an accurate duration itself. - */ - fun getState(durationMs: Long): State - - /** - * Seek to a given position in the internal player. - * - * @param positionMs The position to seek to, in milliseconds. - */ - fun seekTo(positionMs: Long) - - /** - * Set whether the player should play or not. - * - * @param isPlaying Whether to play or pause the current playback. - */ - fun setPlaying(isPlaying: Boolean) - - /** Possible long-running background tasks handled by the background playback task. */ - sealed interface Action { - /** Restore the previously saved playback state. */ - data object RestoreState : Action - - /** - * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" - * shortcut. - */ - data object ShuffleAll : Action - - /** - * Start playing an audio file at the given [Uri]. - * - * @param uri The [Uri] of the audio file to start playing. - */ - data class Open(val uri: Uri) : Action - } - - /** - * A representation of the current state of audio playback. Use [from] to create an instance. - */ - class State - private constructor( - /** Whether the player is actively playing audio or set to play audio in the future. */ - val isPlaying: Boolean, - /** Whether the player is actively playing audio in this moment. */ - private val isAdvancing: Boolean, - /** The position when this instance was created, in milliseconds. */ - private val initPositionMs: Long, - /** The time this instance was created, as a unix epoch timestamp. */ - private val creationTime: Long - ) { - /** - * Calculate the "real" playback position this instance contains, in milliseconds. - * - * @return If paused, the original position will be returned. Otherwise, it will be the - * original position plus the time elapsed since this state was created. - */ - fun calculateElapsedPositionMs() = - if (isAdvancing) { - initPositionMs + (SystemClock.elapsedRealtime() - creationTime) - } else { - // Not advancing due to buffering or some unrelated pausing, such as - // a transient audio focus change. - initPositionMs - } - - /** - * Load this instance into a [PlaybackStateCompat]. - * - * @param builder The [PlaybackStateCompat.Builder] to mutate. - * @return The same [PlaybackStateCompat.Builder] for easy chaining. - */ - fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder = - builder.setState( - // State represents the user's preference, not the actual player state. - // Doing this produces a better experience in the media control UI. - if (isPlaying) { - PlaybackStateCompat.STATE_PLAYING - } else { - PlaybackStateCompat.STATE_PAUSED - }, - initPositionMs, - if (isAdvancing) { - 1f - } else { - // Not advancing, so don't move the position. - 0f - }, - creationTime) - - // Equality ignores the creation time to prevent functionally identical states - // from being non-equal. - - override fun equals(other: Any?) = - other is State && - isPlaying == other.isPlaying && - isAdvancing == other.isAdvancing && - initPositionMs == other.initPositionMs - - override fun hashCode(): Int { - var result = isPlaying.hashCode() - result = 31 * result + isAdvancing.hashCode() - result = 31 * result + initPositionMs.hashCode() - return result - } - - companion object { - /** - * Create a new instance. - * - * @param isPlaying Whether the player is actively playing audio or set to play audio in - * the future. - * @param isAdvancing Whether the player is actively playing audio in this moment. - * @param positionMs The current position of the player. - */ - fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) = - State( - isPlaying, - // Minor sanity check: Make sure that advancing can't occur if already paused. - isPlaying && isAdvancing, - positionMs, - SystemClock.elapsedRealtime()) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt new file mode 100644 index 000000000..710934a6d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2024 Auxio Project + * PlaybackStateHolder.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.state + +import android.net.Uri +import android.os.SystemClock +import android.support.v4.media.session.PlaybackStateCompat +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song + +interface PlaybackStateHolder { + val currentSong: Song? + + val repeatMode: RepeatMode + + val progression: Progression + + val audioSessionId: Int + + val parent: MusicParent? + + val isShuffled: Boolean + + fun resolveQueue(): Queue + + fun newPlayback( + queue: List, + start: Song?, + parent: MusicParent?, + shuffled: Boolean, + play: Boolean + ) + + fun playing(playing: Boolean) + + fun seekTo(positionMs: Long) + + fun repeatMode(repeatMode: RepeatMode) + + fun next() + + fun prev() + + fun goto(index: Int) + + fun playNext(songs: List) + + fun addToQueue(songs: List) + + fun move(from: Int, to: Int) + + fun remove(at: Int) + + fun reorder(shuffled: Boolean) + + fun handleDeferred(action: DeferredPlayback): Boolean +} + +/** + * Represents the possible changes that can occur during certain queue mutation events. + * + * @param type The [Type] of the change to the internal queue state. + * @param instructions The update done to the resolved queue list. + */ +data class QueueChange(val type: Type, val instructions: UpdateInstructions) { + enum class Type { + /** Only the mapping has changed. */ + MAPPING, + + /** The mapping has changed, and the index also changed to align with it. */ + INDEX, + + /** + * The current song has changed, possibly alongside the mapping and index depending on the + * context. + */ + SONG + } +} + +/** Possible long-running background tasks handled by the background playback task. */ +sealed interface DeferredPlayback { + /** Restore the previously saved playback state. */ + data object RestoreState : DeferredPlayback + + /** + * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut. + */ + data object ShuffleAll : DeferredPlayback + + /** + * Start playing an audio file at the given [Uri]. + * + * @param uri The [Uri] of the audio file to start playing. + */ + data class Open(val uri: Uri) : DeferredPlayback +} + +data class Queue(val index: Int, val queue: List) { + companion object { + fun nil() = Queue(-1, emptyList()) + } +} + +data class SavedQueue( + val heap: List, + val orderedMapping: List, + val shuffledMapping: List, + val index: Int, + val songUid: Music.UID, +) + +/** A representation of the current state of audio playback. Use [from] to create an instance. */ +class Progression +private constructor( + /** Whether the player is actively playing audio or set to play audio in the future. */ + val isPlaying: Boolean, + /** Whether the player is actively playing audio in this moment. */ + private val isAdvancing: Boolean, + /** The position when this instance was created, in milliseconds. */ + private val initPositionMs: Long, + /** The time this instance was created, as a unix epoch timestamp. */ + private val creationTime: Long +) { + /** + * Calculate the "real" playback position this instance contains, in milliseconds. + * + * @return If paused, the original position will be returned. Otherwise, it will be the original + * position plus the time elapsed since this state was created. + */ + fun calculateElapsedPositionMs() = + if (isAdvancing) { + initPositionMs + (SystemClock.elapsedRealtime() - creationTime) + } else { + // Not advancing due to buffering or some unrelated pausing, such as + // a transient audio focus change. + initPositionMs + } + + /** + * Load this instance into a [PlaybackStateCompat]. + * + * @param builder The [PlaybackStateCompat.Builder] to mutate. + * @return The same [PlaybackStateCompat.Builder] for easy chaining. + */ + fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder = + builder.setState( + // State represents the user's preference, not the actual player state. + // Doing this produces a better experience in the media control UI. + if (isPlaying) { + PlaybackStateCompat.STATE_PLAYING + } else { + PlaybackStateCompat.STATE_PAUSED + }, + initPositionMs, + if (isAdvancing) { + 1f + } else { + // Not advancing, so don't move the position. + 0f + }, + creationTime) + + // Equality ignores the creation time to prevent functionally identical states + // from being non-equal. + + override fun equals(other: Any?) = + other is Progression && + isPlaying == other.isPlaying && + isAdvancing == other.isAdvancing && + initPositionMs == other.initPositionMs + + override fun hashCode(): Int { + var result = isPlaying.hashCode() + result = 31 * result + isAdvancing.hashCode() + result = 31 * result + initPositionMs.hashCode() + return result + } + + companion object { + /** + * Create a new instance. + * + * @param isPlaying Whether the player is actively playing audio or set to play audio in the + * future. + * @param isAdvancing Whether the player is actively playing audio in this moment. + * @param positionMs The current position of the player. + */ + fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) = + Progression( + isPlaying, + // Minor sanity check: Make sure that advancing can't occur if already paused. + isPlaying && isAdvancing, + positionMs, + SystemClock.elapsedRealtime()) + + fun nil() = Progression(false, false, 0, SystemClock.elapsedRealtime()) + } +} 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 870d7a84e..d33c935ba 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 @@ -22,8 +22,6 @@ import javax.inject.Inject import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.queue.MutableQueue -import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -40,19 +38,26 @@ import org.oxycblt.auxio.util.logW * PlaybackService. * * Internal consumers should usually use [Listener], however the component that manages the player - * itself should instead use [InternalPlayer]. + * itself should instead use [PlaybackStateHolder]. * * @author Alexander Capehart (OxygenCobalt) */ interface PlaybackStateManager { - /** The current [Queue]. */ - val queue: Queue - /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ + /** The current [Progression] state. */ + val progression: Progression + + val currentSong: Song? + + val repeatMode: RepeatMode + + val audioSessionId: Int + val parent: MusicParent? - /** The current [InternalPlayer] state. */ - val playerState: InternalPlayer.State - /** The current [RepeatMode] */ - var repeatMode: RepeatMode + + val isShuffled: Boolean + + fun resolveQueue(): Queue + /** The audio session ID of the internal player. Null if no internal player exists. */ val currentAudioSessionId: Int? @@ -75,23 +80,25 @@ interface PlaybackStateManager { fun removeListener(listener: Listener) /** - * Register an [InternalPlayer] for this instance. This instance will handle translating the - * current playback state into audio playback. There can be only one [InternalPlayer] at a time. - * Will invoke [InternalPlayer] methods to initialize the instance with the current state. + * Register an [PlaybackStateHolder] for this instance. This instance will handle translating + * the current playback state into audio playback. There can be only one [PlaybackStateHolder] + * at a time. Will invoke [PlaybackStateHolder] methods to initialize the instance with the + * current state. * - * @param internalPlayer The [InternalPlayer] to register. Will do nothing if already + * @param stateHolder The [PlaybackStateHolder] to register. Will do nothing if already * registered. */ - fun registerInternalPlayer(internalPlayer: InternalPlayer) + fun registerStateHolder(stateHolder: PlaybackStateHolder) /** - * Unregister the [InternalPlayer] from this instance, prevent it from receiving any further - * commands. + * Unregister the [PlaybackStateHolder] 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. + * @param stateHolder The [PlaybackStateHolder] to unregister. Must be the current + * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder] + * implementation. */ - fun unregisterInternalPlayer(internalPlayer: InternalPlayer) + fun unregisterStateHolder(stateHolder: PlaybackStateHolder) /** * Start new playback. @@ -173,36 +180,33 @@ interface PlaybackStateManager { */ 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. - */ - fun synchronizeState(internalPlayer: InternalPlayer) + fun dispatchEvent(stateHolder: PlaybackStateHolder, vararg events: PlaybackEvent) /** - * Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually. + * Start a [DeferredPlayback] for the current [PlaybackStateHolder] to handle eventually. * - * @param action The [InternalPlayer.Action] to perform. + * @param action The [DeferredPlayback] to perform. */ - fun startAction(action: InternalPlayer.Action) + fun playDeferred(action: DeferredPlayback) /** - * Request that the pending [InternalPlayer.Action] (if any) be passed to the given - * [InternalPlayer]. + * Request that the pending [PlaybackStateHolder.Action] (if any) be passed to the given + * [PlaybackStateHolder]. * - * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current - * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. + * @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current + * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder] + * implementation. */ - fun requestAction(internalPlayer: InternalPlayer) + fun requestAction(stateHolder: PlaybackStateHolder) /** * Update whether playback is ongoing or not. * * @param isPlaying Whether playback is ongoing or not. */ - fun setPlaying(isPlaying: Boolean) + fun playing(isPlaying: Boolean) + + fun repeatMode(repeatMode: RepeatMode) /** * Seek to the given position in the currently playing [Song]. @@ -236,103 +240,78 @@ interface PlaybackStateManager { * [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener]. */ interface Listener { - /** - * Called when the position of the currently playing item has changed, changing the current - * [Song], but no other queue attribute has changed. - * - * @param queue The new [Queue]. - */ - fun onIndexMoved(queue: Queue) {} - - /** - * Called when the [Queue] changed in a manner outlined by the given [Queue.Change]. - * - * @param queue The new [Queue]. - * @param change The type of [Queue.Change] that occurred. - */ - fun onQueueChanged(queue: Queue, change: Queue.Change) {} - - /** - * Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but - * the currently playing [Song] has not. - * - * @param queue The new [Queue]. - */ - fun onQueueReordered(queue: Queue) {} - - /** - * Called when a new playback configuration was created. - * - * @param queue The new [Queue]. - * @param parent The new [MusicParent] being played from, or null if playing from all songs. - */ - fun onNewPlayback(queue: Queue, parent: MusicParent?) {} - - /** - * Called when the state of the [InternalPlayer] changes. - * - * @param state The new state of the [InternalPlayer]. - */ - fun onStateChanged(state: InternalPlayer.State) {} - - /** - * Called when the [RepeatMode] changes. - * - * @param repeatMode The new [RepeatMode]. - */ - fun onRepeatChanged(repeatMode: RepeatMode) {} + fun onPlaybackEvent(event: PlaybackEvent) } /** * 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 queueState The [SavedQueue] * @param positionMs The current position in the currently played song, in ms * @param repeatMode The current [RepeatMode]. */ data class SavedState( val parent: MusicParent?, - val queueState: Queue.SavedState, + val queueState: SavedQueue, val positionMs: Long, val repeatMode: RepeatMode, ) } +sealed interface PlaybackEvent { + class IndexMoved(val currentSong: Song?, val index: Int) : PlaybackEvent + + class QueueChanged(val currentSong: Song?, val queue: Queue, val change: QueueChange) : + PlaybackEvent + + class QueueReordered(val queue: Queue, val isShuffled: Boolean) : PlaybackEvent + + class NewPlayback( + val currentSong: Song, + val queue: Queue, + val parent: MusicParent?, + val isShuffled: Boolean, + ) : PlaybackEvent + + class ProgressionChanged(val progression: Progression) : PlaybackEvent + + class RepeatModeChanged(val repeatMode: RepeatMode) : PlaybackEvent +} + class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { private val listeners = mutableListOf() - @Volatile private var internalPlayer: InternalPlayer? = null - @Volatile private var pendingAction: InternalPlayer.Action? = null + @Volatile private var stateHolder: PlaybackStateHolder? = null + @Volatile private var pendingDeferredPlayback: DeferredPlayback? = null @Volatile private var isInitialized = false - override val queue = MutableQueue() - @Volatile - override var parent: MusicParent? = null - private set + /** The current [Progression] state. */ + override val progression: Progression + get() = stateHolder?.progression ?: Progression.nil() - @Volatile - override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) - private set + override val currentSong: Song? + get() = stateHolder?.currentSong - @Volatile - override var repeatMode = RepeatMode.NONE - set(value) { - field = value - notifyRepeatModeChanged() - } + override val repeatMode: RepeatMode + get() = stateHolder?.repeatMode ?: RepeatMode.NONE + + override val audioSessionId: Int + get() = stateHolder?.audioSessionId ?: -1 + + override val parent: MusicParent? + get() = stateHolder?.parent + + override val isShuffled: Boolean + get() = stateHolder?.isShuffled ?: false + + override fun resolveQueue() = stateHolder?.resolveQueue() ?: Queue.nil() override val currentAudioSessionId: Int? - get() = internalPlayer?.audioSessionId + get() = stateHolder?.audioSessionId @Synchronized override fun addListener(listener: PlaybackStateManager.Listener) { logD("Adding $listener to listeners") - if (isInitialized) { - listener.onNewPlayback(queue, parent) - listener.onRepeatChanged(repeatMode) - listener.onStateChanged(playerState) - } - listeners.add(listener) } @@ -345,286 +324,211 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } @Synchronized - override fun registerInternalPlayer(internalPlayer: InternalPlayer) { - if (this.internalPlayer != null) { + override fun registerStateHolder(stateHolder: PlaybackStateHolder) { + if (this.stateHolder != null) { logW("Internal player is already registered") return } - logD("Registering internal player $internalPlayer") - - 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 + this.stateHolder = stateHolder } @Synchronized - override fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { - if (this.internalPlayer !== internalPlayer) { + override fun unregisterStateHolder(stateHolder: PlaybackStateHolder) { + if (this.stateHolder !== stateHolder) { logW("Given internal player did not match current internal player") return } - logD("Unregistering internal player $internalPlayer") + logD("Unregistering internal player $stateHolder") - this.internalPlayer = null + this.stateHolder = null } // --- PLAYING FUNCTIONS --- @Synchronized override fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { - val internalPlayer = internalPlayer ?: return + val stateHolder = stateHolder ?: return logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]") - // Set up parent and queue - this.parent = parent - this.queue.start(song, queue, shuffled) - // Notify components of changes - notifyNewPlayback() - internalPlayer.loadSong(this.queue.currentSong, true) // Played something, so we are initialized now isInitialized = true + stateHolder.newPlayback(queue, song, parent, shuffled, 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 - logD("At end of queue, wrapping around to position 0 [play=$play]") - } else { - logD("Moving to next song") - } - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, play) + val stateHolder = stateHolder ?: return + logD("Going to next song") + stateHolder.next() } @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) { - logD("Rewinding current song") - rewind() - setPlaying(true) - } else { - logD("Moving to previous song") - if (!queue.goto(queue.index - 1)) { - queue.goto(0) - } - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, true) - } + val stateHolder = stateHolder ?: return + logD("Going to previous song") + stateHolder.prev() } @Synchronized override fun goto(index: Int) { - val internalPlayer = internalPlayer ?: return - if (queue.goto(index)) { - logD("Moving to $index") - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, true) - } else { - logW("$index was not in bounds, could not move to it") - } + val stateHolder = stateHolder ?: return + logD("Going to index $index") + stateHolder.goto(index) } @Synchronized override fun playNext(songs: List) { - if (queue.currentSong == null) { + if (currentSong == null) { logD("Nothing playing, short-circuiting to new playback") play(songs[0], null, songs, false) } else { + val stateHolder = stateHolder ?: return logD("Adding ${songs.size} songs to start of queue") - notifyQueueChanged(queue.addToTop(songs)) + stateHolder.playNext(songs) } } @Synchronized override fun addToQueue(songs: List) { - if (queue.currentSong == null) { + if (currentSong == null) { logD("Nothing playing, short-circuiting to new playback") play(songs[0], null, songs, false) } else { + val stateHolder = stateHolder ?: return logD("Adding ${songs.size} songs to end of queue") - notifyQueueChanged(queue.addToBottom(songs)) + stateHolder.addToQueue(songs) } } @Synchronized override fun moveQueueItem(src: Int, dst: Int) { + val stateHolder = stateHolder ?: return logD("Moving item $src to position $dst") - notifyQueueChanged(queue.move(src, dst)) + stateHolder.move(src, dst) } @Synchronized override fun removeQueueItem(at: Int) { - val internalPlayer = internalPlayer ?: return + val stateHolder = stateHolder ?: return logD("Removing item at $at") - val change = queue.remove(at) - if (change.type == Queue.Change.Type.SONG) { - internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) - } - notifyQueueChanged(change) + stateHolder.remove(at) } @Synchronized override fun reorder(shuffled: Boolean) { + val stateHolder = stateHolder ?: return logD("Reordering queue [shuffled=$shuffled]") - queue.reorder(shuffled) - notifyQueueReordered() + stateHolder.reorder(shuffled) } // --- 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)) { + override fun playDeferred(action: DeferredPlayback) { + val stateHolder = stateHolder + if (stateHolder == null || !stateHolder.handleDeferred(action)) { logD("Internal player not present or did not consume action, waiting") - pendingAction = action + pendingDeferredPlayback = action } } @Synchronized - override fun requestAction(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { + override fun requestAction(stateHolder: PlaybackStateHolder) { + if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) { logW("Given internal player did not match current internal player") return } - if (pendingAction?.let(internalPlayer::performAction) == true) { + if (pendingDeferredPlayback?.let(stateHolder::handleDeferred) == true) { logD("Pending action consumed") - pendingAction = null + pendingDeferredPlayback = null } } @Synchronized - override fun setPlaying(isPlaying: Boolean) { + override fun playing(isPlaying: Boolean) { + val stateHolder = stateHolder ?: return logD("Updating playing state to $isPlaying") - internalPlayer?.setPlaying(isPlaying) + stateHolder.playing(isPlaying) + } + + @Synchronized + override fun repeatMode(repeatMode: RepeatMode) { + val stateHolder = stateHolder ?: return + logD("Updating repeat mode to $repeatMode") + stateHolder.repeatMode(repeatMode) } @Synchronized override fun seekTo(positionMs: Long) { + val stateHolder = stateHolder ?: return logD("Seeking to ${positionMs}ms") - internalPlayer?.seekTo(positionMs) + stateHolder.seekTo(positionMs) + } + + @Synchronized + override fun dispatchEvent(stateHolder: PlaybackStateHolder, vararg events: PlaybackEvent) { + if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) { + logW("Given internal player did not match current internal player") + return + } + + events.forEach { event -> + logD("Dispatching event $event") + listeners.forEach { it.onPlaybackEvent(event) } + } } // --- PERSISTENCE FUNCTIONS --- - @Synchronized - override fun toSavedState() = - queue.toSavedState()?.let { - PlaybackStateManager.SavedState( - parent = parent, - queueState = it, - positionMs = playerState.calculateElapsedPositionMs(), - repeatMode = repeatMode) - } + @Synchronized override fun toSavedState() = null + // queue.toSavedState()?.let { + // PlaybackStateManager.SavedState( + // parent = parent, + // queueState = it, + // positionMs = progression.calculateElapsedPositionMs(), + // repeatMode = repeatMode) + // } @Synchronized override fun applySavedState( savedState: PlaybackStateManager.SavedState, destructive: Boolean ) { - if (isInitialized && !destructive) { - logW("Already initialized, cannot apply saved state") - return - } - val internalPlayer = internalPlayer ?: return - logD("Applying state $savedState") - - val lastSong = queue.currentSong - parent = savedState.parent - queue.applySavedState(savedState.queueState) - repeatMode = savedState.repeatMode - notifyNewPlayback() - - // Check if we need to reload the player with a new music file, or if we can just leave - // it be. Specifically done so we don't pause on music updates that don't really change - // what's playing (ex. playlist editing) - if (lastSong != queue.currentSong) { - logD("Song changed, must reload player") - // Continuing playback while also possibly doing drastic state updates is - // a bad idea, so pause. - internalPlayer.loadSong(queue.currentSong, false) - if (queue.currentSong != null) { - logD("Seeking to saved position ${savedState.positionMs}ms") - // Internal player may have reloaded the media item, re-seek to the previous - // position - seekTo(savedState.positionMs) - } - } + // if (isInitialized && !destructive) { + // logW("Already initialized, cannot apply saved state") + // return + // } + // val stateHolder = stateHolder ?: return + // logD("Applying state $savedState") + // + // val lastSong = queue.currentSong + // parent = savedState.parent + // queue.applySavedState(savedState.queueState) + // repeatMode = savedState.repeatMode + // notifyNewPlayback() + // + // // Check if we need to reload the player with a new music file, or if we can just + // leave + // // it be. Specifically done so we don't pause on music updates that don't really + // change + // // what's playing (ex. playlist editing) + // if (lastSong != queue.currentSong) { + // logD("Song changed, must reload player") + // // Continuing playback while also possibly doing drastic state updates is + // // a bad idea, so pause. + // stateHolder.loadSong(queue.currentSong, false) + // if (queue.currentSong != null) { + // logD("Seeking to saved position ${savedState.positionMs}ms") + // // Internal player may have reloaded the media item, re-seek to the + // previous + // // position + // seekTo(savedState.positionMs) + // } + // } isInitialized = true } - - // --- CALLBACKS --- - - private fun notifyIndexMoved() { - logD("Dispatching index change") - for (callback in listeners) { - callback.onIndexMoved(queue) - } - } - - private fun notifyQueueChanged(change: Queue.Change) { - logD("Dispatching queue change $change") - for (callback in listeners) { - callback.onQueueChanged(queue, change) - } - } - - private fun notifyQueueReordered() { - logD("Dispatching queue reordering") - for (callback in listeners) { - callback.onQueueReordered(queue) - } - } - - private fun notifyNewPlayback() { - logD("Dispatching new playback") - for (callback in listeners) { - callback.onNewPlayback(queue, parent) - } - } - - private fun notifyStateChanged() { - logD("Dispatching player state change") - for (callback in listeners) { - callback.onStateChanged(playerState) - } - } - - private fun notifyRepeatModeChanged() { - logD("Dispatching repeat mode change") - for (callback in listeners) { - callback.onRepeatChanged(repeatMode) - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt index 233e490c3..96ed71028 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt @@ -29,18 +29,10 @@ import java.util.* * * @author media3 team, Alexander Capehart (OxygenCobalt) */ -class BetterShuffleOrder -private constructor(private val shuffled: IntArray, private val random: Random) : ShuffleOrder { +class BetterShuffleOrder private constructor(private val shuffled: IntArray) : ShuffleOrder { private val indexInShuffled: IntArray = IntArray(shuffled.size) - /** - * Creates an instance with a specified length. - * - * @param length The length of the shuffle order. - */ - constructor(length: Int) : this(length, Random()) - - constructor(length: Int, random: Random) : this(createShuffledList(length, random), random) + constructor(length: Int, startIndex: Int) : this(createShuffledList(length, startIndex)) init { for (i in shuffled.indices) { @@ -88,7 +80,7 @@ private constructor(private val shuffled: IntArray, private val random: Random) for (i in 0 until insertionCount) { newShuffled[pivot + i + 1] = insertionIndex + i + 1 } - return BetterShuffleOrder(newShuffled, Random(random.nextLong())) + return BetterShuffleOrder(newShuffled) } override fun cloneAndRemove(indexFrom: Int, indexToExclusive: Int): ShuffleOrder { @@ -104,21 +96,27 @@ private constructor(private val shuffled: IntArray, private val random: Random) else shuffled[i] } } - return BetterShuffleOrder(newShuffled, Random(random.nextLong())) + return BetterShuffleOrder(newShuffled) } override fun cloneAndClear(): ShuffleOrder { - return BetterShuffleOrder(0, Random(random.nextLong())) + return BetterShuffleOrder(0, -1) } companion object { - private fun createShuffledList(length: Int, random: Random): IntArray { + private fun createShuffledList(length: Int, startIndex: Int): IntArray { val shuffled = IntArray(length) for (i in 0 until length) { - val swapIndex = random.nextInt(i + 1) + val swapIndex = (0..i).random() shuffled[i] = shuffled[swapIndex] shuffled[swapIndex] = i } + if (startIndex != -1) { + val startIndexInShuffled = shuffled.indexOf(startIndex) + val temp = shuffled[0] + shuffled[0] = shuffled[startIndexInShuffled] + shuffled[startIndexInShuffled] = temp + } return shuffled } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt new file mode 100644 index 000000000..db841a9a6 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2024 Auxio Project + * ExoPlayerExt.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.system + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.util.logD + +val ExoPlayer.song + get() = currentMediaItem?.song + +fun ExoPlayer.orderedQueue(queue: Collection, start: Song?) { + clearMediaItems() + shuffleModeEnabled = false + setShuffleOrder(DefaultShuffleOrder(mediaItemCount)) + setMediaItems(queue.map { it.toMediaItem() }) + val startIndex = queue.indexOf(start) + if (startIndex != -1) { + seekTo(startIndex, C.TIME_UNSET) + } else { + throw IllegalArgumentException("Start song not in queue") + } +} + +fun ExoPlayer.shuffledQueue(queue: Collection, start: Song?) { + // A fun thing about ShuffleOrder is that ExoPlayer will use cloneAndInsert to both add + // MediaItems AND repopulate MediaItems (?!?!?!?!). As a result, we have to use the default + // shuffle order and it's stupid cloneAndInsert implementation to add the songs, and then + // switch back to our implementation that actually works in normal use. + setShuffleOrder(DefaultShuffleOrder(mediaItemCount)) + setMediaItems(queue.map { it.toMediaItem() }) + shuffleModeEnabled = true + val startIndex = + if (start != null) { + queue.indexOf(start).also { check(it != -1) { "Start song not in queue" } } + } else { + -1 + } + setShuffleOrder(BetterShuffleOrder(queue.size, startIndex)) + seekTo(currentTimeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET) +} + +val ExoPlayer.currentIndex: Int + get() { + val queue = unscrambleQueue { index -> index } + if (queue.isEmpty()) { + return C.INDEX_UNSET + } + + return queue.indexOf(currentMediaItemIndex) + } + +val ExoPlayer.repeat: RepeatMode + get() = + when (repeatMode) { + Player.REPEAT_MODE_OFF -> RepeatMode.NONE + Player.REPEAT_MODE_ONE -> RepeatMode.TRACK + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") + } + +fun ExoPlayer.reorder(shuffled: Boolean) { + logD("Reordering queue to $shuffled") + if (shuffled) { + // Have to manually refresh the shuffle seed. + setShuffleOrder(BetterShuffleOrder(mediaItemCount, currentMediaItemIndex)) + } + shuffleModeEnabled = shuffled +} + +fun ExoPlayer.playNext(songs: List) { + addMediaItems(nextMediaItemIndex, songs.map { it.toMediaItem() }) +} + +fun ExoPlayer.addToQueue(songs: List) { + addMediaItems(songs.map { it.toMediaItem() }) +} + +fun ExoPlayer.goto(index: Int) { + val queue = unscrambleQueue { index -> index } + if (queue.isEmpty()) { + return + } + + val trueIndex = queue[index] + seekTo(trueIndex, C.TIME_UNSET) +} + +fun ExoPlayer.move(from: Int, to: Int) { + val queue = unscrambleQueue { index -> index } + if (queue.isEmpty()) { + return + } + + val trueFrom = queue[from] + val trueTo = queue[to] + moveMediaItem(trueFrom, trueTo) +} + +fun ExoPlayer.remove(at: Int) { + val queue = unscrambleQueue { index -> index } + if (queue.isEmpty()) { + return + } + + val trueIndex = queue[at] + removeMediaItem(trueIndex) +} + +fun ExoPlayer.resolveQueue(): List { + return unscrambleQueue { index -> getMediaItemAt(index).song } +} + +inline fun ExoPlayer.unscrambleQueue(mapper: (Int) -> T): List { + val timeline = currentTimeline + if (timeline.isEmpty()) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(mapper(currentMediaItemIndex)) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(mapper(lastMediaItemIndex)) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, mapper(firstMediaItemIndex)) + } + } + } + + return queue +} + +fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build() + +private val MediaItem.song: Song + get() = requireNotNull(localConfiguration).tag as Song 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 65192cf10..83b6bcbcd 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 @@ -39,7 +39,7 @@ class MediaButtonReceiver : BroadcastReceiver() { // TODO: Figure this out override fun onReceive(context: Context, intent: Intent) { - if (playbackManager.queue.currentSong != null) { + if (playbackManager.currentSong != null) { // We have a song, so we can assume that the service will start a foreground state. // At least, I hope. Again, *this is why we don't do this*. I cannot describe how // stupid this is with the state of foreground services on modern android. One 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 1910b1a01..57ce5d483 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 @@ -38,9 +38,10 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.queue.Queue -import org.oxycblt.auxio.playback.state.InternalPlayer +import org.oxycblt.auxio.playback.state.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Queue +import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD @@ -117,65 +118,65 @@ constructor( // --- PLAYBACKSTATEMANAGER OVERRIDES --- - override fun onIndexMoved(queue: Queue) { - updateMediaMetadata(queue.currentSong, playbackManager.parent) - invalidateSessionState() - } + override fun onPlaybackEvent(event: PlaybackEvent) { + when (event) { + is PlaybackEvent.IndexMoved -> { + updateMediaMetadata(event.currentSong, playbackManager.parent) + invalidateSessionState() + } + is PlaybackEvent.QueueChanged -> { + updateQueue(event.queue) + when (event.change.type) { + // Nothing special to do with mapping changes. + QueueChange.Type.MAPPING -> {} + // Index changed, ensure playback state's index changes. + QueueChange.Type.INDEX -> invalidateSessionState() + // Song changed, ensure metadata changes. + QueueChange.Type.SONG -> + updateMediaMetadata(event.currentSong, playbackManager.parent) + } + } + is PlaybackEvent.QueueReordered -> { + updateQueue(event.queue) + invalidateSessionState() + mediaSession.setShuffleMode( + if (event.isShuffled) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + }) + invalidateSecondaryAction() + } + is PlaybackEvent.NewPlayback -> { + updateMediaMetadata(event.currentSong, event.parent) + updateQueue(event.queue) + invalidateSessionState() + } + is PlaybackEvent.ProgressionChanged -> { + invalidateSessionState() + notification.updatePlaying(playbackManager.progression.isPlaying) + if (!bitmapProvider.isBusy) { + listener?.onPostNotification(notification) + } + } + is PlaybackEvent.RepeatModeChanged -> { + mediaSession.setRepeatMode( + when (event.repeatMode) { + RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE + RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE + RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL + }) - override fun onQueueChanged(queue: Queue, change: Queue.Change) { - updateQueue(queue) - when (change.type) { - // Nothing special to do with mapping changes. - Queue.Change.Type.MAPPING -> {} - // Index changed, ensure playback state's index changes. - Queue.Change.Type.INDEX -> invalidateSessionState() - // Song changed, ensure metadata changes. - Queue.Change.Type.SONG -> updateMediaMetadata(queue.currentSong, playbackManager.parent) + invalidateSecondaryAction() + } } } - override fun onQueueReordered(queue: Queue) { - updateQueue(queue) - invalidateSessionState() - mediaSession.setShuffleMode( - if (queue.isShuffled) { - PlaybackStateCompat.SHUFFLE_MODE_ALL - } else { - PlaybackStateCompat.SHUFFLE_MODE_NONE - }) - invalidateSecondaryAction() - } - - override fun onNewPlayback(queue: Queue, parent: MusicParent?) { - updateMediaMetadata(queue.currentSong, parent) - updateQueue(queue) - invalidateSessionState() - } - - override fun onStateChanged(state: InternalPlayer.State) { - invalidateSessionState() - notification.updatePlaying(playbackManager.playerState.isPlaying) - if (!bitmapProvider.isBusy) { - listener?.onPostNotification(notification) - } - } - - override fun onRepeatChanged(repeatMode: RepeatMode) { - mediaSession.setRepeatMode( - when (repeatMode) { - RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE - RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE - RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL - }) - - invalidateSecondaryAction() - } - // --- SETTINGS OVERRIDES --- override fun onImageSettingsChanged() { // Need to reload the metadata cover. - updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) } override fun onNotificationActionChanged() { @@ -211,11 +212,11 @@ constructor( } override fun onPlay() { - playbackManager.setPlaying(true) + playbackManager.playing(true) } override fun onPause() { - playbackManager.setPlaying(false) + playbackManager.playing(false) } override fun onSkipToNext() { @@ -236,17 +237,17 @@ constructor( override fun onRewind() { playbackManager.rewind() - playbackManager.setPlaying(true) + playbackManager.playing(true) } override fun onSetRepeatMode(repeatMode: Int) { - playbackManager.repeatMode = + playbackManager.repeatMode( when (repeatMode) { PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK else -> RepeatMode.NONE - } + }) } override fun onSetShuffleMode(shuffleMode: Int) { @@ -356,7 +357,7 @@ constructor( */ private fun updateQueue(queue: Queue) { val queueItems = - queue.resolve().mapIndexed { i, song -> + queue.queue.mapIndexed { i, song -> val description = MediaDescriptionCompat.Builder() // Media ID should not be the item index but rather the UID, @@ -381,13 +382,15 @@ constructor( private fun invalidateSessionState() { logD("Updating media session playback state") + val queue = playbackManager.resolveQueue() + val state = // InternalPlayer.State handles position/state information. - playbackManager.playerState + playbackManager.progression .intoPlaybackState(PlaybackStateCompat.Builder()) .setActions(ACTIONS) // Active queue ID corresponds to the indices we populated prior, use them here. - .setActiveQueueItemId(playbackManager.queue.index.toLong()) + .setActiveQueueItemId(queue.index.toLong()) // Android 13+ relies on custom actions in the notification. @@ -399,7 +402,7 @@ constructor( PlaybackStateCompat.CustomAction.Builder( PlaybackService.ACTION_INVERT_SHUFFLE, context.getString(R.string.desc_shuffle), - if (playbackManager.queue.isShuffled) { + if (playbackManager.isShuffled) { R.drawable.ic_shuffle_on_24 } else { R.drawable.ic_shuffle_off_24 @@ -435,7 +438,7 @@ constructor( when (playbackSettings.notificationAction) { ActionMode.SHUFFLE -> { logD("Using shuffle notification action") - notification.updateShuffled(playbackManager.queue.isShuffled) + notification.updateShuffled(playbackManager.isShuffled) } else -> { logD("Using repeat mode notification action") 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 7ac1c66cf..349c8ca19 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -48,13 +48,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor -import org.oxycblt.auxio.playback.state.InternalPlayer +import org.oxycblt.auxio.playback.state.DeferredPlayback +import org.oxycblt.auxio.playback.state.PlaybackEvent +import org.oxycblt.auxio.playback.state.PlaybackStateHolder import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression +import org.oxycblt.auxio.playback.state.Queue +import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.util.logD @@ -82,7 +89,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider class PlaybackService : Service(), Player.Listener, - InternalPlayer, + PlaybackStateHolder, MediaSessionComponent.Listener, MusicRepository.UpdateListener { // Player components @@ -148,7 +155,7 @@ class PlaybackService : foregroundManager = ForegroundManager(this) // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. - playbackManager.registerInternalPlayer(this) + playbackManager.registerStateHolder(this) musicRepository.addUpdateListener(this) mediaSessionComponent.registerListener(this) @@ -189,8 +196,8 @@ class PlaybackService : foregroundManager.release() // Pause just in case this destruction was unexpected. - playbackManager.setPlaying(false) - playbackManager.unregisterInternalPlayer(this) + playbackManager.playing(false) + playbackManager.unregisterStateHolder(this) musicRepository.removeUpdateListener(this) unregisterReceiver(systemReceiver) @@ -210,101 +217,221 @@ class PlaybackService : logD("Service destroyed") } - // --- CONTROLLER OVERRIDES --- + // --- PLAYBACKSTATEHOLDER OVERRIDES --- + + override val currentSong + get() = player.song + + override val repeatMode + get() = player.repeat + + override val progression: Progression + get() = + player.song?.let { + Progression.from( + player.playWhenReady, + player.isPlaying, + // The position value can be below zero or past the expected duration, make + // sure we handle that. + player.currentPosition.coerceAtLeast(0).coerceAtMost(it.durationMs)) + } + ?: Progression.nil() override val audioSessionId: Int get() = player.audioSessionId - override val shouldRewindWithPrev: Boolean - get() = playbackSettings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD + override var parent: MusicParent? = null - override fun getState(durationMs: Long) = - InternalPlayer.State.from( - player.playWhenReady, - player.isPlaying, - // The position value can be below zero or past the expected duration, make - // sure we handle that. - player.currentPosition.coerceAtLeast(0).coerceAtMost(durationMs)) + override val isShuffled + get() = player.shuffleModeEnabled - override fun loadSong(song: Song?, play: Boolean) { - if (song == null) { - // No song, stop playback and foreground state. - logD("Nothing playing, stopping playback") - // For some reason the player does not mark playWhenReady as false when stopped, - // which then completely breaks any re-initialization if playback starts again. - // So we manually set it to false here. - player.playWhenReady = false - player.stop() - stopAndSave() - return + override fun resolveQueue(): Queue = + player.song?.let { Queue(player.currentIndex, player.resolveQueue()) } ?: Queue.nil() + + override fun newPlayback( + queue: List, + start: Song?, + parent: MusicParent?, + shuffled: Boolean, + play: Boolean + ) { + this.parent = parent + if (shuffled) { + player.shuffledQueue(queue, start) + } else { + player.orderedQueue(queue, start) } - - logD("Loading $song") - player.setMediaItem(MediaItem.fromUri(song.uri)) player.prepare() player.playWhenReady = play + playbackManager.dispatchEvent( + this, + PlaybackEvent.NewPlayback( + requireNotNull(player.song) { + "Inconsistency detected: Player does not have song despite being populated" + }, + Queue(player.currentIndex, player.resolveQueue()), + parent, + isShuffled)) + } + + override fun playing(playing: Boolean) { + player.playWhenReady = playing + } + + override fun repeatMode(repeatMode: RepeatMode) { + player.repeatMode = + when (repeatMode) { + RepeatMode.NONE -> Player.REPEAT_MODE_OFF + RepeatMode.ALL -> Player.REPEAT_MODE_ALL + RepeatMode.TRACK -> Player.REPEAT_MODE_ONE + } } override fun seekTo(positionMs: Long) { - logD("Seeking to ${positionMs}ms") player.seekTo(positionMs) } - override fun setPlaying(isPlaying: Boolean) { - logD("Updating player state to $isPlaying") - player.playWhenReady = isPlaying + override fun next() { + player.seekToNext() + player.play() + } + + override fun prev() { + player.seekToPrevious() + player.play() + } + + override fun goto(index: Int) { + player.goto(index) + player.play() + } + + override fun reorder(shuffled: Boolean) { + player.reorder(shuffled) + playbackManager.dispatchEvent( + this, + PlaybackEvent.QueueReordered( + Queue( + player.currentIndex, + player.resolveQueue(), + ), + shuffled)) + } + + override fun addToQueue(songs: List) { + val insertAt = player.currentIndex + 1 + player.addToQueue(songs) + playbackManager.dispatchEvent( + this, + PlaybackEvent.QueueChanged( + requireNotNull(player.song) { + "Inconsistency detected: Player does not have song despite being populated" + }, + Queue(player.currentIndex, player.resolveQueue()), + QueueChange( + QueueChange.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size)))) + } + + override fun playNext(songs: List) { + val insertAt = player.currentIndex + 1 + player.playNext(songs) + // TODO: Re-add queue changes + playbackManager.dispatchEvent( + this, + PlaybackEvent.QueueChanged( + requireNotNull(player.song) { + "Inconsistency detected: Player does not have song despite being populated" + }, + Queue(player.currentIndex, player.resolveQueue()), + QueueChange( + QueueChange.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size)))) + } + + override fun move(from: Int, to: Int) { + val oldIndex = player.currentIndex + player.move(from, to) + val changeType = + if (player.currentIndex != oldIndex) { + QueueChange.Type.INDEX + } else { + QueueChange.Type.MAPPING + } + playbackManager.dispatchEvent( + this, + PlaybackEvent.QueueChanged( + requireNotNull(player.song) { + "Inconsistency detected: Player does not have song despite being populated" + }, + Queue(player.currentIndex, player.resolveQueue()), + QueueChange(changeType, UpdateInstructions.Diff))) + } + + override fun remove(at: Int) { + val oldUnscrambledIndex = player.currentIndex + val oldScrambledIndex = player.currentMediaItemIndex + player.remove(at) + val newUnscrambledIndex = player.currentIndex + val newScrambledIndex = player.currentMediaItemIndex + val changeType = + when { + oldScrambledIndex != newScrambledIndex -> QueueChange.Type.SONG + oldUnscrambledIndex != newUnscrambledIndex -> QueueChange.Type.INDEX + else -> QueueChange.Type.MAPPING + } + + playbackManager.dispatchEvent( + this, + PlaybackEvent.QueueChanged( + requireNotNull(player.song) { + "Inconsistency detected: Player does not have song despite being populated" + }, + Queue(player.currentIndex, player.resolveQueue()), + QueueChange(changeType, UpdateInstructions.Diff))) } // --- PLAYER OVERRIDES --- + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + + if (player.playWhenReady) { + // Mark that we have started playing so that the notification can now be posted. + hasPlayed = true + logD("Player has started playing") + if (!openAudioEffectSession) { + // Convention to start an audioeffect session on play/pause rather than + // start/stop + logD("Opening audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = true + } + } else if (openAudioEffectSession) { + // Make sure to close the audio session when we stop playback. + logD("Closing audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = false + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || + reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + playbackManager.dispatchEvent( + this, PlaybackEvent.IndexMoved(player.song, player.currentIndex)) + } + } + override fun onEvents(player: Player, events: Player.Events) { super.onEvents(player, events) - if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) { - if (player.playWhenReady) { - // Mark that we have started playing so that the notification can now be posted. - hasPlayed = true - logD("Player has started playing") - if (!openAudioEffectSession) { - // Convention to start an audioeffect session on play/pause rather than - // start/stop - logD("Opening audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = true - } - } else if (openAudioEffectSession) { - // Make sure to close the audio session when we stop playback. - logD("Closing audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = false - } - } - // Any change to the analogous isPlaying, isAdvancing, or positionMs values require - // us to synchronize with a new state. if (events.containsAny( Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY)) { logD("Player state changed, must synchronize state") - playbackManager.synchronizeState(this) - } - } - - override fun onPlaybackStateChanged(state: Int) { - if (state == Player.STATE_ENDED) { - // Player ended, repeat the current track if we are configured to. - if (playbackManager.repeatMode == RepeatMode.TRACK) { - logD("Looping current track") - playbackManager.rewind() - // May be configured to pause when we repeat a track. - if (playbackSettings.pauseOnRepeat) { - logD("Pausing track on loop") - playbackManager.setPlaying(false) - } - } else { - logD("Track ended, moving to next track") - playbackManager.next() - } + playbackManager.dispatchEvent(this, PlaybackEvent.ProgressionChanged(progression)) } } @@ -347,7 +474,7 @@ class PlaybackService : } } - override fun performAction(action: InternalPlayer.Action): Boolean { + override fun handleDeferred(action: DeferredPlayback): Boolean { val deviceLibrary = musicRepository.deviceLibrary // No library, cannot do anything. @@ -355,7 +482,7 @@ class PlaybackService : when (action) { // Restore state -> Start a new restoreState job - is InternalPlayer.Action.RestoreState -> { + is DeferredPlayback.RestoreState -> { logD("Restoring playback state") restoreScope.launch { persistenceRepository.readState()?.let { @@ -366,20 +493,20 @@ class PlaybackService : } } // Shuffle all -> Start new playback from all songs - is InternalPlayer.Action.ShuffleAll -> { + is DeferredPlayback.ShuffleAll -> { logD("Shuffling all tracks") playbackManager.play( null, null, listSettings.songSort.songs(deviceLibrary.songs), true) } // Open -> Try to find the Song for the given file and then play it from all songs - is InternalPlayer.Action.Open -> { + is DeferredPlayback.Open -> { logD("Opening specified file") deviceLibrary.findSongForUri(application, action.uri)?.let { song -> playbackManager.play( song, null, listSettings.songSort.songs(deviceLibrary.songs), - playbackManager.queue.isShuffled && playbackSettings.keepShuffle) + player.shuffleModeEnabled && playbackSettings.keepShuffle) } } } @@ -437,15 +564,15 @@ class PlaybackService : // --- AUXIO EVENTS --- ACTION_PLAY_PAUSE -> { logD("Received play event") - playbackManager.setPlaying(!playbackManager.playerState.isPlaying) + playbackManager.playing(!playbackManager.progression.isPlaying) } ACTION_INC_REPEAT_MODE -> { logD("Received repeat mode event") - playbackManager.repeatMode = playbackManager.repeatMode.increment() + playbackManager.repeatMode(playbackManager.repeatMode.increment()) } ACTION_INVERT_SHUFFLE -> { logD("Received shuffle event") - playbackManager.reorder(!playbackManager.queue.isShuffled) + playbackManager.reorder(!playbackManager.isShuffled) } ACTION_SKIP_PREV -> { logD("Received skip previous event") @@ -457,7 +584,7 @@ class PlaybackService : } ACTION_EXIT -> { logD("Received exit event") - playbackManager.setPlaying(false) + playbackManager.playing(false) stopAndSave() } WidgetProvider.ACTION_WIDGET_UPDATE -> { @@ -472,17 +599,17 @@ class PlaybackService : // which would result in unexpected playback. Work around it by dropping the first // call to this function, which should come from that Intent. if (playbackSettings.headsetAutoplay && - playbackManager.queue.currentSong != null && + playbackManager.currentSong != null && initialHeadsetPlugEventHandled) { logD("Device connected, resuming") - playbackManager.setPlaying(true) + playbackManager.playing(true) } } private fun pauseFromHeadsetPlug() { - if (playbackManager.queue.currentSong != null) { + if (playbackManager.currentSong != null) { logD("Device disconnected, pausing") - playbackManager.setPlaying(false) + playbackManager.playing(false) } } } 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 e6f86736a..2311ab53f 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -29,11 +29,11 @@ import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.extractor.RoundedRectTransformation import org.oxycblt.auxio.image.extractor.SquareCropTransformation -import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.queue.Queue -import org.oxycblt.auxio.playback.state.InternalPlayer +import org.oxycblt.auxio.playback.state.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getDimenPixels @@ -64,7 +64,7 @@ constructor( /** Update [WidgetProvider] with the current playback state. */ fun update() { - val song = playbackManager.queue.currentSong + val song = playbackManager.currentSong if (song == null) { logD("No song, resetting widget") widgetProvider.update(context, uiSettings, null) @@ -72,9 +72,9 @@ constructor( } // Note: Store these values here so they remain consistent once the bitmap is loaded. - val isPlaying = playbackManager.playerState.isPlaying + val isPlaying = playbackManager.progression.isPlaying val repeatMode = playbackManager.repeatMode - val isShuffled = playbackManager.queue.isShuffled + val isShuffled = playbackManager.isShuffled logD("Updating widget with new playback state") bitmapProvider.load( @@ -135,16 +135,16 @@ constructor( // --- CALLBACKS --- - // Respond to all major song or player changes that will affect the widget - override fun onIndexMoved(queue: Queue) = update() - - override fun onQueueReordered(queue: Queue) = update() - - override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update() - - override fun onStateChanged(state: InternalPlayer.State) = update() - - override fun onRepeatChanged(repeatMode: RepeatMode) = update() + override fun onPlaybackEvent(event: PlaybackEvent) { + if (event is PlaybackEvent.NewPlayback || + event is PlaybackEvent.ProgressionChanged || + (event is PlaybackEvent.QueueChanged && event.change.type == QueueChange.Type.SONG) || + event is PlaybackEvent.QueueReordered || + event is PlaybackEvent.IndexMoved || + event is PlaybackEvent.RepeatModeChanged) { + update() + } + } // Respond to settings changes that will affect the widget override fun onRoundModeChanged() = update() @@ -156,7 +156,7 @@ constructor( * * @param song [Queue.currentSong] * @param cover A pre-loaded album cover [Bitmap] for [song]. - * @param isPlaying [PlaybackStateManager.playerState] + * @param isPlaying [PlaybackStateManager.progression] * @param repeatMode [PlaybackStateManager.repeatMode] * @param isShuffled [Queue.isShuffled] */