From 26d14ec6e1ab42c86c85a59e9428e91024fba96c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 7 Jan 2024 15:52:19 -0700 Subject: [PATCH 01/36] 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] */ From d5622895d0362e1afe1094c17f06fa8034968dc5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 9 Jan 2024 13:49:00 -0700 Subject: [PATCH 02/36] playback: fix more gapless issues --- .../playback/system/BetterShuffleOrder.kt | 4 +++ .../auxio/playback/system/ExoPlayerExt.kt | 29 ++++++++++++------- .../auxio/playback/system/PlaybackService.kt | 4 +-- 3 files changed, 25 insertions(+), 12 deletions(-) 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 96ed71028..cc921c277 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 @@ -63,6 +63,10 @@ class BetterShuffleOrder private constructor(private val shuffled: IntArray) : S } override fun cloneAndInsert(insertionIndex: Int, insertionCount: Int): ShuffleOrder { + if (shuffled.isEmpty()) { + return BetterShuffleOrder(insertionCount, -1) + } + val newShuffled = IntArray(shuffled.size + insertionCount) val pivot = indexInShuffled[insertionIndex] for (i in shuffled.indices) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt index db841a9a6..c47e45473 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt @@ -22,7 +22,6 @@ 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 @@ -33,13 +32,14 @@ val ExoPlayer.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") + if (start != null) { + val startIndex = queue.indexOf(start) + if (startIndex != -1) { + seekTo(startIndex, C.TIME_UNSET) + } else { + throw IllegalArgumentException("Start song not in queue") + } } } @@ -48,7 +48,6 @@ fun ExoPlayer.shuffledQueue(queue: Collection, start: Song?) { // 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 = @@ -82,11 +81,11 @@ val ExoPlayer.repeat: RepeatMode fun ExoPlayer.reorder(shuffled: Boolean) { logD("Reordering queue to $shuffled") + shuffleModeEnabled = shuffled if (shuffled) { // Have to manually refresh the shuffle seed. setShuffleOrder(BetterShuffleOrder(mediaItemCount, currentMediaItemIndex)) } - shuffleModeEnabled = shuffled } fun ExoPlayer.playNext(songs: List) { @@ -115,7 +114,17 @@ fun ExoPlayer.move(from: Int, to: Int) { val trueFrom = queue[from] val trueTo = queue[to] - moveMediaItem(trueFrom, trueTo) + + when { + trueFrom > trueTo -> { + moveMediaItem(trueFrom, trueTo) + moveMediaItem(trueTo + 1, trueFrom) + } + trueTo > trueFrom -> { + moveMediaItem(trueFrom, trueTo) + moveMediaItem(trueTo - 1, trueFrom) + } + } } fun ExoPlayer.remove(at: Int) { 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 349c8ca19..a7849e699 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 @@ -363,7 +363,7 @@ class PlaybackService : "Inconsistency detected: Player does not have song despite being populated" }, Queue(player.currentIndex, player.resolveQueue()), - QueueChange(changeType, UpdateInstructions.Diff))) + QueueChange(changeType, UpdateInstructions.Move(from, to)))) } override fun remove(at: Int) { @@ -386,7 +386,7 @@ class PlaybackService : "Inconsistency detected: Player does not have song despite being populated" }, Queue(player.currentIndex, player.resolveQueue()), - QueueChange(changeType, UpdateInstructions.Diff))) + QueueChange(changeType, UpdateInstructions.Remove(at, 1)))) } // --- PLAYER OVERRIDES --- From 1d63ad5b7b1c19b9dfddb883facaec9cf778d097 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 9 Jan 2024 15:04:32 -0700 Subject: [PATCH 03/36] playback: mirror state internally Mirror the last playback state of the holder inside PlaybackStateManager. This is generally more efficient and will enable better handling of when state holders attach and detach. --- CHANGELOG.md | 1 + .../auxio/playback/PlaybackViewModel.kt | 96 ++-- .../org/oxycblt/auxio/playback/queue/Queue.kt | 449 ------------------ .../auxio/playback/queue/QueueViewModel.kt | 78 +-- .../replaygain/ReplayGainAudioProcessor.kt | 44 +- .../playback/state/PlaybackStateHolder.kt | 31 +- .../playback/state/PlaybackStateManager.kt | 215 +++++++-- .../auxio/playback/system/ExoPlayerExt.kt | 66 ++- .../playback/system/MediaSessionComponent.kt | 119 ++--- .../auxio/playback/system/PlaybackService.kt | 108 +---- .../oxycblt/auxio/widgets/WidgetComponent.kt | 31 +- 11 files changed, 436 insertions(+), 802 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a01e2ec..91100cae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ #### What's Fixed - Fixed a crash occuring if you navigated to the settings page from the playlist view and then back +- Fixed music loading failing with an SQL error with certain music folder configurations ## 3.3.0 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 b12287bfc..73f7d1258 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -37,8 +37,8 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.persist.PersistenceRepository 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.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.Event @@ -130,52 +130,56 @@ constructor( playbackSettings.unregisterListener(this) } - 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 onIndexMoved(index: Int) { + logD("Index moved, updating current song") + _song.value = playbackManager.currentSong + } + + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + // Other types of queue changes preserve the current song. + if (change.type == QueueChange.Type.SONG) { + logD("Queue changed, updating current song") + _song.value = playbackManager.currentSong + } + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + logD("Queue completely changed, updating current song") + _isShuffled.value = isShuffled + } + + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) { + logD("New playback started, updating playback information") + _song.value = playbackManager.currentSong + _parent.value = parent + _isShuffled.value = isShuffled + } + + override fun onProgressionChanged(progression: Progression) { + logD("Player state changed, starting new position polling") + _isPlaying.value = progression.isPlaying + // Still need to update the position now due to co-routine launch delays + _positionDs.value = 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 = progression.calculateElapsedPositionMs().msToDs() + // Wait a deci-second for the next position tick. + delay(100) } } - 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 onRepeatModeChanged(repeatMode: RepeatMode) { + _repeatMode.value = repeatMode } override fun onBarActionChanged() { @@ -579,7 +583,7 @@ constructor( /** Toggle [isShuffled] (ex. from on to off) */ fun toggleShuffled() { logD("Toggling shuffled state") - playbackManager.reorder(!playbackManager.isShuffled) + playbackManager.shuffled(!playbackManager.isShuffled) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt deleted file mode 100644 index bc421b846..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt +++ /dev/null @@ -1,449 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * Queue.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.queue - -import kotlin.random.Random -import kotlin.random.nextInt -import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.logD - -/** - * A heap-backed play queue. - * - * Whereas other queue implementations use a plain list, Auxio requires a more complicated data - * structure in order to implement features such as gapless playback in ExoPlayer. This queue - * implementation is instead based around an unorganized "heap" of [Song] instances, that are then - * interpreted into different queues depending on the current playback configuration. - * - * In general, the implementation details don't need to be known for this data structure to be used, - * except in special circumstances like [SavedState]. The functions exposed should be familiar for - * any typical play queue. - * - * @author OxygenCobalt - */ -interface Queue { - /** The index of the currently playing [Song] in the current mapping. */ - val index: Int - /** The currently playing [Song]. */ - val currentSong: Song? - /** Whether this queue is shuffled. */ - val isShuffled: Boolean - /** - * Resolve this queue into a more conventional list of [Song]s. - * - * @return A list of [Song] corresponding to the current queue mapping. - */ - fun resolve(): List - - /** - * Represents the possible changes that can occur during certain queue mutation events. - * - * @param type The [Type] of the change to the internal queue state. - * @param instructions The update done to the resolved queue list. - */ - data class Change(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 - } - } - - /** - * An immutable representation of the queue state. - * - * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with - * null values to represent [Song]s that were "lost" from the heap without having to change - * other values. - * @param orderedMapping The mapping of the [heap] to an ordered queue. - * @param shuffledMapping The mapping of the [heap] to a shuffled queue. - * @param index The index of the currently playing [Song] at the time of serialization. - * @param songUid The [Music.UID] of the [Song] that was originally at [index]. - */ - class SavedState( - val heap: List, - val orderedMapping: List, - val shuffledMapping: List, - val index: Int, - val songUid: Music.UID, - ) { - /** - * Remaps the [heap] of this instance based on the given mapping function and copies it into - * a new [SavedState]. - * - * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This - * **MUST** be the same size as the original heap. [Song] instances that could not be - * converted should be replaced with null in the new heap. - * @throws IllegalStateException If the invariant specified by [transform] is violated. - */ - inline fun remap(transform: (Song?) -> Song?) = - SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid) - } -} - -class MutableQueue : Queue { - @Volatile private var heap = mutableListOf() - @Volatile private var orderedMapping = mutableListOf() - @Volatile private var shuffledMapping = mutableListOf() - @Volatile - override var index = -1 - private set - - override val currentSong: Song? - get() = - shuffledMapping - .ifEmpty { orderedMapping.ifEmpty { null } } - ?.getOrNull(index) - ?.let(heap::get) - - override val isShuffled: Boolean - get() = shuffledMapping.isNotEmpty() - - override fun resolve() = - if (currentSong != null) { - shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } - } else { - // Queue doesn't exist, return saner data. - listOf() - } - - /** - * Go to a particular index in the queue. - * - * @param to The index of the [Song] to start playing, in the current queue mapping. - * @return true if the queue jumped to that position, false otherwise. - */ - fun goto(to: Int): Boolean { - if (to !in orderedMapping.indices) { - return false - } - index = to - return true - } - - /** - * Start a new queue configuration. - * - * @param play The [Song] to play, or null to start from a random position. - * @param queue The queue of [Song]s to play. Must contain [play]. This list will become the - * heap internally. - * @param shuffled Whether to shuffle the queue or not. This changes the interpretation of - * [queue]. - */ - fun start(play: Song?, queue: List, shuffled: Boolean) { - heap = queue.toMutableList() - orderedMapping = MutableList(queue.size) { it } - shuffledMapping = mutableListOf() - index = - play?.let(queue::indexOf) ?: if (shuffled) Random.Default.nextInt(queue.indices) else 0 - reorder(shuffled) - check() - } - - /** - * Re-order the queue. - * - * @param shuffled Whether the queue should be shuffled or not. - */ - fun reorder(shuffled: Boolean) { - if (orderedMapping.isEmpty()) { - // Nothing to do. - return - } - - logD("Reordering queue [shuffled=$shuffled]") - - if (shuffled) { - val trueIndex = - if (shuffledMapping.isNotEmpty()) { - // Re-shuffling, song to preserve is in the shuffled mapping - shuffledMapping[index] - } else { - // First shuffle, song to preserve is in the ordered mapping - orderedMapping[index] - } - - // Since we are re-shuffling existing songs, we use the previous mapping size - // instead of the total queue size. - shuffledMapping = orderedMapping.shuffled().toMutableList() - shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex))) - index = 0 - } else if (shuffledMapping.isNotEmpty()) { - // Ordering queue, song to preserve is in the shuffled mapping. - index = orderedMapping.indexOf(shuffledMapping[index]) - shuffledMapping = mutableListOf() - } - check() - } - - /** - * Add [Song]s to the "top" of the queue (right next to the currently playing song). Will start - * playback if nothing is playing. - * - * @param songs The [Song]s to add. - * @return A [Queue.Change] instance that reflects the changes made. - */ - fun addToTop(songs: List): Queue.Change { - logD("Adding ${songs.size} songs to the front of the queue") - val insertAt = index + 1 - val heapIndices = songs.map(::addSongToHeap) - if (shuffledMapping.isNotEmpty()) { - // Add the new songs in front of the current index in the shuffled mapping and in front - // of the analogous list song in the ordered mapping. - logD("Must append songs to shuffled mapping") - val orderedIndex = orderedMapping.indexOf(shuffledMapping[index]) - orderedMapping.addAll(orderedIndex + 1, heapIndices) - shuffledMapping.addAll(insertAt, heapIndices) - } else { - // Add the new song in front of the current index in the ordered mapping. - logD("Only appending songs to ordered mapping") - orderedMapping.addAll(insertAt, heapIndices) - } - check() - return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size)) - } - - /** - * Add [Song]s to the end of the queue. Will start playback if nothing is playing. - * - * @param songs The [Song]s to add. - * @return A [Queue.Change] instance that reflects the changes made. - */ - fun addToBottom(songs: List): Queue.Change { - logD("Adding ${songs.size} songs to the back of the queue") - val insertAt = orderedMapping.size - val heapIndices = songs.map(::addSongToHeap) - // Can simple append the new songs to the end of both mappings. - orderedMapping.addAll(heapIndices) - if (shuffledMapping.isNotEmpty()) { - logD("Appending songs to shuffled mapping") - shuffledMapping.addAll(heapIndices) - } - check() - return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size)) - } - - /** - * 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 A [Queue.Change] instance that reflects the changes made. - */ - fun move(src: Int, dst: Int): Queue.Change { - if (shuffledMapping.isNotEmpty()) { - // Move songs only in the shuffled mapping. There is no sane analogous form of - // this for the ordered mapping. - shuffledMapping.add(dst, shuffledMapping.removeAt(src)) - } else { - // Move songs in the ordered mapping. - orderedMapping.add(dst, orderedMapping.removeAt(src)) - } - - val oldIndex = index - when (index) { - // We are moving the currently playing song, correct the index to it's new position. - src -> { - logD("Moving current song, shifting index") - index = dst - } - // We have moved an song from behind the playing song to in front, shift back. - in (src + 1)..dst -> { - logD("Moving song from behind -> front, shift backwards") - index -= 1 - } - // We have moved an song from in front of the playing song to behind, shift forward. - in dst until src -> { - logD("Moving song from front -> behind, shift forward") - index += 1 - } - else -> { - // Nothing to do. - logD("Move preserved index") - check() - return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Move(src, dst)) - } - } - - logD("Move changed index: $oldIndex -> $index") - - check() - return Queue.Change(Queue.Change.Type.INDEX, UpdateInstructions.Move(src, dst)) - } - - /** - * Remove a [Song] at the given position. - * - * @param at The position of the [Song] to remove. - * @return A [Queue.Change] instance that reflects the changes made. - */ - fun remove(at: Int): Queue.Change { - val lastIndex = orderedMapping.lastIndex - if (shuffledMapping.isNotEmpty()) { - // Remove the specified index in the shuffled mapping and the analogous song in the - // ordered mapping. - orderedMapping.removeAt(orderedMapping.indexOf(shuffledMapping[at])) - shuffledMapping.removeAt(at) - } else { - // Remove the specified index in the shuffled mapping - orderedMapping.removeAt(at) - } - - // Note: We do not clear songs out from the heap, as that would require the backing data - // of the player to be completely invalidated. It's generally easier to not remove the - // song and retain player state consistency. - - val type = - when { - // We just removed the currently playing song. - index == at -> { - logD("Removed current song") - if (lastIndex == index) { - logD("Current song at end of queue, shift back") - --index - } - Queue.Change.Type.SONG - } - // Index was ahead of removed song, shift back to preserve consistency. - index > at -> { - logD("Removed before current song, shift back") - --index - Queue.Change.Type.INDEX - } - // Nothing to do - else -> { - logD("Removal preserved index") - Queue.Change.Type.MAPPING - } - } - logD("Committing change of type $type") - check() - return Queue.Change(type, UpdateInstructions.Remove(at, 1)) - } - - /** - * 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 -> - Queue.SavedState( - heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid) - } - - /** - * Update this instance from the given [Queue.SavedState]. - * - * @param savedState A [Queue.SavedState] with a valid queue representation. - */ - fun applySavedState(savedState: Queue.SavedState) { - val adjustments = mutableListOf() - var currentShift = 0 - for (song in savedState.heap) { - if (song != null) { - adjustments.add(currentShift) - } else { - adjustments.add(null) - currentShift -= 1 - } - } - - logD("Created adjustment mapping [max shift=$currentShift]") - - heap = savedState.heap.filterNotNull().toMutableList() - orderedMapping = - savedState.orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex -> - adjustments[heapIndex]?.let { heapIndex + it } - } - shuffledMapping = - savedState.shuffledMapping.mapNotNullTo(mutableListOf()) { heapIndex -> - adjustments[heapIndex]?.let { heapIndex + it } - } - - // Make sure we re-align the index to point to the previously playing song. - index = savedState.index - while (currentSong?.uid != savedState.songUid && index > -1) { - index-- - } - logD("Corrected index: ${savedState.index} -> $index") - check() - } - - private fun addSongToHeap(song: Song): Int { - // We want to first try to see if there are any "orphaned" songs in the queue - // that we can re-use. This way, we can reduce the memory used up by songs that - // were previously removed from the queue. - val currentMapping = orderedMapping - if (orderedMapping.isNotEmpty()) { - // While we could iterate through the queue and then check the mapping, it's - // faster if we first check the queue for all instances of this song, and then - // do a exclusion of this set of indices with the current mapping in order to - // obtain the orphaned songs. - val orphanCandidates = mutableSetOf() - for (entry in heap.withIndex()) { - if (entry.value == song) { - orphanCandidates.add(entry.index) - } - } - logD("Found orphans: ${orphanCandidates.map { heap[it] }}") - orphanCandidates.removeAll(currentMapping.toSet()) - if (orphanCandidates.isNotEmpty()) { - val orphan = orphanCandidates.first() - logD("Found an orphan that could be re-used: ${heap[orphan]}") - // There are orphaned songs, return the first one we find. - return orphan - } - } - // Nothing to re-use, add this song to the queue - logD("No orphan could be re-used") - heap.add(song) - return heap.lastIndex - } - - private fun check() { - check(!(heap.isEmpty() && (orderedMapping.isNotEmpty() || shuffledMapping.isNotEmpty()))) { - "Queue inconsistency detected: Empty heap with non-empty mappings" + - "[ordered: ${orderedMapping.size}, shuffled: ${shuffledMapping.size}]" - } - - check(shuffledMapping.isEmpty() || orderedMapping.size == shuffledMapping.size) { - "Queue inconsistency detected: Ordered mapping size ${orderedMapping.size} " + - "!= Shuffled mapping size ${shuffledMapping.size}" - } - - check(orderedMapping.all { it in heap.indices }) { - "Queue inconsistency detected: Ordered mapping indices out of heap bounds" - } - - check(shuffledMapping.all { it in heap.indices }) { - "Queue inconsistency detected: Shuffled mapping indices out of heap bounds" - } - } -} 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 8ec0b3cd8..1283fc909 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,8 +24,8 @@ 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 @@ -52,7 +52,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt val scrollTo: Event get() = _scrollTo - private val _index = MutableStateFlow(playbackManager.resolveQueue().index) + private val _index = MutableStateFlow(playbackManager.index) /** The index of the currently playing song in the queue. */ val index: StateFlow get() = _index @@ -61,45 +61,47 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt playbackManager.addListener(this) } - 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 onIndexMoved(index: Int) { + logD("Index moved, synchronizing and scrolling to new position") + _scrollTo.put(index) + _index.value = index + } + + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + // Queue changed trivially due to item mo -> Diff queue, stay at current index. + logD("Updating queue display") + _queueInstructions.put(change.instructions) + _queue.value = queue + if (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 = index } } + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + // Queue changed completely -> Replace queue, update index + logD("Queue changed completely, replacing queue and position") + _queueInstructions.put(UpdateInstructions.Replace(0)) + _scrollTo.put(index) + _queue.value = queue + _index.value = index + } + + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) { + // Entirely new queue -> Replace queue, update index + logD("New playback, replacing queue and position") + _queueInstructions.put(UpdateInstructions.Replace(0)) + _scrollTo.put(index) + _queue.value = queue + _index.value = 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 ff3c27d78..c94f32ae5 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,9 +27,9 @@ 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.state.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.util.logD @@ -70,32 +70,28 @@ constructor( // --- OVERRIDES --- - 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 onIndexMoved(index: Int) { + logD("Index moved, updating current song") + applyReplayGain(playbackManager.currentSong) + } + + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + // Other types of queue changes preserve the current song. + if (change.type == QueueChange.Type.SONG) { + applyReplayGain(playbackManager.currentSong) } } + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) { + logD("New playback started, updating playback information") + applyReplayGain(playbackManager.currentSong) + } + override fun onReplayGainSettingsChanged() { // ReplayGain config changed, we need to set it up again. applyReplayGain(playbackManager.currentSong) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 710934a6d..c3b2fbbab 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -27,19 +27,19 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song interface PlaybackStateHolder { - val currentSong: Song? + val progression: Progression val repeatMode: RepeatMode - val progression: Progression - - val audioSessionId: Int - val parent: MusicParent? + fun resolveQueue(): List + + fun resolveIndex(): Int + val isShuffled: Boolean - fun resolveQueue(): Queue + val audioSessionId: Int fun newPlayback( queue: List, @@ -74,6 +74,21 @@ interface PlaybackStateHolder { fun handleDeferred(action: DeferredPlayback): Boolean } +sealed interface StateEvent { + data object IndexMoved : StateEvent + + data class QueueChanged(val instructions: UpdateInstructions, val songChanged: Boolean) : + StateEvent + + data object QueueReordered : StateEvent + + data object NewPlayback : StateEvent + + data object ProgressionChanged : StateEvent + + data object RepeatModeChanged : StateEvent +} + /** * Represents the possible changes that can occur during certain queue mutation events. * @@ -114,9 +129,9 @@ sealed interface DeferredPlayback { data class Open(val uri: Uri) : DeferredPlayback } -data class Queue(val index: Int, val queue: List) { +data class Queue(val songs: List, val index: Int) { companion object { - fun nil() = Queue(-1, emptyList()) + fun nil() = Queue(emptyList(), -1) } } 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 d33c935ba..e6356bba2 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 @@ -46,17 +46,17 @@ interface PlaybackStateManager { /** The current [Progression] state. */ val progression: Progression - val currentSong: Song? - val repeatMode: RepeatMode - val audioSessionId: Int - val parent: MusicParent? - val isShuffled: Boolean + val currentSong: Song? - fun resolveQueue(): Queue + val queue: List + + val index: Int + + val isShuffled: Boolean /** The audio session ID of the internal player. Null if no internal player exists. */ val currentAudioSessionId: Int? @@ -178,9 +178,9 @@ interface PlaybackStateManager { * * @param shuffled Whether to shuffle the queue or not. */ - fun reorder(shuffled: Boolean) + fun shuffled(shuffled: Boolean) - fun dispatchEvent(stateHolder: PlaybackStateHolder, vararg events: PlaybackEvent) + fun dispatchEvent(stateHolder: PlaybackStateHolder, event: StateEvent) /** * Start a [DeferredPlayback] for the current [PlaybackStateHolder] to handle eventually. @@ -240,7 +240,54 @@ interface PlaybackStateManager { * [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener]. */ interface Listener { - fun onPlaybackEvent(event: PlaybackEvent) + /** + * Called when the position of the currently playing item has changed, changing the current + * [Song], but no other queue attribute has changed. + */ + fun onIndexMoved(index: Int) {} + + /** + * 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: List, index: Int, change: QueueChange) {} + + /** + * 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: List, index: Int, isShuffled: Boolean) {} + + /** + * Called when a new playback configuration was created. + * + * @param queue The new [Queue]. + * @param parent The new [MusicParent] being played from, or null if playing from all songs. + */ + fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) {} + + /** + * Called when the state of the [InternalPlayer] changes. + * + * @param progression The new state of the [InternalPlayer]. + */ + fun onProgressionChanged(progression: Progression) {} + + /** + * Called when the [RepeatMode] changes. + * + * @param repeatMode The new [RepeatMode]. + */ + fun onRepeatModeChanged(repeatMode: RepeatMode) {} } /** @@ -259,52 +306,52 @@ interface PlaybackStateManager { ) } -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 data class StateMirror( + val progression: Progression, + val repeatMode: RepeatMode, + val parent: MusicParent?, + val queue: List, + val index: Int, + val isShuffled: Boolean, + ) + private val listeners = mutableListOf() + + @Volatile + private var stateMirror = + StateMirror( + progression = Progression.nil(), + repeatMode = RepeatMode.NONE, + parent = null, + queue = emptyList(), + index = -1, + isShuffled = false, + ) @Volatile private var stateHolder: PlaybackStateHolder? = null @Volatile private var pendingDeferredPlayback: DeferredPlayback? = null @Volatile private var isInitialized = false - /** The current [Progression] state. */ - override val progression: Progression - get() = stateHolder?.progression ?: Progression.nil() + override val progression + get() = stateMirror.progression - override val currentSong: Song? - get() = stateHolder?.currentSong + override val repeatMode + get() = stateMirror.repeatMode - override val repeatMode: RepeatMode - get() = stateHolder?.repeatMode ?: RepeatMode.NONE + override val parent + get() = stateMirror.parent - override val audioSessionId: Int - get() = stateHolder?.audioSessionId ?: -1 + override val currentSong + get() = stateMirror.queue.getOrNull(stateMirror.index) - override val parent: MusicParent? - get() = stateHolder?.parent + override val queue + get() = stateMirror.queue - override val isShuffled: Boolean - get() = stateHolder?.isShuffled ?: false + override val index + get() = stateMirror.index - override fun resolveQueue() = stateHolder?.resolveQueue() ?: Queue.nil() + override val isShuffled + get() = stateMirror.isShuffled override val currentAudioSessionId: Int? get() = stateHolder?.audioSessionId @@ -313,6 +360,14 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { override fun addListener(listener: PlaybackStateManager.Listener) { logD("Adding $listener to listeners") listeners.add(listener) + + if (isInitialized) { + logD("Sending initial state to $listener") + listener.onNewPlayback( + stateMirror.parent, stateMirror.queue, stateMirror.index, stateMirror.isShuffled) + listener.onProgressionChanged(stateMirror.progression) + listener.onRepeatModeChanged(stateMirror.repeatMode) + } } @Synchronized @@ -418,7 +473,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } @Synchronized - override fun reorder(shuffled: Boolean) { + override fun shuffled(shuffled: Boolean) { val stateHolder = stateHolder ?: return logD("Reordering queue [shuffled=$shuffled]") stateHolder.reorder(shuffled) @@ -470,15 +525,79 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } @Synchronized - override fun dispatchEvent(stateHolder: PlaybackStateHolder, vararg events: PlaybackEvent) { + override fun dispatchEvent(stateHolder: PlaybackStateHolder, event: StateEvent) { 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) } + when (event) { + is StateEvent.IndexMoved -> { + stateMirror = + stateMirror.copy( + index = stateHolder.resolveIndex(), + ) + listeners.forEach { it.onIndexMoved(stateMirror.index) } + } + is StateEvent.QueueChanged -> { + val instructions = event.instructions + val newIndex = stateHolder.resolveIndex() + val changeType = + when { + event.songChanged -> { + QueueChange.Type.SONG + } + stateMirror.index != newIndex -> QueueChange.Type.INDEX + else -> QueueChange.Type.MAPPING + } + stateMirror = stateMirror.copy(queue = stateHolder.resolveQueue(), index = newIndex) + val change = QueueChange(changeType, instructions) + listeners.forEach { + it.onQueueChanged(stateMirror.queue, stateMirror.index, change) + } + } + is StateEvent.QueueReordered -> { + stateMirror = + stateMirror.copy( + queue = stateHolder.resolveQueue(), + index = stateHolder.resolveIndex(), + isShuffled = stateHolder.isShuffled, + ) + listeners.forEach { + it.onQueueReordered( + stateMirror.queue, stateMirror.index, stateMirror.isShuffled) + } + } + is StateEvent.NewPlayback -> { + stateMirror = + stateMirror.copy( + parent = stateHolder.parent, + queue = stateHolder.resolveQueue(), + index = stateHolder.resolveIndex(), + isShuffled = stateHolder.isShuffled, + ) + listeners.forEach { + it.onNewPlayback( + stateMirror.parent, + stateMirror.queue, + stateMirror.index, + stateMirror.isShuffled) + } + } + is StateEvent.ProgressionChanged -> { + stateMirror = + stateMirror.copy( + progression = stateHolder.progression, + ) + listeners.forEach { it.onProgressionChanged(stateMirror.progression) } + } + is StateEvent.RepeatModeChanged -> { + stateMirror = + stateMirror.copy( + repeatMode = stateHolder.repeatMode, + ) + listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) } + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt index c47e45473..d22bf6376 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt @@ -29,6 +29,19 @@ import org.oxycblt.auxio.util.logD val ExoPlayer.song get() = currentMediaItem?.song +fun ExoPlayer.resolveIndex() = unscrambleQueueIndices().indexOf(currentMediaItemIndex) + +fun ExoPlayer.resolveQueue() = unscrambleQueueIndices().map { getMediaItemAt(it).song } + +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.orderedQueue(queue: Collection, start: Song?) { clearMediaItems() shuffleModeEnabled = false @@ -60,25 +73,6 @@ fun ExoPlayer.shuffledQueue(queue: Collection, start: Song?) { 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") shuffleModeEnabled = shuffled @@ -97,23 +91,23 @@ fun ExoPlayer.addToQueue(songs: List) { } fun ExoPlayer.goto(index: Int) { - val queue = unscrambleQueue { index -> index } - if (queue.isEmpty()) { + val indices = unscrambleQueueIndices() + if (indices.isEmpty()) { return } - val trueIndex = queue[index] + val trueIndex = indices[index] seekTo(trueIndex, C.TIME_UNSET) } fun ExoPlayer.move(from: Int, to: Int) { - val queue = unscrambleQueue { index -> index } - if (queue.isEmpty()) { + val indices = unscrambleQueueIndices() + if (indices.isEmpty()) { return } - val trueFrom = queue[from] - val trueTo = queue[to] + val trueFrom = indices[from] + val trueTo = indices[to] when { trueFrom > trueTo -> { @@ -128,29 +122,25 @@ fun ExoPlayer.move(from: Int, to: Int) { } fun ExoPlayer.remove(at: Int) { - val queue = unscrambleQueue { index -> index } - if (queue.isEmpty()) { + val indices = unscrambleQueueIndices() + if (indices.isEmpty()) { return } - val trueIndex = queue[at] + val trueIndex = indices[at] removeMediaItem(trueIndex) } -fun ExoPlayer.resolveQueue(): List { - return unscrambleQueue { index -> getMediaItemAt(index).song } -} - -inline fun ExoPlayer.unscrambleQueue(mapper: (Int) -> T): List { +fun ExoPlayer.unscrambleQueueIndices(): List { val timeline = currentTimeline if (timeline.isEmpty()) { return emptyList() } - val queue = mutableListOf() + val queue = mutableListOf() // Add the active queue item. val currentMediaItemIndex = currentMediaItemIndex - queue.add(mapper(currentMediaItemIndex)) + queue.add(currentMediaItemIndex) // Fill queue alternating with next and/or previous queue items. var firstMediaItemIndex = currentMediaItemIndex @@ -164,7 +154,7 @@ inline fun ExoPlayer.unscrambleQueue(mapper: (Int) -> T): List { timeline.getNextWindowIndex( lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(mapper(lastMediaItemIndex)) + queue.add(lastMediaItemIndex) } } if (firstMediaItemIndex != C.INDEX_UNSET) { @@ -172,7 +162,7 @@ inline fun ExoPlayer.unscrambleQueue(mapper: (Int) -> T): List { timeline.getPreviousWindowIndex( firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, mapper(firstMediaItemIndex)) + queue.add(0, firstMediaItemIndex) } } } 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 57ce5d483..cbccf56ec 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,8 @@ 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.state.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Queue +import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD @@ -118,60 +117,66 @@ constructor( // --- PLAYBACKSTATEMANAGER OVERRIDES --- - 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 onIndexMoved(index: Int) { + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + invalidateSessionState() + } - invalidateSecondaryAction() - } + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + updateQueue(queue) + when (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(playbackManager.currentSong, playbackManager.parent) } } + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + updateQueue(queue) + invalidateSessionState() + mediaSession.setShuffleMode( + if (isShuffled) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + }) + invalidateSecondaryAction() + } + + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) { + updateMediaMetadata(playbackManager.currentSong, parent) + updateQueue(queue) + invalidateSessionState() + } + + override fun onProgressionChanged(progression: Progression) { + invalidateSessionState() + notification.updatePlaying(playbackManager.progression.isPlaying) + if (!bitmapProvider.isBusy) { + listener?.onPostNotification(notification) + } + } + + override fun onRepeatModeChanged(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() { @@ -251,7 +256,7 @@ constructor( } override fun onSetShuffleMode(shuffleMode: Int) { - playbackManager.reorder( + playbackManager.shuffled( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) } @@ -355,9 +360,9 @@ constructor( * * @param queue The current queue to upload. */ - private fun updateQueue(queue: Queue) { + private fun updateQueue(queue: List) { val queueItems = - queue.queue.mapIndexed { i, song -> + queue.mapIndexed { i, song -> val description = MediaDescriptionCompat.Builder() // Media ID should not be the item index but rather the UID, @@ -382,15 +387,13 @@ constructor( private fun invalidateSessionState() { logD("Updating media session playback state") - val queue = playbackManager.resolveQueue() - val state = // InternalPlayer.State handles position/state information. playbackManager.progression .intoPlaybackState(PlaybackStateCompat.Builder()) .setActions(ACTIONS) // Active queue ID corresponds to the indices we populated prior, use them here. - .setActiveQueueItemId(queue.index.toLong()) + .setActiveQueueItemId(playbackManager.index.toLong()) // Android 13+ relies on custom actions in the notification. 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 a7849e699..6c973c167 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 @@ -56,13 +56,11 @@ 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.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.playback.state.StateEvent import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE @@ -219,9 +217,6 @@ class PlaybackService : // --- PLAYBACKSTATEHOLDER OVERRIDES --- - override val currentSong - get() = player.song - override val repeatMode get() = player.repeat @@ -237,16 +232,17 @@ class PlaybackService : } ?: Progression.nil() - override val audioSessionId: Int - get() = player.audioSessionId - override var parent: MusicParent? = null override val isShuffled get() = player.shuffleModeEnabled - override fun resolveQueue(): Queue = - player.song?.let { Queue(player.currentIndex, player.resolveQueue()) } ?: Queue.nil() + override fun resolveIndex() = player.resolveIndex() + + override fun resolveQueue() = player.resolveQueue() + + override val audioSessionId: Int + get() = player.audioSessionId override fun newPlayback( queue: List, @@ -263,19 +259,12 @@ class PlaybackService : } 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)) + playbackManager.dispatchEvent(this, StateEvent.NewPlayback) } override fun playing(playing: Boolean) { player.playWhenReady = playing + // Dispatched later once all of the changes have been accumulated } override fun repeatMode(repeatMode: RepeatMode) { @@ -285,6 +274,7 @@ class PlaybackService : RepeatMode.ALL -> Player.REPEAT_MODE_ALL RepeatMode.TRACK -> Player.REPEAT_MODE_ONE } + playbackManager.dispatchEvent(this, StateEvent.RepeatModeChanged) } override fun seekTo(positionMs: Long) { @@ -293,100 +283,53 @@ class PlaybackService : override fun next() { player.seekToNext() + playbackManager.dispatchEvent(this, StateEvent.IndexMoved) player.play() } override fun prev() { player.seekToPrevious() + playbackManager.dispatchEvent(this, StateEvent.IndexMoved) player.play() } override fun goto(index: Int) { player.goto(index) + playbackManager.dispatchEvent(this, StateEvent.IndexMoved) player.play() } override fun reorder(shuffled: Boolean) { player.reorder(shuffled) - playbackManager.dispatchEvent( - this, - PlaybackEvent.QueueReordered( - Queue( - player.currentIndex, - player.resolveQueue(), - ), - shuffled)) + playbackManager.dispatchEvent(this, StateEvent.QueueReordered) } override fun addToQueue(songs: List) { - val insertAt = player.currentIndex + 1 + val insertAt = playbackManager.index + 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)))) + this, StateEvent.QueueChanged(UpdateInstructions.Add(insertAt, songs.size), false)) } override fun playNext(songs: List) { - val insertAt = player.currentIndex + 1 + val insertAt = playbackManager.index + 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)))) + this, StateEvent.QueueChanged(UpdateInstructions.Add(insertAt, songs.size), false)) } 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.Move(from, to)))) + this, StateEvent.QueueChanged(UpdateInstructions.Move(from, to), false)) } override fun remove(at: Int) { - val oldUnscrambledIndex = player.currentIndex - val oldScrambledIndex = player.currentMediaItemIndex + val oldIndex = 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 - } - + val newIndex = player.currentMediaItemIndex 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.Remove(at, 1)))) + this, StateEvent.QueueChanged(UpdateInstructions.Remove(at, 1), oldIndex != newIndex)) } // --- PLAYER OVERRIDES --- @@ -418,8 +361,7 @@ class PlaybackService : if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { - playbackManager.dispatchEvent( - this, PlaybackEvent.IndexMoved(player.song, player.currentIndex)) + playbackManager.dispatchEvent(this, StateEvent.IndexMoved) } } @@ -431,7 +373,7 @@ class PlaybackService : Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY)) { logD("Player state changed, must synchronize state") - playbackManager.dispatchEvent(this, PlaybackEvent.ProgressionChanged(progression)) + playbackManager.dispatchEvent(this, StateEvent.ProgressionChanged) } } @@ -572,7 +514,7 @@ class PlaybackService : } ACTION_INVERT_SHUFFLE -> { logD("Received shuffle event") - playbackManager.reorder(!playbackManager.isShuffled) + playbackManager.shuffled(!playbackManager.isShuffled) } ACTION_SKIP_PREV -> { logD("Received skip previous event") 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 2311ab53f..d67892300 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -29,10 +29,10 @@ 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.PlaybackEvent import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.ui.UISettings @@ -135,17 +135,28 @@ constructor( // --- CALLBACKS --- - 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) { + // Respond to all major song or player changes that will affect the widget + override fun onIndexMoved(index: Int) = update() + + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + if (change.type == QueueChange.Type.SONG) { update() } } + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) = update() + + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) = update() + + override fun onProgressionChanged(progression: Progression) = update() + + override fun onRepeatModeChanged(repeatMode: RepeatMode) = update() + // Respond to settings changes that will affect the widget override fun onRoundModeChanged() = update() @@ -156,7 +167,7 @@ constructor( * * @param song [Queue.currentSong] * @param cover A pre-loaded album cover [Bitmap] for [song]. - * @param isPlaying [PlaybackStateManager.progression] + * @param isPlaying [PlaybackStateManager.playerState] * @param repeatMode [PlaybackStateManager.repeatMode] * @param isShuffled [Queue.isShuffled] */ From bd240f967eb2f32beedd845652c15cc10c8a6a53 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 Jan 2024 18:34:17 -0700 Subject: [PATCH 04/36] playback: reimplement state saving --- .../auxio/music/system/IndexerService.kt | 29 +- .../playback/persist/PersistenceDatabase.kt | 18 +- .../playback/persist/PersistenceRepository.kt | 59 ++-- .../playback/state/PlaybackStateHolder.kt | 86 ++--- .../playback/state/PlaybackStateManager.kt | 302 +++++++++++++----- .../playback/system/BetterShuffleOrder.kt | 2 +- .../auxio/playback/system/ExoPlayerExt.kt | 29 +- .../auxio/playback/system/PlaybackService.kt | 235 +++++++------- 8 files changed, 440 insertions(+), 320 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 3e506967e..83f8d5f80 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -138,23 +138,18 @@ class IndexerService : logD("Music changed, updating shared objects") // Wipe possibly-invalidated outdated covers imageLoader.memoryCache?.clear() - // // Clear invalid models from PlaybackStateManager. This is not connected - // // to a listener as it is bad practice for a shared object to attach to - // // the listener system of another. - // playbackManager.toSavedState()?.let { savedState -> - // playbackManager.applySavedState( - // PlaybackStateManager.SavedState( - // parent = - // savedState.parent?.let { musicRepository.find(it.uid) as? - // MusicParent }, - // queueState = - // savedState.queueState.remap { song -> - // deviceLibrary.findSong(requireNotNull(song).uid) - // }, - // positionMs = savedState.positionMs, - // repeatMode = savedState.repeatMode), - // true) - // } + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + savedState.copy( + heap = + savedState.heap.map { song -> + song?.let { deviceLibrary.findSong(it.uid) } + }), + true) + } } override fun onIndexingStateChanged() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index 82ba84ddb..adceb581f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -37,8 +37,8 @@ import org.oxycblt.auxio.playback.state.RepeatMode * @author Alexander Capehart */ @Database( - entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class], - version = 32, + entities = [PlaybackState::class, QueueHeapItem::class, QueueShuffledMappingItem::class], + version = 38, exportSchema = false) @TypeConverters(Music.UID.TypeConverters::class) abstract class PersistenceDatabase : RoomDatabase() { @@ -109,15 +109,16 @@ interface QueueDao { /** * Get the previously persisted queue mapping. * - * @return A list of persisted [QueueMappingItem]s wrapping each heap item. + * @return A list of persisted [QueueShuffledMappingItem]s wrapping each heap item. */ - @Query("SELECT * FROM QueueMappingItem") suspend fun getMapping(): List + @Query("SELECT * FROM QueueShuffledMappingItem") + suspend fun getShuffledMapping(): List /** Delete any previously persisted queue heap entries. */ @Query("DELETE FROM QueueHeapItem") suspend fun nukeHeap() /** Delete any previously persisted queue mapping entries. */ - @Query("DELETE FROM QueueMappingItem") suspend fun nukeMapping() + @Query("DELETE FROM QueueShuffledMappingItem") suspend fun nukeShuffledMapping() /** * Insert new heap entries into the database. @@ -129,10 +130,10 @@ interface QueueDao { /** * Insert new mapping entries into the database. * - * @param mapping The list of wrapped [QueueMappingItem] to insert. + * @param mapping The list of wrapped [QueueShuffledMappingItem] to insert. */ @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertMapping(mapping: List) + suspend fun insertShuffledMapping(mapping: List) } // TODO: Figure out how to get RepeatMode to map to an int instead of a string @@ -148,5 +149,4 @@ data class PlaybackState( @Entity data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) -@Entity -data class QueueMappingItem(@PrimaryKey val id: Int, val orderedIndex: Int, val shuffledIndex: Int) +@Entity data class QueueShuffledMappingItem(@PrimaryKey val id: Int, val index: Int) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt index 4ba4423ea..a291cc175 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt @@ -53,48 +53,37 @@ constructor( override suspend fun readState(): PlaybackStateManager.SavedState? { val deviceLibrary = musicRepository.deviceLibrary ?: return null val playbackState: PlaybackState - val heap: List - val mapping: List + val heapItems: List + val mappingItems: List try { playbackState = playbackStateDao.getState() ?: return null - heap = queueDao.getHeap() - mapping = queueDao.getMapping() + heapItems = queueDao.getHeap() + mappingItems = queueDao.getShuffledMapping() } catch (e: Exception) { logE("Unable read playback state") logE(e.stackTraceToString()) return null } - val orderedMapping = mutableListOf() - val shuffledMapping = mutableListOf() - for (entry in mapping) { - orderedMapping.add(entry.orderedIndex) - shuffledMapping.add(entry.shuffledIndex) - } - + val heap = heapItems.map { deviceLibrary.findSong(it.uid) } + val shuffledMapping = mappingItems.map { it.index } val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent } - logD("Successfully read playback state") - // return PlaybackStateManager.SavedState( - // parent = parent, - // queueState = - // Queue.SavedState( - // heap.map { deviceLibrary.findSong(it.uid) }, - // orderedMapping, - // shuffledMapping, - // playbackState.index, - // playbackState.songUid), - // positionMs = playbackState.positionMs, - // repeatMode = playbackState.repeatMode) - - return null + return PlaybackStateManager.SavedState( + positionMs = playbackState.positionMs, + repeatMode = playbackState.repeatMode, + parent = parent, + heap = heap, + shuffledMapping = shuffledMapping, + index = playbackState.index, + songUid = playbackState.songUid) } override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean { try { playbackStateDao.nukeState() queueDao.nukeHeap() - queueDao.nukeMapping() + queueDao.nukeShuffledMapping() } catch (e: Exception) { logE("Unable to clear previous state") logE(e.stackTraceToString()) @@ -107,29 +96,23 @@ constructor( val playbackState = PlaybackState( id = 0, - index = state.queueState.index, + index = state.index, positionMs = state.positionMs, repeatMode = state.repeatMode, - songUid = state.queueState.songUid, + songUid = state.songUid, parentUid = state.parent?.uid) // Convert the remaining queue information do their database-specific counterparts. val heap = - state.queueState.heap.mapIndexed { i, song -> - QueueHeapItem(i, requireNotNull(song).uid) - } + state.heap.mapIndexed { i, song -> QueueHeapItem(i, requireNotNull(song).uid) } - val mapping = - state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed { - i, - pair -> - QueueMappingItem(i, pair.first, pair.second) - } + val shuffledMapping = + state.shuffledMapping.mapIndexed { i, index -> QueueShuffledMappingItem(i, index) } try { playbackStateDao.insertState(playbackState) queueDao.insertHeap(heap) - queueDao.insertMapping(mapping) + queueDao.insertShuffledMapping(shuffledMapping) } catch (e: Exception) { logE("Unable to write new state") logE(e.stackTraceToString()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index c3b2fbbab..ef8175755 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -22,7 +22,6 @@ import android.net.Uri import android.os.SystemClock import android.support.v4.media.session.PlaybackStateCompat import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -33,21 +32,13 @@ interface PlaybackStateHolder { val parent: MusicParent? - fun resolveQueue(): List - - fun resolveIndex(): Int + fun resolveQueue(): RawQueue val isShuffled: Boolean val audioSessionId: Int - fun newPlayback( - queue: List, - start: Song?, - parent: MusicParent?, - shuffled: Boolean, - play: Boolean - ) + fun newPlayback(queue: List, start: Song?, parent: MusicParent?, shuffled: Boolean) fun playing(playing: Boolean) @@ -61,32 +52,65 @@ interface PlaybackStateHolder { fun goto(index: Int) - fun playNext(songs: List) + fun playNext(songs: List, ack: StateAck.PlayNext) - fun addToQueue(songs: List) + fun addToQueue(songs: List, ack: StateAck.AddToQueue) - fun move(from: Int, to: Int) + fun move(from: Int, to: Int, ack: StateAck.Move) - fun remove(at: Int) + fun remove(at: Int, ack: StateAck.Remove) - fun reorder(shuffled: Boolean) + fun shuffled(shuffled: Boolean) fun handleDeferred(action: DeferredPlayback): Boolean + + fun applySavedState(parent: MusicParent?, rawQueue: RawQueue) } -sealed interface StateEvent { - data object IndexMoved : StateEvent +sealed interface StateAck { + data object IndexMoved : StateAck - data class QueueChanged(val instructions: UpdateInstructions, val songChanged: Boolean) : - StateEvent + data class PlayNext(val at: Int, val size: Int) : StateAck - data object QueueReordered : StateEvent + data class AddToQueue(val at: Int, val size: Int) : StateAck - data object NewPlayback : StateEvent + data class Move(val from: Int, val to: Int) : StateAck - data object ProgressionChanged : StateEvent + data class Remove(val index: Int) : StateAck - data object RepeatModeChanged : StateEvent + data object QueueReordered : StateAck + + data object NewPlayback : StateAck + + data object ProgressionChanged : StateAck + + data object RepeatModeChanged : StateAck +} + +data class RawQueue( + val heap: List, + val shuffledMapping: List, + val heapIndex: Int, +) { + val isShuffled = shuffledMapping.isNotEmpty() + + fun resolveSongs() = + if (isShuffled) { + shuffledMapping.map { heap[it] } + } else { + heap + } + + fun resolveIndex() = + if (isShuffled) { + shuffledMapping.indexOf(heapIndex) + } else { + heapIndex + } + + companion object { + fun nil() = RawQueue(emptyList(), emptyList(), -1) + } } /** @@ -129,20 +153,6 @@ sealed interface DeferredPlayback { data class Open(val uri: Uri) : DeferredPlayback } -data class Queue(val songs: List, val index: Int) { - companion object { - fun nil() = Queue(emptyList(), -1) - } -} - -data class SavedQueue( - val heap: List, - val orderedMapping: List, - val shuffledMapping: List, - val index: Int, - val songUid: Music.UID, -) - /** A representation of the current state of audio playback. Use [from] to create an instance. */ class Progression private constructor( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index e6356bba2..b38c63c0b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -20,6 +20,8 @@ package org.oxycblt.auxio.playback.state import javax.inject.Inject import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logD @@ -180,7 +182,16 @@ interface PlaybackStateManager { */ fun shuffled(shuffled: Boolean) - fun dispatchEvent(stateHolder: PlaybackStateHolder, event: StateEvent) + /** + * Acknowledges that an event has happened that modified the state held by the current + * [PlaybackStateHolder]. + * + * @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current + * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder] + * implementation. + * @param ack The [StateAck] to acknowledge. + */ + fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) /** * Start a [DeferredPlayback] for the current [PlaybackStateHolder] to handle eventually. @@ -243,30 +254,37 @@ interface PlaybackStateManager { /** * Called when the position of the currently playing item has changed, changing the current * [Song], but no other queue attribute has changed. + * + * @param index The new index of the currently playing [Song]. */ fun onIndexMoved(index: Int) {} /** - * Called when the [Queue] changed in a manner outlined by the given [Queue.Change]. + * Called when the queue changed in a manner outlined by the given [Queue.Change]. * - * @param queue The new [Queue]. - * @param change The type of [Queue.Change] that occurred. + * @param queue The songs of the new queue. + * @param index The new index of the currently playing [Song]. + * @param change The [QueueChange] that occurred. */ fun onQueueChanged(queue: List, index: Int, change: QueueChange) {} /** - * Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but + * Called when the queue has changed in a non-trivial manner (such as re-shuffling), but * the currently playing [Song] has not. * - * @param queue The new [Queue]. + * @param queue The songs of the new queue. + * @param index The new index of the currently playing [Song]. + * @param isShuffled Whether the queue is shuffled or not. */ fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) {} /** * Called when a new playback configuration was created. * - * @param queue The new [Queue]. - * @param parent The new [MusicParent] being played from, or null if playing from all songs. + * @param parent The [MusicParent] item currently being played from. + * @param queue The queue of [Song]s to play from. + * @param index The index of the currently playing [Song]. + * @param isShuffled Whether the queue is shuffled or not. */ fun onNewPlayback( parent: MusicParent?, @@ -294,15 +312,17 @@ interface PlaybackStateManager { * A condensed representation of the playback state that can be persisted. * * @param parent The [MusicParent] item currently being played from. - * @param queueState The [SavedQueue] * @param positionMs The current position in the currently played song, in ms * @param repeatMode The current [RepeatMode]. */ data class SavedState( - val parent: MusicParent?, - val queueState: SavedQueue, val positionMs: Long, val repeatMode: RepeatMode, + val parent: MusicParent?, + val heap: List, + val shuffledMapping: List, + val index: Int, + val songUid: Music.UID, ) } @@ -314,6 +334,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val queue: List, val index: Int, val isShuffled: Boolean, + val rawQueue: RawQueue ) private val listeners = mutableListOf() @@ -327,7 +348,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { queue = emptyList(), index = -1, isShuffled = false, - ) + rawQueue = RawQueue.nil()) @Volatile private var stateHolder: PlaybackStateHolder? = null @Volatile private var pendingDeferredPlayback: DeferredPlayback? = null @Volatile private var isInitialized = false @@ -408,7 +429,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]") // Played something, so we are initialized now isInitialized = true - stateHolder.newPlayback(queue, song, parent, shuffled, true) + stateHolder.newPlayback(queue, song, parent, shuffled) } // --- QUEUE FUNCTIONS --- @@ -418,6 +439,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val stateHolder = stateHolder ?: return logD("Going to next song") stateHolder.next() + stateHolder.playing(true) } @Synchronized @@ -425,6 +447,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val stateHolder = stateHolder ?: return logD("Going to previous song") stateHolder.prev() + stateHolder.playing(true) } @Synchronized @@ -432,6 +455,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val stateHolder = stateHolder ?: return logD("Going to index $index") stateHolder.goto(index) + stateHolder.playing(true) } @Synchronized @@ -442,7 +466,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } else { val stateHolder = stateHolder ?: return logD("Adding ${songs.size} songs to start of queue") - stateHolder.playNext(songs) + stateHolder.playNext(songs, StateAck.PlayNext(stateMirror.index + 1, songs.size)) } } @@ -454,7 +478,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } else { val stateHolder = stateHolder ?: return logD("Adding ${songs.size} songs to end of queue") - stateHolder.addToQueue(songs) + stateHolder.addToQueue(songs, StateAck.AddToQueue(stateMirror.index + 1, songs.size)) } } @@ -462,21 +486,21 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { override fun moveQueueItem(src: Int, dst: Int) { val stateHolder = stateHolder ?: return logD("Moving item $src to position $dst") - stateHolder.move(src, dst) + stateHolder.move(src, dst, StateAck.Move(src, dst)) } @Synchronized override fun removeQueueItem(at: Int) { val stateHolder = stateHolder ?: return logD("Removing item at $at") - stateHolder.remove(at) + stateHolder.remove(at, StateAck.Remove(at)) } @Synchronized override fun shuffled(shuffled: Boolean) { val stateHolder = stateHolder ?: return logD("Reordering queue [shuffled=$shuffled]") - stateHolder.reorder(shuffled) + stateHolder.shuffled(shuffled) } // --- INTERNAL PLAYER FUNCTIONS --- @@ -525,57 +549,113 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } @Synchronized - override fun dispatchEvent(stateHolder: PlaybackStateHolder, event: StateEvent) { + override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) { if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) { logW("Given internal player did not match current internal player") return } - when (event) { - is StateEvent.IndexMoved -> { - stateMirror = - stateMirror.copy( - index = stateHolder.resolveIndex(), - ) + when (ack) { + is StateAck.IndexMoved -> { + val rawQueue = stateHolder.resolveQueue() + stateMirror = stateMirror.copy(index = rawQueue.resolveIndex(), rawQueue = rawQueue) listeners.forEach { it.onIndexMoved(stateMirror.index) } } - is StateEvent.QueueChanged -> { - val instructions = event.instructions - val newIndex = stateHolder.resolveIndex() - val changeType = - when { - event.songChanged -> { - QueueChange.Type.SONG - } - stateMirror.index != newIndex -> QueueChange.Type.INDEX - else -> QueueChange.Type.MAPPING - } - stateMirror = stateMirror.copy(queue = stateHolder.resolveQueue(), index = newIndex) - val change = QueueChange(changeType, instructions) + is StateAck.PlayNext -> { + val rawQueue = stateHolder.resolveQueue() + val change = + QueueChange(QueueChange.Type.MAPPING, UpdateInstructions.Add(ack.at, ack.size)) + stateMirror = + stateMirror.copy( + queue = rawQueue.resolveSongs(), + rawQueue = rawQueue, + ) listeners.forEach { it.onQueueChanged(stateMirror.queue, stateMirror.index, change) } } - is StateEvent.QueueReordered -> { + is StateAck.AddToQueue -> { + val rawQueue = stateHolder.resolveQueue() + val change = + QueueChange(QueueChange.Type.MAPPING, UpdateInstructions.Add(ack.at, ack.size)) stateMirror = stateMirror.copy( - queue = stateHolder.resolveQueue(), - index = stateHolder.resolveIndex(), - isShuffled = stateHolder.isShuffled, + queue = rawQueue.resolveSongs(), + rawQueue = rawQueue, ) + listeners.forEach { + it.onQueueChanged(stateMirror.queue, stateMirror.index, change) + } + } + is StateAck.Move -> { + val rawQueue = stateHolder.resolveQueue() + val newIndex = rawQueue.resolveIndex() + val change = + QueueChange( + if (stateMirror.index != newIndex) QueueChange.Type.INDEX + else QueueChange.Type.MAPPING, + UpdateInstructions.Move(ack.from, ack.to)) + + stateMirror = + stateMirror.copy( + queue = rawQueue.resolveSongs(), + index = newIndex, + rawQueue = rawQueue, + ) + + listeners.forEach { + it.onQueueChanged(stateMirror.queue, stateMirror.index, change) + } + } + is StateAck.Remove -> { + val rawQueue = stateHolder.resolveQueue() + val newIndex = rawQueue.resolveIndex() + val change = + QueueChange( + when { + ack.index == stateMirror.index -> QueueChange.Type.SONG + stateMirror.index != newIndex -> QueueChange.Type.INDEX + else -> QueueChange.Type.MAPPING + }, + UpdateInstructions.Remove(ack.index, 1)) + + stateMirror = + stateMirror.copy( + queue = rawQueue.resolveSongs(), + index = newIndex, + rawQueue = rawQueue, + ) + + if (change.type == QueueChange.Type.SONG) { + playing(true) + } + + listeners.forEach { + it.onQueueChanged(stateMirror.queue, stateMirror.index, change) + } + } + is StateAck.QueueReordered -> { + val rawQueue = stateHolder.resolveQueue() + stateMirror = + stateMirror.copy( + queue = rawQueue.resolveSongs(), + index = rawQueue.resolveIndex(), + isShuffled = stateHolder.isShuffled, + rawQueue = rawQueue) listeners.forEach { it.onQueueReordered( stateMirror.queue, stateMirror.index, stateMirror.isShuffled) } } - is StateEvent.NewPlayback -> { + is StateAck.NewPlayback -> { + val rawQueue = stateHolder.resolveQueue() stateMirror = stateMirror.copy( parent = stateHolder.parent, - queue = stateHolder.resolveQueue(), - index = stateHolder.resolveIndex(), + queue = rawQueue.resolveSongs(), + index = rawQueue.resolveIndex(), isShuffled = stateHolder.isShuffled, - ) + rawQueue = rawQueue) listeners.forEach { it.onNewPlayback( stateMirror.parent, @@ -584,14 +664,14 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { stateMirror.isShuffled) } } - is StateEvent.ProgressionChanged -> { + is StateAck.ProgressionChanged -> { stateMirror = stateMirror.copy( progression = stateHolder.progression, ) listeners.forEach { it.onProgressionChanged(stateMirror.progression) } } - is StateEvent.RepeatModeChanged -> { + is StateAck.RepeatModeChanged -> { stateMirror = stateMirror.copy( repeatMode = stateHolder.repeatMode, @@ -603,51 +683,99 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { // --- PERSISTENCE FUNCTIONS --- - @Synchronized override fun toSavedState() = null - // queue.toSavedState()?.let { - // PlaybackStateManager.SavedState( - // parent = parent, - // queueState = it, - // positionMs = progression.calculateElapsedPositionMs(), - // repeatMode = repeatMode) - // } + @Synchronized + override fun toSavedState(): PlaybackStateManager.SavedState? { + val currentSong = currentSong ?: return null + return PlaybackStateManager.SavedState( + positionMs = stateMirror.progression.calculateElapsedPositionMs(), + repeatMode = stateMirror.repeatMode, + parent = stateMirror.parent, + heap = stateMirror.rawQueue.heap, + shuffledMapping = stateMirror.rawQueue.shuffledMapping, + index = stateMirror.index, + songUid = currentSong.uid, + ) + } @Synchronized override fun applySavedState( savedState: PlaybackStateManager.SavedState, destructive: Boolean ) { - // if (isInitialized && !destructive) { - // logW("Already initialized, cannot apply saved state") - // return - // } - // val stateHolder = stateHolder ?: return - // logD("Applying state $savedState") - // - // val lastSong = queue.currentSong - // parent = savedState.parent - // queue.applySavedState(savedState.queueState) - // repeatMode = savedState.repeatMode - // notifyNewPlayback() - // - // // Check if we need to reload the player with a new music file, or if we can just - // leave - // // it be. Specifically done so we don't pause on music updates that don't really - // change - // // what's playing (ex. playlist editing) - // if (lastSong != queue.currentSong) { - // logD("Song changed, must reload player") - // // Continuing playback while also possibly doing drastic state updates is - // // a bad idea, so pause. - // stateHolder.loadSong(queue.currentSong, false) - // if (queue.currentSong != null) { - // logD("Seeking to saved position ${savedState.positionMs}ms") - // // Internal player may have reloaded the media item, re-seek to the - // previous - // // position - // seekTo(savedState.positionMs) - // } - // } + if (isInitialized && !destructive) { + logW("Already initialized, cannot apply saved state") + return + } + + // The heap may not be the same if the song composition changed between state saves/reloads. + // This also means that we must modify the shuffled mapping as well, in what it points to + // and it's general composition. + val heap = mutableListOf() + val adjustments = mutableListOf() + var currentShift = 0 + for (song in savedState.heap) { + if (song != null) { + heap.add(song) + adjustments.add(currentShift) + } else { + adjustments.add(null) + currentShift -= 1 + } + } + + logD("Created adjustment mapping [max shift=$currentShift]") + + val shuffledMapping = + savedState.shuffledMapping.mapNotNullTo(mutableListOf()) { index -> + adjustments[index]?.let { index + it } + } + + // Make sure we re-align the index to point to the previously playing song. + fun pointingAtSong(): Boolean { + val currentSong = + if (shuffledMapping.isNotEmpty()) { + shuffledMapping.getOrNull(savedState.index)?.let { heap.getOrNull(it) } + } else { + heap.getOrNull(savedState.index) + } + + return currentSong?.uid == savedState.songUid + } + + var index = savedState.index + while (pointingAtSong() && index > -1) { + index-- + } + + logD("Corrected index: ${savedState.index} -> $index") + + check(shuffledMapping.all { it in heap.indices }) { + "Queue inconsistency detected: Shuffled mapping indices out of heap bounds" + } + + val rawQueue = + RawQueue( + heap = heap, + shuffledMapping = savedState.shuffledMapping, + heapIndex = + if (savedState.shuffledMapping.isNotEmpty()) { + savedState.shuffledMapping[savedState.index] + } else { + savedState.index + }) + + val oldStateMirror = stateMirror + + if (oldStateMirror.rawQueue != rawQueue) { + logD("Queue changed, must reload player") + stateHolder?.applySavedState(parent, rawQueue) + } + + if (oldStateMirror.progression.calculateElapsedPositionMs() != savedState.positionMs) { + logD("Seeking to saved position ${savedState.positionMs}ms") + stateHolder?.seekTo(savedState.positionMs) + } + isInitialized = true } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt index cc921c277..099931baf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt @@ -29,7 +29,7 @@ import java.util.* * * @author media3 team, Alexander Capehart (OxygenCobalt) */ -class BetterShuffleOrder private constructor(private val shuffled: IntArray) : ShuffleOrder { +class BetterShuffleOrder constructor(private val shuffled: IntArray) : ShuffleOrder { private val indexInShuffled: IntArray = IntArray(shuffled.size) constructor(length: Int, startIndex: Int) : this(createShuffledList(length, startIndex)) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt index d22bf6376..0ae7ce70f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt @@ -23,15 +23,19 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.state.RawQueue import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD val ExoPlayer.song get() = currentMediaItem?.song -fun ExoPlayer.resolveIndex() = unscrambleQueueIndices().indexOf(currentMediaItemIndex) - -fun ExoPlayer.resolveQueue() = unscrambleQueueIndices().map { getMediaItemAt(it).song } +fun ExoPlayer.resolveQueue(): RawQueue { + val heap = (0 until mediaItemCount).map { getMediaItemAt(it).song } + val shuffledMapping = if (shuffleModeEnabled) unscrambleQueueIndices() else emptyList() + logD(shuffledMapping) + return RawQueue(heap, shuffledMapping, currentMediaItemIndex) +} val ExoPlayer.repeat: RepeatMode get() = @@ -57,10 +61,6 @@ fun ExoPlayer.orderedQueue(queue: Collection, start: Song?) { } fun ExoPlayer.shuffledQueue(queue: Collection, start: Song?) { - // A fun thing about ShuffleOrder is that ExoPlayer will use cloneAndInsert to both add - // MediaItems AND repopulate MediaItems (?!?!?!?!). As a result, we have to use the default - // shuffle order and it's stupid cloneAndInsert implementation to add the songs, and then - // switch back to our implementation that actually works in normal use. setMediaItems(queue.map { it.toMediaItem() }) shuffleModeEnabled = true val startIndex = @@ -73,11 +73,22 @@ fun ExoPlayer.shuffledQueue(queue: Collection, start: Song?) { seekTo(currentTimeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET) } -fun ExoPlayer.reorder(shuffled: Boolean) { +fun ExoPlayer.applyQueue(rawQueue: RawQueue) { + setMediaItems(rawQueue.heap.map { it.toMediaItem() }) + if (rawQueue.isShuffled) { + shuffleModeEnabled = true + setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) + } else { + shuffleModeEnabled = false + } + seekTo(rawQueue.heapIndex, C.TIME_UNSET) +} + +fun ExoPlayer.shuffled(shuffled: Boolean) { logD("Reordering queue to $shuffled") shuffleModeEnabled = shuffled if (shuffled) { - // Have to manually refresh the shuffle seed. + // Have to manually refresh the shuffle seed and anchor it to the new current songs setShuffleOrder(BetterShuffleOrder(mediaItemCount, currentMediaItemIndex)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 6c973c167..64c5d2ffd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -48,7 +48,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song @@ -59,8 +58,9 @@ import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateHolder import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression +import org.oxycblt.auxio.playback.state.RawQueue import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.StateEvent +import org.oxycblt.auxio.playback.state.StateAck import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE @@ -237,8 +237,6 @@ class PlaybackService : override val isShuffled get() = player.shuffleModeEnabled - override fun resolveIndex() = player.resolveIndex() - override fun resolveQueue() = player.resolveQueue() override val audioSessionId: Int @@ -248,8 +246,7 @@ class PlaybackService : queue: List, start: Song?, parent: MusicParent?, - shuffled: Boolean, - play: Boolean + shuffled: Boolean ) { this.parent = parent if (shuffled) { @@ -258,8 +255,8 @@ class PlaybackService : player.orderedQueue(queue, start) } player.prepare() - player.playWhenReady = play - playbackManager.dispatchEvent(this, StateEvent.NewPlayback) + player.play() + playbackManager.ack(this, StateAck.NewPlayback) } override fun playing(playing: Boolean) { @@ -274,7 +271,7 @@ class PlaybackService : RepeatMode.ALL -> Player.REPEAT_MODE_ALL RepeatMode.TRACK -> Player.REPEAT_MODE_ONE } - playbackManager.dispatchEvent(this, StateEvent.RepeatModeChanged) + playbackManager.ack(this, StateAck.RepeatModeChanged) } override fun seekTo(positionMs: Long) { @@ -283,137 +280,42 @@ class PlaybackService : override fun next() { player.seekToNext() - playbackManager.dispatchEvent(this, StateEvent.IndexMoved) - player.play() + playbackManager.ack(this, StateAck.IndexMoved) } override fun prev() { player.seekToPrevious() - playbackManager.dispatchEvent(this, StateEvent.IndexMoved) - player.play() + playbackManager.ack(this, StateAck.IndexMoved) } override fun goto(index: Int) { player.goto(index) - playbackManager.dispatchEvent(this, StateEvent.IndexMoved) - player.play() + playbackManager.ack(this, StateAck.IndexMoved) } - override fun reorder(shuffled: Boolean) { - player.reorder(shuffled) - playbackManager.dispatchEvent(this, StateEvent.QueueReordered) + override fun shuffled(shuffled: Boolean) { + player.shuffled(shuffled) + playbackManager.ack(this, StateAck.QueueReordered) } - override fun addToQueue(songs: List) { - val insertAt = playbackManager.index + 1 - player.addToQueue(songs) - playbackManager.dispatchEvent( - this, StateEvent.QueueChanged(UpdateInstructions.Add(insertAt, songs.size), false)) - } - - override fun playNext(songs: List) { - val insertAt = playbackManager.index + 1 + override fun playNext(songs: List, ack: StateAck.PlayNext) { player.playNext(songs) - playbackManager.dispatchEvent( - this, StateEvent.QueueChanged(UpdateInstructions.Add(insertAt, songs.size), false)) + playbackManager.ack(this, ack) } - override fun move(from: Int, to: Int) { + override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { + player.addToQueue(songs) + playbackManager.ack(this, ack) + } + + override fun move(from: Int, to: Int, ack: StateAck.Move) { player.move(from, to) - playbackManager.dispatchEvent( - this, StateEvent.QueueChanged(UpdateInstructions.Move(from, to), false)) + playbackManager.ack(this, ack) } - override fun remove(at: Int) { - val oldIndex = player.currentMediaItemIndex + override fun remove(at: Int, ack: StateAck.Remove) { player.remove(at) - val newIndex = player.currentMediaItemIndex - playbackManager.dispatchEvent( - this, StateEvent.QueueChanged(UpdateInstructions.Remove(at, 1), oldIndex != newIndex)) - } - - // --- PLAYER OVERRIDES --- - - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - super.onPlayWhenReadyChanged(playWhenReady, reason) - - if (player.playWhenReady) { - // Mark that we have started playing so that the notification can now be posted. - hasPlayed = true - logD("Player has started playing") - if (!openAudioEffectSession) { - // Convention to start an audioeffect session on play/pause rather than - // start/stop - logD("Opening audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = true - } - } else if (openAudioEffectSession) { - // Make sure to close the audio session when we stop playback. - logD("Closing audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = false - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - super.onMediaItemTransition(mediaItem, reason) - - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || - reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { - playbackManager.dispatchEvent(this, StateEvent.IndexMoved) - } - } - - override fun onEvents(player: Player, events: Player.Events) { - super.onEvents(player, events) - - if (events.containsAny( - Player.EVENT_PLAY_WHEN_READY_CHANGED, - Player.EVENT_IS_PLAYING_CHANGED, - Player.EVENT_POSITION_DISCONTINUITY)) { - logD("Player state changed, must synchronize state") - playbackManager.dispatchEvent(this, StateEvent.ProgressionChanged) - } - } - - override fun onPlayerError(error: PlaybackException) { - // TODO: Replace with no skipping and a notification instead - // If there's any issue, just go to the next song. - logE("Player error occured") - logE(error.stackTraceToString()) - playbackManager.next() - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { - // We now have a library, see if we have anything we need to do. - logD("Library obtained, requesting action") - playbackManager.requestAction(this) - } - } - - // --- OTHER FUNCTIONS --- - - private fun broadcastAudioEffectAction(event: String) { - logD("Broadcasting AudioEffect event: $event") - sendBroadcast( - Intent(event) - .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) - .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) - .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) - } - - private fun stopAndSave() { - // This session has ended, so we need to reset this flag for when the next session starts. - hasPlayed = false - if (foregroundManager.tryStopForeground()) { - // Now that we have ended the foreground state (and thus music playback), we'll need - // to save the current state as it's not long until this service (and likely the whole - // app) is killed. - logD("Saving playback state") - saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) } - } + playbackManager.ack(this, ack) } override fun handleDeferred(action: DeferredPlayback): Boolean { @@ -456,6 +358,97 @@ class PlaybackService : return true } + override fun applySavedState(parent: MusicParent?, rawQueue: RawQueue) { + this.parent = parent + player.applyQueue(rawQueue) + player.prepare() + playbackManager.ack(this, StateAck.NewPlayback) + } + + // --- PLAYER OVERRIDES --- + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + + if (player.playWhenReady) { + // Mark that we have started playing so that the notification can now be posted. + hasPlayed = true + logD("Player has started playing") + if (!openAudioEffectSession) { + // Convention to start an audioeffect session on play/pause rather than + // start/stop + logD("Opening audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = true + } + } else if (openAudioEffectSession) { + // Make sure to close the audio session when we stop playback. + logD("Closing audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = false + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || + reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + playbackManager.ack(this, StateAck.IndexMoved) + } + } + + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + + if (events.containsAny( + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_IS_PLAYING_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY)) { + logD("Player state changed, must synchronize state") + playbackManager.ack(this, StateAck.ProgressionChanged) + } + } + + override fun onPlayerError(error: PlaybackException) { + // TODO: Replace with no skipping and a notification instead + // If there's any issue, just go to the next song. + logE("Player error occured") + logE(error.stackTraceToString()) + playbackManager.next() + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { + // We now have a library, see if we have anything we need to do. + logD("Library obtained, requesting action") + playbackManager.requestAction(this) + } + } + + // --- OTHER FUNCTIONS --- + + private fun broadcastAudioEffectAction(event: String) { + logD("Broadcasting AudioEffect event: $event") + sendBroadcast( + Intent(event) + .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) + .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) + } + + private fun stopAndSave() { + // This session has ended, so we need to reset this flag for when the next session starts. + hasPlayed = false + if (foregroundManager.tryStopForeground()) { + // Now that we have ended the foreground state (and thus music playback), we'll need + // to save the current state as it's not long until this service (and likely the whole + // app) is killed. + logD("Saving playback state") + saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) } + } + } + // --- MEDIASESSIONCOMPONENT OVERRIDES --- override fun onPostNotification(notification: NotificationComponent) { From b2d71f8903ce7f87f3b58118cf13801865e68300 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 Jan 2024 20:26:02 -0700 Subject: [PATCH 05/36] playback: reattach settings to player It's mostly identical to prior, albiet pausing on repeat now leaves the player position at the end of the song rather than the beginning. That's the only regression I couldn't figure out how to resolve. --- .../auxio/playback/PlaybackSettings.kt | 6 +++ .../playback/state/PlaybackStateHolder.kt | 2 - .../playback/state/PlaybackStateManager.kt | 18 ++++---- .../auxio/playback/system/ExoPlayerExt.kt | 11 ----- .../auxio/playback/system/PlaybackService.kt | 46 +++++++++++++++---- 5 files changed, 54 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt index a270c5c07..846a6ae68 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -66,6 +66,8 @@ interface PlaybackSettings : Settings { fun onNotificationActionChanged() {} /** Called when [barAction] has changed. */ fun onBarActionChanged() {} + /** Called when [pauseOnRepeat] has changed. */ + fun onPauseOnRepeatChanged() {} } } @@ -187,6 +189,10 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont logD("Dispatching bar action change") listener.onBarActionChanged() } + getString(R.string.set_key_repeat_pause) -> { + logD("Dispatching pause on repeat change") + listener.onPauseOnRepeatChanged() + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index ef8175755..373e9ff2d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -34,8 +34,6 @@ interface PlaybackStateHolder { fun resolveQueue(): RawQueue - val isShuffled: Boolean - val audioSessionId: Int fun newPlayback(queue: List, start: Song?, parent: MusicParent?, shuffled: Boolean) 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 b38c63c0b..50a5155bb 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 @@ -187,9 +187,9 @@ interface PlaybackStateManager { * [PlaybackStateHolder]. * * @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current - * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder] - * implementation. - * @param ack The [StateAck] to acknowledge. + * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder] + * implementation. + * @param ack The [StateAck] to acknowledge. */ fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) @@ -269,8 +269,8 @@ interface PlaybackStateManager { fun onQueueChanged(queue: List, index: Int, change: QueueChange) {} /** - * Called when the queue has changed in a non-trivial manner (such as re-shuffling), but - * the currently playing [Song] has not. + * 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 songs of the new queue. * @param index The new index of the currently playing [Song]. @@ -407,6 +407,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } this.stateHolder = stateHolder + + // TODO: Re-init player } @Synchronized @@ -627,7 +629,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { ) if (change.type == QueueChange.Type.SONG) { - playing(true) + stateHolder.playing(true) } listeners.forEach { @@ -640,7 +642,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { stateMirror.copy( queue = rawQueue.resolveSongs(), index = rawQueue.resolveIndex(), - isShuffled = stateHolder.isShuffled, + isShuffled = rawQueue.isShuffled, rawQueue = rawQueue) listeners.forEach { it.onQueueReordered( @@ -654,7 +656,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { parent = stateHolder.parent, queue = rawQueue.resolveSongs(), index = rawQueue.resolveIndex(), - isShuffled = stateHolder.isShuffled, + isShuffled = rawQueue.isShuffled, rawQueue = rawQueue) listeners.forEach { it.onNewPlayback( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt index 0ae7ce70f..b5fba9d74 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt @@ -24,7 +24,6 @@ import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.RawQueue -import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD val ExoPlayer.song @@ -33,19 +32,9 @@ val ExoPlayer.song fun ExoPlayer.resolveQueue(): RawQueue { val heap = (0 until mediaItemCount).map { getMediaItemAt(it).song } val shuffledMapping = if (shuffleModeEnabled) unscrambleQueueIndices() else emptyList() - logD(shuffledMapping) return RawQueue(heap, shuffledMapping, currentMediaItemIndex) } -val ExoPlayer.repeat: RepeatMode - get() = - 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.orderedQueue(queue: Collection, start: Song?) { clearMediaItems() shuffleModeEnabled = false 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 64c5d2ffd..6c9a2dfab 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 @@ -88,6 +88,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateHolder, + PlaybackSettings.Listener, MediaSessionComponent.Listener, MusicRepository.UpdateListener { // Player components @@ -156,6 +157,7 @@ class PlaybackService : playbackManager.registerStateHolder(this) musicRepository.addUpdateListener(this) mediaSessionComponent.registerListener(this) + playbackSettings.registerListener(this) val intentFilter = IntentFilter().apply { @@ -197,6 +199,7 @@ class PlaybackService : playbackManager.playing(false) playbackManager.unregisterStateHolder(this) musicRepository.removeUpdateListener(this) + playbackSettings.unregisterListener(this) unregisterReceiver(systemReceiver) serviceJob.cancel() @@ -217,9 +220,6 @@ class PlaybackService : // --- PLAYBACKSTATEHOLDER OVERRIDES --- - override val repeatMode - get() = player.repeat - override val progression: Progression get() = player.song?.let { @@ -232,10 +232,16 @@ class PlaybackService : } ?: Progression.nil() - override var parent: MusicParent? = null + override val repeatMode + get() = + when (val repeatMode = player.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") + } - override val isShuffled - get() = player.shuffleModeEnabled + override var parent: MusicParent? = null override fun resolveQueue() = player.resolveQueue() @@ -272,6 +278,7 @@ class PlaybackService : RepeatMode.TRACK -> Player.REPEAT_MODE_ONE } playbackManager.ack(this, StateAck.RepeatModeChanged) + updatePauseOnRepeat() } override fun seekTo(positionMs: Long) { @@ -284,7 +291,11 @@ class PlaybackService : } override fun prev() { - player.seekToPrevious() + if (playbackSettings.rewindWithPrev) { + player.seekToPrevious() + } else { + player.seekToPreviousMediaItem() + } playbackManager.ack(this, StateAck.IndexMoved) } @@ -398,6 +409,15 @@ class PlaybackService : } } + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + + if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) { + goto(0) + player.pause() + } + } + override fun onEvents(player: Player, events: Player.Events) { super.onEvents(player, events) @@ -418,6 +438,12 @@ class PlaybackService : playbackManager.next() } + // --- OTHER OVERRIDES --- + + override fun onPauseOnRepeatChanged() { + updatePauseOnRepeat() + } + override fun onMusicChanges(changes: MusicRepository.Changes) { if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { // We now have a library, see if we have anything we need to do. @@ -428,6 +454,11 @@ class PlaybackService : // --- OTHER FUNCTIONS --- + private fun updatePauseOnRepeat() { + player.pauseAtEndOfMediaItems = + playbackManager.repeatMode == RepeatMode.TRACK && playbackSettings.pauseOnRepeat + } + private fun broadcastAudioEffectAction(event: String) { logD("Broadcasting AudioEffect event: $event") sendBroadcast( @@ -556,6 +587,5 @@ class PlaybackService : const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" - private const val REWIND_THRESHOLD = 3000L } } From 3267ae98bee69dbe4b011f9aa25f88519ec260df Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 Jan 2024 20:43:51 -0700 Subject: [PATCH 06/36] playback: readd service reinit Make sure the service is properly re-initialized when it dies and is then restarted. --- .../auxio/playback/state/PlaybackStateHolder.kt | 2 +- .../auxio/playback/state/PlaybackStateManager.kt | 16 +++++++++++++--- .../auxio/playback/system/PlaybackService.kt | 8 ++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 373e9ff2d..7b6b16fd1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -62,7 +62,7 @@ interface PlaybackStateHolder { fun handleDeferred(action: DeferredPlayback): Boolean - fun applySavedState(parent: MusicParent?, rawQueue: RawQueue) + fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) } sealed interface StateAck { 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 50a5155bb..3ceb8be59 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 @@ -217,6 +217,11 @@ interface PlaybackStateManager { */ fun playing(isPlaying: Boolean) + /** + * Update the current [RepeatMode]. + * + * @param repeatMode The new [RepeatMode]. + */ fun repeatMode(repeatMode: RepeatMode) /** @@ -407,8 +412,11 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } this.stateHolder = stateHolder - - // TODO: Re-init player + if (isInitialized) { + stateHolder.applySavedState(stateMirror.parent, stateMirror.rawQueue, null) + stateHolder.seekTo(stateMirror.progression.calculateElapsedPositionMs()) + stateHolder.playing(false) + } } @Synchronized @@ -770,12 +778,14 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { if (oldStateMirror.rawQueue != rawQueue) { logD("Queue changed, must reload player") - stateHolder?.applySavedState(parent, rawQueue) + stateHolder?.applySavedState(parent, rawQueue, StateAck.NewPlayback) + stateHolder?.playing(false) } if (oldStateMirror.progression.calculateElapsedPositionMs() != savedState.positionMs) { logD("Seeking to saved position ${savedState.positionMs}ms") stateHolder?.seekTo(savedState.positionMs) + stateHolder?.playing(false) } isInitialized = true 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 6c9a2dfab..9bf6511c1 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 @@ -369,11 +369,15 @@ class PlaybackService : return true } - override fun applySavedState(parent: MusicParent?, rawQueue: RawQueue) { + override fun applySavedState( + parent: MusicParent?, + rawQueue: RawQueue, + ack: StateAck.NewPlayback? + ) { this.parent = parent player.applyQueue(rawQueue) player.prepare() - playbackManager.ack(this, StateAck.NewPlayback) + ack?.let { playbackManager.ack(this, it) } } // --- PLAYER OVERRIDES --- From 48ab83f6de9c4ccf8c83a2ecbfb12fc6baaebe83 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 16:01:53 -0700 Subject: [PATCH 07/36] playback: handle deferred action on startup Dropped this when I ripped out the code prior. --- .../org/oxycblt/auxio/playback/state/PlaybackStateManager.kt | 1 + 1 file changed, 1 insertion(+) 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 3ceb8be59..d24927511 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 @@ -416,6 +416,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { stateHolder.applySavedState(stateMirror.parent, stateMirror.rawQueue, null) stateHolder.seekTo(stateMirror.progression.calculateElapsedPositionMs()) stateHolder.playing(false) + pendingDeferredPlayback?.let(stateHolder::handleDeferred) } } From b2d9b244e5b37022b15f7a00174789b7a3adba87 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 16:02:29 -0700 Subject: [PATCH 08/36] playback: redocument/refactor gapless playback Should complete this feature, save regression fixes. Resolves #110. --- .../playback/state/PlaybackStateHolder.kt | 139 ++++++++++++++ .../playback/state/PlaybackStateManager.kt | 8 +- .../auxio/playback/system/ExoPlayerExt.kt | 176 ------------------ .../auxio/playback/system/PlaybackService.kt | 160 +++++++++++++--- 4 files changed, 277 insertions(+), 206 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 7b6b16fd1..7de33f20c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -25,73 +25,206 @@ import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song +/** + * The designated "source of truth" for the current playback state. Should only be used by + * [PlaybackStateManager], which mirrors a more refined version of the state held here. + * + * @author Alexander Capehart (OxygenCobalt) + */ interface PlaybackStateHolder { + /** The current [Progression] state of the audio player. */ val progression: Progression + /** The current [RepeatMode] of the audio player. */ val repeatMode: RepeatMode + /** The current [MusicParent] being played from. Null if playing from all songs. */ val parent: MusicParent? + /** + * Resolve the current queue state as a [RawQueue]. + * + * @return The current queue state. + */ fun resolveQueue(): RawQueue + /** The current audio session ID of the audio player. */ val audioSessionId: Int + /** + * Applies a completely new playback state to the holder. + * + * @param queue The new queue to use. + * @param start The song to start playback from. Should be in the queue. + * @param parent The parent to play from. + * @param shuffled Whether the queue should be shuffled. + */ fun newPlayback(queue: List, start: Song?, parent: MusicParent?, shuffled: Boolean) + /** + * Update the playing state of the audio player. + * + * @param playing Whether the player should be playing audio. + */ fun playing(playing: Boolean) + /** + * Seek to a position in the current song. + * + * @param positionMs The position to seek to, in milliseconds. + */ fun seekTo(positionMs: Long) + /** + * Update the repeat mode of the audio player. + * + * @param repeatMode The new repeat mode. + */ fun repeatMode(repeatMode: RepeatMode) + /** Go to the next song in the queue. */ fun next() + /** Go to the previous song in the queue. */ fun prev() + /** + * Go to a specific index in the queue. + * + * @param index The index to go to. Should be in the queue. + */ fun goto(index: Int) + /** + * Add songs to the currently playing item in the queue. + * + * @param songs The songs to add. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + */ fun playNext(songs: List, ack: StateAck.PlayNext) + /** + * Add songs to the end of the queue. + * + * @param songs The songs to add. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + */ fun addToQueue(songs: List, ack: StateAck.AddToQueue) + /** + * Move a song in the queue to a new position. + * + * @param from The index of the song to move. + * @param to The index to move the song to. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + */ fun move(from: Int, to: Int, ack: StateAck.Move) + /** + * Remove a song from the queue. + * + * @param at The index of the song to remove. + * @param ack The [StateAck] to return to [PlaybackStateManager]. + * @return The [Song] that was removed. + */ fun remove(at: Int, ack: StateAck.Remove) + /** + * Reorder the queue. + * + * @param shuffled Whether the queue should be shuffled. + */ fun shuffled(shuffled: Boolean) + /** + * Handle a deferred playback action. + * + * @param action The action to handle. + * @return Whether the action could be handled, or if it should be deferred for later. + */ fun handleDeferred(action: DeferredPlayback): Boolean + /** + * Override the current held state with a saved state. + * + * @param parent The parent to play from. + * @param rawQueue The queue to use. + * @param ack The [StateAck] to return to [PlaybackStateManager]. If null, do not return any + * ack. + */ fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) } +/** + * An acknowledgement that the state of the [PlaybackStateHolder] has changed. This is sent back to + * [PlaybackStateManager] once an operation in [PlaybackStateHolder] has completed so that the new + * state can be mirrored to the rest of the application. + * + * @author Alexander Capehart (OxygenCobalt) + */ sealed interface StateAck { + /** + * @see PlaybackStateHolder.next + * @see PlaybackStateHolder.prev + * @see PlaybackStateHolder.goto + */ data object IndexMoved : StateAck + /** @see PlaybackStateHolder.playNext */ data class PlayNext(val at: Int, val size: Int) : StateAck + /** @see PlaybackStateHolder.addToQueue */ data class AddToQueue(val at: Int, val size: Int) : StateAck + /** @see PlaybackStateHolder.move */ data class Move(val from: Int, val to: Int) : StateAck + /** @see PlaybackStateHolder.remove */ data class Remove(val index: Int) : StateAck + /** @see PlaybackStateHolder.shuffled */ data object QueueReordered : StateAck + /** + * @see PlaybackStateHolder.newPlayback + * @see PlaybackStateHolder.applySavedState + */ data object NewPlayback : StateAck + /** + * @see PlaybackStateHolder.playing + * @see PlaybackStateHolder.seekTo + */ data object ProgressionChanged : StateAck + /** @see PlaybackStateHolder.repeatMode */ data object RepeatModeChanged : StateAck } +/** + * The queue as it is represented in the audio player held by [PlaybackStateHolder]. This should not + * be used as anything but a container. Use the provided fields to obtain saner queue information. + * + * @param heap The ordered list of all [Song]s in the queue. + * @param shuffledMapping A list of indices that remap the songs in [heap] into a shuffled queue. + * Empty if the queue is not shuffled. + * @param heapIndex The index of the current song in [heap]. Note that if shuffled, this will be a + * nonsensical value that cannot be used to obtain next and last songs without first resolving the + * queue. + */ data class RawQueue( val heap: List, val shuffledMapping: List, val heapIndex: Int, ) { + /** Whether the queue is currently shuffled. */ val isShuffled = shuffledMapping.isNotEmpty() + /** + * Resolve and return the exact [Song] sequence in the queue. + * + * @return The [Song]s in the queue, in order. + */ fun resolveSongs() = if (isShuffled) { shuffledMapping.map { heap[it] } @@ -99,6 +232,11 @@ data class RawQueue( heap } + /** + * Resolve and return the current index of the queue. + * + * @return The current index of the queue. + */ fun resolveIndex() = if (isShuffled) { shuffledMapping.indexOf(heapIndex) @@ -107,6 +245,7 @@ data class RawQueue( } companion object { + /** Create a blank instance. */ fun nil() = RawQueue(emptyList(), emptyList(), -1) } } 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 d24927511..7cdbe0690 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 @@ -45,19 +45,25 @@ import org.oxycblt.auxio.util.logW * @author Alexander Capehart (OxygenCobalt) */ interface PlaybackStateManager { - /** The current [Progression] state. */ + /** The current [Progression] of the audio player */ val progression: Progression + /** The current [RepeatMode]. */ val repeatMode: RepeatMode + /** The current [MusicParent] being played from */ val parent: MusicParent? + /** The current [Song] being played. Null if nothing is playing. */ val currentSong: Song? + /** The current queue of [Song]s. */ val queue: List + /** The index of the currently playing [Song] in the queue. */ val index: Int + /** Whether the queue is shuffled or not. */ val isShuffled: Boolean /** The audio session ID of the internal player. Null if no internal player exists. */ 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 deleted file mode 100644 index b5fba9d74..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/ExoPlayerExt.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * 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 org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.state.RawQueue -import org.oxycblt.auxio.util.logD - -val ExoPlayer.song - get() = currentMediaItem?.song - -fun ExoPlayer.resolveQueue(): RawQueue { - val heap = (0 until mediaItemCount).map { getMediaItemAt(it).song } - val shuffledMapping = if (shuffleModeEnabled) unscrambleQueueIndices() else emptyList() - return RawQueue(heap, shuffledMapping, currentMediaItemIndex) -} - -fun ExoPlayer.orderedQueue(queue: Collection, start: Song?) { - clearMediaItems() - shuffleModeEnabled = false - setMediaItems(queue.map { it.toMediaItem() }) - if (start != null) { - 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?) { - 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) -} - -fun ExoPlayer.applyQueue(rawQueue: RawQueue) { - setMediaItems(rawQueue.heap.map { it.toMediaItem() }) - if (rawQueue.isShuffled) { - shuffleModeEnabled = true - setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) - } else { - shuffleModeEnabled = false - } - seekTo(rawQueue.heapIndex, C.TIME_UNSET) -} - -fun ExoPlayer.shuffled(shuffled: Boolean) { - logD("Reordering queue to $shuffled") - shuffleModeEnabled = shuffled - if (shuffled) { - // Have to manually refresh the shuffle seed and anchor it to the new current songs - setShuffleOrder(BetterShuffleOrder(mediaItemCount, currentMediaItemIndex)) - } -} - -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 indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[index] - seekTo(trueIndex, C.TIME_UNSET) -} - -fun ExoPlayer.move(from: Int, to: Int) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueFrom = indices[from] - val trueTo = indices[to] - - when { - trueFrom > trueTo -> { - moveMediaItem(trueFrom, trueTo) - moveMediaItem(trueTo + 1, trueFrom) - } - trueTo > trueFrom -> { - moveMediaItem(trueFrom, trueTo) - moveMediaItem(trueTo - 1, trueFrom) - } - } -} - -fun ExoPlayer.remove(at: Int) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[at] - removeMediaItem(trueIndex) -} - -fun ExoPlayer.unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty()) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(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(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, 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/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 9bf6511c1..168514c01 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 @@ -222,13 +222,14 @@ class PlaybackService : override val progression: Progression get() = - player.song?.let { + player.currentMediaItem?.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)) + player.currentPosition.coerceAtLeast(0) + .coerceAtMost(it.song.durationMs)) } ?: Progression.nil() @@ -243,7 +244,15 @@ class PlaybackService : override var parent: MusicParent? = null - override fun resolveQueue() = player.resolveQueue() + override fun resolveQueue(): RawQueue { + val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).song } + val shuffledMapping = if (player.shuffleModeEnabled) { + player.unscrambleQueueIndices() + } else { + emptyList() + } + return RawQueue(heap, shuffledMapping, player.currentMediaItemIndex) + } override val audioSessionId: Int get() = player.audioSessionId @@ -255,11 +264,18 @@ class PlaybackService : shuffled: Boolean ) { this.parent = parent + player.shuffleModeEnabled = shuffled + player.setMediaItems(queue.map { it.toMediaItem() }) + val startIndex = + start + ?.let { queue.indexOf(start) } + .also { check(it != -1) { "Start song not in queue" } } if (shuffled) { - player.shuffledQueue(queue, start) - } else { - player.orderedQueue(queue, start) + player.setShuffleOrder(BetterShuffleOrder(queue.size, startIndex ?: -1)) } + val target = + startIndex ?: player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled) + player.seekTo(target, C.TIME_UNSET) player.prepare() player.play() playbackManager.ack(this, StateAck.NewPlayback) @@ -300,32 +316,67 @@ class PlaybackService : } override fun goto(index: Int) { - player.goto(index) + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[index] + player.seekTo(trueIndex, C.TIME_UNSET) playbackManager.ack(this, StateAck.IndexMoved) } override fun shuffled(shuffled: Boolean) { - player.shuffled(shuffled) + logD("Reordering queue to $shuffled") + player.shuffleModeEnabled = shuffled + if (shuffled) { + // Have to manually refresh the shuffle seed and anchor it to the new current songs + player.setShuffleOrder( + BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) + } playbackManager.ack(this, StateAck.QueueReordered) } override fun playNext(songs: List, ack: StateAck.PlayNext) { - player.playNext(songs) + player.addMediaItems(player.nextMediaItemIndex, songs.map { it.toMediaItem() }) playbackManager.ack(this, ack) } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addToQueue(songs) + player.addMediaItems(songs.map { it.toMediaItem() }) playbackManager.ack(this, ack) } override fun move(from: Int, to: Int, ack: StateAck.Move) { - player.move(from, to) + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueFrom = indices[from] + val trueTo = indices[to] + + when { + trueFrom > trueTo -> { + player.moveMediaItem(trueFrom, trueTo) + player.moveMediaItem(trueTo + 1, trueFrom) + } + trueTo > trueFrom -> { + player.moveMediaItem(trueFrom, trueTo) + player.moveMediaItem(trueTo - 1, trueFrom) + } + } playbackManager.ack(this, ack) } override fun remove(at: Int, ack: StateAck.Remove) { - player.remove(at) + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[at] + player.removeMediaItem(trueIndex) playbackManager.ack(this, ack) } @@ -375,7 +426,14 @@ class PlaybackService : ack: StateAck.NewPlayback? ) { this.parent = parent - player.applyQueue(rawQueue) + player.setMediaItems(rawQueue.heap.map { it.toMediaItem() }) + if (rawQueue.isShuffled) { + player.shuffleModeEnabled = true + player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) + } else { + player.shuffleModeEnabled = false + } + player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) player.prepare() ack?.let { playbackManager.ack(this, it) } } @@ -456,13 +514,72 @@ class PlaybackService : } } - // --- OTHER FUNCTIONS --- + override fun onPostNotification(notification: NotificationComponent) { + // Do not post the notification if playback hasn't started yet. This prevents errors + // where changing a setting would cause the notification to appear in an unfriendly + // manner. + if (hasPlayed) { + logD("Played before, starting foreground state") + if (!foregroundManager.tryStartForeground(notification)) { + logD("Notification changed, re-posting") + notification.post() + } + } + } + + // --- PLAYER MANAGEMENT --- private fun updatePauseOnRepeat() { player.pauseAtEndOfMediaItems = playbackManager.repeatMode == RepeatMode.TRACK && playbackSettings.pauseOnRepeat } + private fun ExoPlayer.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty()) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(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(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue + } + + private fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build() + + private val MediaItem.song: Song + get() = requireNotNull(localConfiguration).tag as Song + + // --- OTHER FUNCTIONS --- + private fun broadcastAudioEffectAction(event: String) { logD("Broadcasting AudioEffect event: $event") sendBroadcast( @@ -484,21 +601,6 @@ class PlaybackService : } } - // --- MEDIASESSIONCOMPONENT OVERRIDES --- - - override fun onPostNotification(notification: NotificationComponent) { - // Do not post the notification if playback hasn't started yet. This prevents errors - // where changing a setting would cause the notification to appear in an unfriendly - // manner. - if (hasPlayed) { - logD("Played before, starting foreground state") - if (!foregroundManager.tryStartForeground(notification)) { - logD("Notification changed, re-posting") - notification.post() - } - } - } - /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require * an active [IntentFilter] to be registered. From 1766283cd263e28bc990405414c7420bdc4c4200 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 16:15:09 -0700 Subject: [PATCH 09/36] playback: save playback state on every change Prior, I was saving when the service was closed, which is a stupid decision and caused a lot of unreliability. Resolves #404. --- .../auxio/playback/system/PlaybackService.kt | 74 ++++++++++++++----- .../auxio/settings/RootPreferenceFragment.kt | 35 --------- app/src/main/res/xml/preferences_root.xml | 20 +---- 3 files changed, 55 insertions(+), 74 deletions(-) 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 168514c01..0981a24ee 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 @@ -44,8 +44,10 @@ import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.music.MusicParent @@ -117,6 +119,7 @@ class PlaybackService : private val serviceJob = Job() private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO) private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO) + private var currentSaveJob: Job? = null // --- SERVICE OVERRIDES --- @@ -228,8 +231,7 @@ class PlaybackService : 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.song.durationMs)) + player.currentPosition.coerceAtLeast(0).coerceAtMost(it.song.durationMs)) } ?: Progression.nil() @@ -246,11 +248,12 @@ class PlaybackService : override fun resolveQueue(): RawQueue { val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).song } - val shuffledMapping = if (player.shuffleModeEnabled) { - player.unscrambleQueueIndices() - } else { - emptyList() - } + val shuffledMapping = + if (player.shuffleModeEnabled) { + player.unscrambleQueueIndices() + } else { + emptyList() + } return RawQueue(heap, shuffledMapping, player.currentMediaItemIndex) } @@ -279,11 +282,13 @@ class PlaybackService : player.prepare() player.play() playbackManager.ack(this, StateAck.NewPlayback) + deferSave() } override fun playing(playing: Boolean) { player.playWhenReady = playing // Dispatched later once all of the changes have been accumulated + // Playing state is not persisted, do not need to save } override fun repeatMode(repeatMode: RepeatMode) { @@ -295,15 +300,19 @@ class PlaybackService : } playbackManager.ack(this, StateAck.RepeatModeChanged) updatePauseOnRepeat() + deferSave() } override fun seekTo(positionMs: Long) { player.seekTo(positionMs) + // Dispatched later once all of the changes have been accumulated + // Deferred save is handled on position discontinuity } override fun next() { player.seekToNext() playbackManager.ack(this, StateAck.IndexMoved) + // Deferred save is handled on position discontinuity } override fun prev() { @@ -313,6 +322,7 @@ class PlaybackService : player.seekToPreviousMediaItem() } playbackManager.ack(this, StateAck.IndexMoved) + // Deferred save is handled on position discontinuity } override fun goto(index: Int) { @@ -324,6 +334,7 @@ class PlaybackService : val trueIndex = indices[index] player.seekTo(trueIndex, C.TIME_UNSET) playbackManager.ack(this, StateAck.IndexMoved) + // Deferred save is handled on position discontinuity } override fun shuffled(shuffled: Boolean) { @@ -335,16 +346,19 @@ class PlaybackService : BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) } playbackManager.ack(this, StateAck.QueueReordered) + deferSave() } override fun playNext(songs: List, ack: StateAck.PlayNext) { player.addMediaItems(player.nextMediaItemIndex, songs.map { it.toMediaItem() }) playbackManager.ack(this, ack) + deferSave() } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { player.addMediaItems(songs.map { it.toMediaItem() }) playbackManager.ack(this, ack) + deferSave() } override fun move(from: Int, to: Int, ack: StateAck.Move) { @@ -367,6 +381,7 @@ class PlaybackService : } } playbackManager.ack(this, ack) + deferSave() } override fun remove(at: Int, ack: StateAck.Remove) { @@ -378,6 +393,7 @@ class PlaybackService : val trueIndex = indices[at] player.removeMediaItem(trueIndex) playbackManager.ack(this, ack) + deferSave() } override fun handleDeferred(action: DeferredPlayback): Boolean { @@ -480,6 +496,17 @@ class PlaybackService : } } + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + deferSave() + } + } + override fun onEvents(player: Player, events: Player.Events) { super.onEvents(player, events) @@ -580,6 +607,21 @@ class PlaybackService : // --- OTHER FUNCTIONS --- + private fun deferSave() { + currentSaveJob?.let { + logD("Discarding prior save job") + it.cancel() + } + currentSaveJob = + saveScope.launch { + logD("Waiting for save buffer") + delay(SAVE_BUFFER) + yield() + logD("Committing saved state") + persistenceRepository.saveState(playbackManager.toSavedState()) + } + } + private fun broadcastAudioEffectAction(event: String) { logD("Broadcasting AudioEffect event: $event") sendBroadcast( @@ -589,18 +631,6 @@ class PlaybackService : .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) } - private fun stopAndSave() { - // This session has ended, so we need to reset this flag for when the next session starts. - hasPlayed = false - if (foregroundManager.tryStopForeground()) { - // Now that we have ended the foreground state (and thus music playback), we'll need - // to save the current state as it's not long until this service (and likely the whole - // app) is killed. - logD("Saving playback state") - saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) } - } - } - /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require * an active [IntentFilter] to be registered. @@ -657,7 +687,10 @@ class PlaybackService : ACTION_EXIT -> { logD("Received exit event") playbackManager.playing(false) - stopAndSave() + // This session has ended, so we need to reset this flag for when the next + // session starts. + hasPlayed = false + foregroundManager.tryStopForeground() } WidgetProvider.ACTION_WIDGET_UPDATE -> { logD("Received widget update event") @@ -687,6 +720,7 @@ class PlaybackService : } companion object { + const val SAVE_BUFFER = 5000L const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt index 4149cc0f5..310e36155 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt @@ -84,41 +84,6 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { } getString(R.string.set_key_reindex) -> musicModel.refresh() getString(R.string.set_key_rescan) -> musicModel.rescan() - getString(R.string.set_key_save_state) -> { - playbackModel.savePlaybackState { saved -> - // Use the nullable context, as we could try to show a toast when this - // fragment is no longer attached. - logD("Showing saving confirmation") - if (saved) { - context?.showToast(R.string.lbl_state_saved) - } else { - context?.showToast(R.string.err_did_not_save) - } - } - } - getString(R.string.set_key_wipe_state) -> { - playbackModel.wipePlaybackState { wiped -> - logD("Showing wipe confirmation") - if (wiped) { - // Use the nullable context, as we could try to show a toast when this - // fragment is no longer attached. - context?.showToast(R.string.lbl_state_wiped) - } else { - context?.showToast(R.string.err_did_not_wipe) - } - } - } - getString(R.string.set_key_restore_state) -> - playbackModel.tryRestorePlaybackState { restored -> - logD("Showing restore confirmation") - if (restored) { - // Use the nullable context, as we could try to show a toast when this - // fragment is no longer attached. - context?.showToast(R.string.lbl_state_restored) - } else { - context?.showToast(R.string.err_did_not_restore) - } - } else -> return super.onPreferenceTreeClick(preference) } diff --git a/app/src/main/res/xml/preferences_root.xml b/app/src/main/res/xml/preferences_root.xml index 980ce2c9c..114c4ca29 100644 --- a/app/src/main/res/xml/preferences_root.xml +++ b/app/src/main/res/xml/preferences_root.xml @@ -44,23 +44,5 @@ app:title="@string/set_rescan" /> - - - - - - - - - - + \ No newline at end of file From a44f0bce353e00e9120ee72f1dd907c2175ecc3e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 16:23:04 -0700 Subject: [PATCH 10/36] playback: stop playback on task removal if paused This is apparently the standard behavior that media apps should use to allow the foreground state to be exited. I personally don't want to make it really unilateral like that, so if playback is already ongoing I'll keep the foreground state going. --- .../auxio/playback/system/PlaybackService.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 0981a24ee..72c0e9de4 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 @@ -191,7 +191,12 @@ class PlaybackService : override fun onBind(intent: Intent): IBinder? = null - // TODO: Implement task removal (Have to radically alter state saving to occur at runtime) + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (!playbackManager.progression.isPlaying) { + endSession() + } + } override fun onDestroy() { super.onDestroy() @@ -631,6 +636,13 @@ class PlaybackService : .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) } + private fun endSession() { + // This session has ended, so we need to reset this flag for when the next + // session starts. + hasPlayed = false + foregroundManager.tryStopForeground() + } + /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require * an active [IntentFilter] to be registered. @@ -687,10 +699,7 @@ class PlaybackService : ACTION_EXIT -> { logD("Received exit event") playbackManager.playing(false) - // This session has ended, so we need to reset this flag for when the next - // session starts. - hasPlayed = false - foregroundManager.tryStopForeground() + endSession() } WidgetProvider.ACTION_WIDGET_UPDATE -> { logD("Received widget update event") From 3e79f317929c7dcb08503d37afb62899029a09cd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 16:45:44 -0700 Subject: [PATCH 11/36] playback: add remember pause setting This setting allows you to remain playing/paused when you move in the queue or edit the queue. Useful for some people who like this behavior more than always playing. Resolves #568. --- .../org/oxycblt/auxio/playback/PlaybackSettings.kt | 5 +++++ .../auxio/playback/state/PlaybackStateManager.kt | 7 ------- .../auxio/playback/system/PlaybackService.kt | 13 +++++++++++++ .../auxio/settings/RootPreferenceFragment.kt | 1 - app/src/main/res/values/settings.xml | 4 +--- app/src/main/res/values/strings.xml | 2 ++ app/src/main/res/xml/preferences_audio.xml | 6 ++++++ app/src/main/res/xml/preferences_root.xml | 2 +- 8 files changed, 28 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt index 846a6ae68..9899477c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -58,6 +58,8 @@ interface PlaybackSettings : Settings { val rewindWithPrev: Boolean /** Whether a song should pause after every repeat. */ val pauseOnRepeat: Boolean + /** Whether to maintain the play/pause state when skipping or editing the queue */ + val rememberPause: Boolean interface Listener { /** Called when one of the ReplayGain configurations have changed. */ @@ -129,6 +131,9 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont override val pauseOnRepeat: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false) + override val rememberPause: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_remember_pause), false) + override fun migrate() { // MusicMode was converted to PlaySong in 3.2.0 fun Int.migrateMusicMode() = 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 7cdbe0690..f03884f8d 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 @@ -456,7 +456,6 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val stateHolder = stateHolder ?: return logD("Going to next song") stateHolder.next() - stateHolder.playing(true) } @Synchronized @@ -464,7 +463,6 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val stateHolder = stateHolder ?: return logD("Going to previous song") stateHolder.prev() - stateHolder.playing(true) } @Synchronized @@ -472,7 +470,6 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val stateHolder = stateHolder ?: return logD("Going to index $index") stateHolder.goto(index) - stateHolder.playing(true) } @Synchronized @@ -643,10 +640,6 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { rawQueue = rawQueue, ) - if (change.type == QueueChange.Type.SONG) { - stateHolder.playing(true) - } - listeners.forEach { it.onQueueChanged(stateMirror.queue, stateMirror.index, change) } 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 72c0e9de4..637c62286 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 @@ -316,6 +316,9 @@ class PlaybackService : override fun next() { player.seekToNext() + if (!playbackSettings.rememberPause) { + player.play() + } playbackManager.ack(this, StateAck.IndexMoved) // Deferred save is handled on position discontinuity } @@ -326,6 +329,9 @@ class PlaybackService : } else { player.seekToPreviousMediaItem() } + if (!playbackSettings.rememberPause) { + player.play() + } playbackManager.ack(this, StateAck.IndexMoved) // Deferred save is handled on position discontinuity } @@ -338,6 +344,9 @@ class PlaybackService : val trueIndex = indices[index] player.seekTo(trueIndex, C.TIME_UNSET) + if (!playbackSettings.rememberPause) { + player.play() + } playbackManager.ack(this, StateAck.IndexMoved) // Deferred save is handled on position discontinuity } @@ -396,7 +405,11 @@ class PlaybackService : } val trueIndex = indices[at] + val songWillChange = player.currentMediaItemIndex == trueIndex player.removeMediaItem(trueIndex) + if (songWillChange && !playbackSettings.rememberPause) { + player.play() + } playbackManager.ack(this, ack) deferSave() } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt index 310e36155..78961662f 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt @@ -32,7 +32,6 @@ import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.settings.ui.WrappedDialogPreference import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe -import org.oxycblt.auxio.util.showToast /** * The [PreferenceFragmentCompat] that displays the root settings list. diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index eb07550e3..f5c45132a 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -32,9 +32,7 @@ KEY_KEEP_SHUFFLE KEY_PREV_REWIND KEY_LOOP_PAUSE - auxio_save_state - auxio_wipe_state - auxio_restore_state + auxio_remember_pause auxio_home_tabs auxio_hide_collaborators diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 241b71838..5a465a49c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -288,6 +288,8 @@ Rewind before skipping to the previous song Pause on repeat Pause when a song repeats + Remember pause + Remain playing/paused when skipping or editing queue ReplayGain ReplayGain strategy Prefer track diff --git a/app/src/main/res/xml/preferences_audio.xml b/app/src/main/res/xml/preferences_audio.xml index ba4402385..9768fe3dd 100644 --- a/app/src/main/res/xml/preferences_audio.xml +++ b/app/src/main/res/xml/preferences_audio.xml @@ -21,6 +21,12 @@ app:summary="@string/set_repeat_pause_desc" app:title="@string/set_repeat_pause" /> + + diff --git a/app/src/main/res/xml/preferences_root.xml b/app/src/main/res/xml/preferences_root.xml index 114c4ca29..64b8ebafc 100644 --- a/app/src/main/res/xml/preferences_root.xml +++ b/app/src/main/res/xml/preferences_root.xml @@ -44,5 +44,5 @@ app:title="@string/set_rescan" /> - + \ No newline at end of file From 195498879a0d9c17ddb14b6792d251bc41ba4a51 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 16:47:24 -0700 Subject: [PATCH 12/36] info: update changelog --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a04afeb75..adecf82f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## dev + +#### What's New +- Gapless playback is now used whenever possible +- Added "Remember pause" setting that makes remain paused when skipping +or editing queue + +#### What's Improved +- The playback state is now saved more often, improving persistence +- The queue is now fully circular when repeat all is enabled + +#### What's Changed +- You can no longer save, restore, or clear the playback state +- The playback session now ends if you swipe away the app while it's paused + ## 3.3.3 #### What's Fixed From 881df0fc02af673ba26461dbcff40383f224de66 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 20:47:21 -0700 Subject: [PATCH 13/36] widgets: add 1x3/1x4 widget Add a 1x3/1x4 widget that displays the cover and controls Also requires another widget type that just displays controls to accomodate landscape devices. Resolves #420. --- CHANGELOG.md | 1 + .../oxycblt/auxio/widgets/WidgetComponent.kt | 1 + .../oxycblt/auxio/widgets/WidgetProvider.kt | 93 +++++++++---- .../org/oxycblt/auxio/widgets/WidgetUtil.kt | 11 -- .../ui_widget_rectangle_button_bg.xml | 6 + .../drawable/ic_remote_default_cover_24.xml | 15 +- .../drawable/ui_widget_circle_button_bg.xml | 5 + .../ui_widget_rectangle_button_bg.xml | 6 + ...idget_small.xml => widget_docked_thin.xml} | 0 ...widget_wide.xml => widget_docked_wide.xml} | 0 ...widget_medium.xml => widget_pane_thin.xml} | 0 ...{widget_large.xml => widget_pane_wide.xml} | 0 app/src/main/res/layout/widget_stick_thin.xml | 55 ++++++++ app/src/main/res/layout/widget_stick_wide.xml | 84 ++++++++++++ app/src/main/res/layout/widget_thin.xml | 106 --------------- app/src/main/res/layout/widget_wafer_thin.xml | 85 ++++++++++++ app/src/main/res/layout/widget_wafer_wide.xml | 128 ++++++++++++++++++ app/src/main/res/xml-v31/widget_info.xml | 4 +- app/src/main/res/xml/widget_info.xml | 2 +- 19 files changed, 451 insertions(+), 151 deletions(-) create mode 100644 app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml create mode 100644 app/src/main/res/drawable/ui_widget_circle_button_bg.xml create mode 100644 app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml rename app/src/main/res/layout/{widget_small.xml => widget_docked_thin.xml} (100%) rename app/src/main/res/layout/{widget_wide.xml => widget_docked_wide.xml} (100%) rename app/src/main/res/layout/{widget_medium.xml => widget_pane_thin.xml} (100%) rename app/src/main/res/layout/{widget_large.xml => widget_pane_wide.xml} (100%) create mode 100644 app/src/main/res/layout/widget_stick_thin.xml create mode 100644 app/src/main/res/layout/widget_stick_wide.xml delete mode 100644 app/src/main/res/layout/widget_thin.xml create mode 100644 app/src/main/res/layout/widget_wafer_thin.xml create mode 100644 app/src/main/res/layout/widget_wafer_wide.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index adecf82f2..4688896cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Gapless playback is now used whenever possible - Added "Remember pause" setting that makes remain paused when skipping or editing queue +- Added 1x4 and 1x3 widget forms #### What's Improved - The playback state is now saved more often, improving persistence 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 d67892300..9280d2ea2 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -167,6 +167,7 @@ constructor( * * @param song [Queue.currentSong] * @param cover A pre-loaded album cover [Bitmap] for [song]. + * @param cover A pre-loaded album cover [Bitmap] for [song], with rounded corners. * @param isPlaying [PlaybackStateManager.playerState] * @param repeatMode [PlaybackStateManager.repeatMode] * @param isShuffled [Queue.isShuffled] diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 153b7bccf..7a3bc6c40 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -91,11 +91,14 @@ class WidgetProvider : AppWidgetProvider() { // the widget elements, plus some leeway for text sizing. val views = mapOf( - SizeF(180f, 100f) to newThinLayout(context, uiSettings, state), - SizeF(180f, 152f) to newSmallLayout(context, uiSettings, state), - SizeF(272f, 152f) to newWideLayout(context, uiSettings, state), - SizeF(180f, 272f) to newMediumLayout(context, uiSettings, state), - SizeF(272f, 272f) to newLargeLayout(context, uiSettings, state)) + SizeF(180f, 48f) to newThinStickLayout(context, state), + SizeF(304f, 48f) to newWideStickLayout(context, state), + SizeF(180f, 100f) to newThinWaferLayout(context, uiSettings, state), + SizeF(304f, 100f) to newWideWaferLayout(context, uiSettings, state), + SizeF(180f, 152f) to newThinDockedLayout(context, uiSettings, state), + SizeF(304f, 152f) to newWideDockedLayout(context, uiSettings, state), + SizeF(180f, 272f) to newThinPaneLayout(context, uiSettings, state), + SizeF(304f, 272f) to newWidePaneLayout(context, uiSettings, state)) // Manually update AppWidgetManager with the new views. val awm = AppWidgetManager.getInstance(context) @@ -139,60 +142,78 @@ class WidgetProvider : AppWidgetProvider() { private fun newDefaultLayout(context: Context) = newRemoteViews(context, R.layout.widget_default) - private fun newThinLayout( + private fun newThinStickLayout(context: Context, state: WidgetComponent.PlaybackState) = + newRemoteViews(context, R.layout.widget_stick_thin).setupTimelineControls(context, state) + + private fun newWideStickLayout(context: Context, state: WidgetComponent.PlaybackState) = + newRemoteViews(context, R.layout.widget_stick_wide).setupFullControls(context, state) + + private fun newThinWaferLayout( context: Context, uiSettings: UISettings, state: WidgetComponent.PlaybackState ) = - newRemoteViews(context, R.layout.widget_thin) + newRemoteViews(context, R.layout.widget_wafer_thin) .setupBackground( uiSettings, ) - .setupPlaybackState(context, state) + .setupCover(context, state.takeIf { canDisplayWaferCover(uiSettings) }) .setupTimelineControls(context, state) - private fun newSmallLayout( + private fun newWideWaferLayout( context: Context, uiSettings: UISettings, state: WidgetComponent.PlaybackState ) = - newRemoteViews(context, R.layout.widget_small) + newRemoteViews(context, R.layout.widget_wafer_wide) + .setupBackground( + uiSettings, + ) + .setupCover(context, state.takeIf { canDisplayWaferCover(uiSettings) }) + .setupFullControls(context, state) + + private fun newThinDockedLayout( + context: Context, + uiSettings: UISettings, + state: WidgetComponent.PlaybackState + ) = + newRemoteViews(context, R.layout.widget_docked_thin) .setupBar( uiSettings, ) .setupCover(context, state) .setupTimelineControls(context, state) - private fun newMediumLayout( + private fun newWideDockedLayout( context: Context, uiSettings: UISettings, state: WidgetComponent.PlaybackState ) = - newRemoteViews(context, R.layout.widget_medium) - .setupBackground( - uiSettings, - ) - .setupPlaybackState(context, state) - .setupTimelineControls(context, state) - - private fun newWideLayout( - context: Context, - uiSettings: UISettings, - state: WidgetComponent.PlaybackState - ) = - newRemoteViews(context, R.layout.widget_wide) + newRemoteViews(context, R.layout.widget_docked_wide) .setupBar( uiSettings, ) .setupCover(context, state) .setupFullControls(context, state) - private fun newLargeLayout( + private fun newThinPaneLayout( context: Context, uiSettings: UISettings, state: WidgetComponent.PlaybackState ) = - newRemoteViews(context, R.layout.widget_large) + newRemoteViews(context, R.layout.widget_pane_thin) + .setupBackground( + uiSettings, + ) + .setupPlaybackState(context, state) + .setupTimelineControls(context, state) + + private fun newWidePaneLayout( + context: Context, + uiSettings: UISettings, + state: WidgetComponent.PlaybackState + ) = + newRemoteViews(context, R.layout.widget_pane_wide) .setupBackground( uiSettings, ) @@ -246,8 +267,14 @@ class WidgetProvider : AppWidgetProvider() { */ private fun RemoteViews.setupCover( context: Context, - state: WidgetComponent.PlaybackState + state: WidgetComponent.PlaybackState? ): RemoteViews { + if (state == null) { + setImageViewBitmap(R.id.widget_cover, null) + setContentDescription(R.id.widget_cover, null) + return this + } + if (state.cover != null) { setImageViewBitmap(R.id.widget_cover, state.cover) setContentDescription( @@ -388,6 +415,18 @@ class WidgetProvider : AppWidgetProvider() { return this } + private fun useRoundedRemoteViews(uiSettings: UISettings) = + uiSettings.roundMode || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + private fun canDisplayWaferCover(uiSettings: UISettings) = + // We cannot display album covers in the wafer-style widget when round mode is enabled + // below Android 12, as: + // - We cannot rely on system widget corner clipping, like on Android 12+ + // - We cannot manually clip the widget ourselves due to broken clipToOutline support + // - We cannot determine the exact widget height that would allow us to clip the loaded + // image itself + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || !uiSettings.roundMode + companion object { /** * Broadcast when [WidgetProvider] desires to update it's widget with new information. diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt index cd7151b13..799aa8a67 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt @@ -28,7 +28,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.LayoutRes import kotlin.math.sqrt -import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent @@ -139,13 +138,3 @@ fun AppWidgetManager.updateAppWidgetCompat( } } } - -/** - * Returns whether rounded UI elements are appropriate for the widget, either based on the current - * settings or if the widget has to fit in aesthetically with other widgets. - * - * @param [uiSettings] [UISettings] required to obtain round mode configuration. - * @return true if to use round mode, false otherwise. - */ -fun useRoundedRemoteViews(uiSettings: UISettings) = - uiSettings.roundMode || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml b/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml new file mode 100644 index 000000000..feba7d550 --- /dev/null +++ b/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_remote_default_cover_24.xml b/app/src/main/res/drawable/ic_remote_default_cover_24.xml index fa860ad09..d849d7106 100644 --- a/app/src/main/res/drawable/ic_remote_default_cover_24.xml +++ b/app/src/main/res/drawable/ic_remote_default_cover_24.xml @@ -6,8 +6,15 @@ android:viewportHeight="24"> - + android:pathData="M 2.6000008,9.3143836e-7 H 21.399999 c 1.4404,0 2.6,1.15959996856164 2.6,2.59999986856164 V 21.399999 c 0,1.4404 -1.1596,2.6 -2.6,2.6 H 2.6000008 c -1.4403999,0 -2.59999986856164,-1.1596 -2.59999986856164,-2.6 V 2.6000008 C 9.3143836e-7,1.1596009 1.1596009,9.3143836e-7 2.6000008,9.3143836e-7 Z" /> + + + + diff --git a/app/src/main/res/drawable/ui_widget_circle_button_bg.xml b/app/src/main/res/drawable/ui_widget_circle_button_bg.xml new file mode 100644 index 000000000..75c293f4b --- /dev/null +++ b/app/src/main/res/drawable/ui_widget_circle_button_bg.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml b/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml new file mode 100644 index 000000000..b03eb6f1c --- /dev/null +++ b/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_small.xml b/app/src/main/res/layout/widget_docked_thin.xml similarity index 100% rename from app/src/main/res/layout/widget_small.xml rename to app/src/main/res/layout/widget_docked_thin.xml diff --git a/app/src/main/res/layout/widget_wide.xml b/app/src/main/res/layout/widget_docked_wide.xml similarity index 100% rename from app/src/main/res/layout/widget_wide.xml rename to app/src/main/res/layout/widget_docked_wide.xml diff --git a/app/src/main/res/layout/widget_medium.xml b/app/src/main/res/layout/widget_pane_thin.xml similarity index 100% rename from app/src/main/res/layout/widget_medium.xml rename to app/src/main/res/layout/widget_pane_thin.xml diff --git a/app/src/main/res/layout/widget_large.xml b/app/src/main/res/layout/widget_pane_wide.xml similarity index 100% rename from app/src/main/res/layout/widget_large.xml rename to app/src/main/res/layout/widget_pane_wide.xml diff --git a/app/src/main/res/layout/widget_stick_thin.xml b/app/src/main/res/layout/widget_stick_thin.xml new file mode 100644 index 000000000..776d26525 --- /dev/null +++ b/app/src/main/res/layout/widget_stick_thin.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_stick_wide.xml b/app/src/main/res/layout/widget_stick_wide.xml new file mode 100644 index 000000000..ff5a97d6b --- /dev/null +++ b/app/src/main/res/layout/widget_stick_wide.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_thin.xml b/app/src/main/res/layout/widget_thin.xml deleted file mode 100644 index a3201fc54..000000000 --- a/app/src/main/res/layout/widget_thin.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/widget_wafer_thin.xml b/app/src/main/res/layout/widget_wafer_thin.xml new file mode 100644 index 000000000..fe7ec01dc --- /dev/null +++ b/app/src/main/res/layout/widget_wafer_thin.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_wafer_wide.xml b/app/src/main/res/layout/widget_wafer_wide.xml new file mode 100644 index 000000000..f32d97b4b --- /dev/null +++ b/app/src/main/res/layout/widget_wafer_wide.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml-v31/widget_info.xml b/app/src/main/res/xml-v31/widget_info.xml index 11fcafb44..1d12aaff8 100644 --- a/app/src/main/res/xml-v31/widget_info.xml +++ b/app/src/main/res/xml-v31/widget_info.xml @@ -4,10 +4,10 @@ android:initialLayout="@layout/widget_default" android:minWidth="@dimen/widget_def_width" android:minHeight="@dimen/widget_def_height" + android:minResizeHeight="0dp" android:minResizeWidth="@dimen/widget_def_width" - android:minResizeHeight="@dimen/widget_def_height" android:previewImage="@drawable/ui_widget_preview" - android:previewLayout="@layout/widget_small" + android:previewLayout="@layout/widget_docked_thin" android:resizeMode="horizontal|vertical" android:targetCellWidth="3" android:targetCellHeight="2" diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml index 84ac71fc7..35608e938 100644 --- a/app/src/main/res/xml/widget_info.xml +++ b/app/src/main/res/xml/widget_info.xml @@ -3,8 +3,8 @@ android:initialLayout="@layout/widget_default" android:minWidth="@dimen/widget_def_width" android:minHeight="@dimen/widget_def_height" + android:minResizeHeight="0dp" android:minResizeWidth="@dimen/widget_def_width" - android:minResizeHeight="@dimen/widget_def_height" android:previewImage="@drawable/ui_widget_preview" android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0" From 76eb693fb5aba0a0ddf9db5f0bee7268d7cda7ca Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 15 Jan 2024 20:51:08 -0700 Subject: [PATCH 14/36] all: reformat --- .../res/drawable-v31/ui_widget_rectangle_button_bg.xml | 4 ++-- .../res/drawable/ui_widget_rectangle_button_bg.xml | 6 +++--- app/src/main/res/layout/fragment_home.xml | 2 +- app/src/main/res/layout/fragment_main.xml | 10 ++++++++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml b/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml index feba7d550..38fb204ce 100644 --- a/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml +++ b/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml @@ -1,6 +1,6 @@ - + android:shape="rectangle"> + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml b/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml index b03eb6f1c..03b519850 100644 --- a/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml +++ b/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml @@ -1,6 +1,6 @@ - - + android:shape="rectangle"> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index d8344e9bb..2ff9e9757 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -166,7 +166,7 @@ android:gravity="bottom|end" app:sdMainFabAnimationRotateAngle="135" app:sdMainFabClosedIconColor="@android:color/white" - app:sdMainFabClosedSrc="@drawable/ic_add_24"/> + app:sdMainFabClosedSrc="@drawable/ic_add_24" /> - + - + From ec61ba50ecc98a776d55bdde7910b5ca7f92dbd0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 16 Jan 2024 12:46:26 -0700 Subject: [PATCH 15/36] playback: re-add semi-circular queue w/no repeat --- .../auxio/playback/system/PlaybackService.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 637c62286..6530bca91 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 @@ -315,9 +315,21 @@ class PlaybackService : } override fun next() { - player.seekToNext() - if (!playbackSettings.rememberPause) { - player.play() + // Replicate the old pseudo-circular queue behavior when no repeat option is implemented. + // Basically, you can't skip back and wrap around the queue, but you can skip forward and + // wrap around the queue, albeit playback will be paused. + if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) { + player.seekToNext() + if (!playbackSettings.rememberPause) { + player.play() + } + } else { + goto(0) + // TODO: Dislike the UX implications of this, I feel should I bite the bullet + // and switch to dynamic skip enable/disable? + if (!playbackSettings.rememberPause) { + player.pause() + } } playbackManager.ack(this, StateAck.IndexMoved) // Deferred save is handled on position discontinuity From e6141b12e628c036980585c852f44e1d4a27d189 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 16 Jan 2024 13:25:03 -0700 Subject: [PATCH 16/36] playback: fix state restore Wasn't using the corrected shuffle mapping --- .../oxycblt/auxio/playback/state/PlaybackStateManager.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 f03884f8d..2eacddff5 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 @@ -766,12 +766,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val rawQueue = RawQueue( heap = heap, - shuffledMapping = savedState.shuffledMapping, + shuffledMapping = shuffledMapping, heapIndex = - if (savedState.shuffledMapping.isNotEmpty()) { - savedState.shuffledMapping[savedState.index] + if (shuffledMapping.isNotEmpty()) { + shuffledMapping[savedState.index] } else { - savedState.index + index }) val oldStateMirror = stateMirror From 6c427185ee570652dd07025b2eb875c646dc90d4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 16 Jan 2024 13:25:26 -0700 Subject: [PATCH 17/36] widgets: fix default cover icon --- app/src/main/res/drawable/ic_remote_default_cover_24.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/drawable/ic_remote_default_cover_24.xml b/app/src/main/res/drawable/ic_remote_default_cover_24.xml index d849d7106..298ee86cc 100644 --- a/app/src/main/res/drawable/ic_remote_default_cover_24.xml +++ b/app/src/main/res/drawable/ic_remote_default_cover_24.xml @@ -13,7 +13,7 @@ android:translateX="6" android:translateY="6"> From 5ab05b75d9e0e1eab7ee1595c9f7c655221d777b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 16 Jan 2024 14:27:32 -0700 Subject: [PATCH 18/36] widgets: fix default cover icon --- app/src/main/res/drawable/ic_remote_default_cover_24.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/drawable/ic_remote_default_cover_24.xml b/app/src/main/res/drawable/ic_remote_default_cover_24.xml index 298ee86cc..9a084874c 100644 --- a/app/src/main/res/drawable/ic_remote_default_cover_24.xml +++ b/app/src/main/res/drawable/ic_remote_default_cover_24.xml @@ -5,7 +5,7 @@ android:viewportWidth="24" android:viewportHeight="24"> From bbe00422815f7afb51486b6ac479817ba67085f9 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 17 Jan 2024 04:05:26 +0100 Subject: [PATCH 19/36] Translations update from Hosted Weblate (#657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Czech) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Hindi) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/he/ * Translated using Weblate (Russian) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (305 of 305 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Czech) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Hindi) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (German) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (German) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Korean) Currently translated at 98.7% (307 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Russian) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (311 of 311 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Czech) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Croatian) Currently translated at 100.0% (42 of 42 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hr/ * Translated using Weblate (Croatian) Currently translated at 98.7% (309 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ --------- Co-authored-by: Fjuro Co-authored-by: gallegonovato Co-authored-by: ShareASmile Co-authored-by: BMT[UA] Co-authored-by: Eric Co-authored-by: Ron Shabi Co-authored-by: Макар Разин Co-authored-by: Сергій Co-authored-by: qwerty287 Co-authored-by: min7-i Co-authored-by: Yurical Co-authored-by: Vaclovas Intas Co-authored-by: 大王叫我来巡山 Co-authored-by: Milo Ivir --- app/src/main/res/values-be/strings.xml | 7 + app/src/main/res/values-cs/strings.xml | 9 ++ app/src/main/res/values-de/strings.xml | 17 ++ app/src/main/res/values-es/strings.xml | 9 ++ app/src/main/res/values-hi/strings.xml | 7 + app/src/main/res/values-hr/strings.xml | 19 ++- app/src/main/res/values-iw/strings.xml | 22 ++- app/src/main/res/values-ko/strings.xml | 17 +- app/src/main/res/values-lt/strings.xml | 152 ++++++++++-------- app/src/main/res/values-pa/strings.xml | 7 + app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values-uk/strings.xml | 7 + app/src/main/res/values-zh-rCN/strings.xml | 9 ++ .../metadata/android/hr/full_description.txt | 2 +- 14 files changed, 220 insertions(+), 71 deletions(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 3a7d1044b..b00c7ba2c 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -319,4 +319,11 @@ Плэйліст экспартаваны Экспартаваць плэйліст Немагчыма экспартаваць плэйліст ў гэты файл + Імпартаваць плэйліст + Рэгуляванне ReplayGain песні + Рэгуляванне ReplayGain альбома + Аўтар + Ахвярнасць + Прыхільнікі + Ахвяруйце на праект, каб ваша імя было дададзена тут! \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index c62df4253..6d70c2fce 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -330,4 +330,13 @@ Import Seznam skladeb importován Seznam skladeb exportován + Importovat seznam skladeb + Úprava ReplayGain u stopy + Přispět + Podporovatelé + Autor + Úprava ReplayGain u alba + Přispějte na projekt a uvidíte zde své jméno! + Zůstat ve stavu přehrávání/pozastavení při přeskakování nebo úpravě fronty + Zapamatovat pozastavení \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2714faddd..e99609e83 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -311,4 +311,21 @@ Pfad Wiedergabeliste konnte nicht aus dieser Datei importiert werden Leere Wiedergabeliste + ReplayGain-Albenanpassung + ReplayGain-Trackanpassung + Autor + Spenden + Unterstützer + Spende für das Projekt, damit dein Name hier aufgenommen wird! + Wiedergabeliste importieren + Wiedergabeliste exportieren + Pfadstil + Abolut + Relativ + Windows-kompatible Pfade verwenden + Exportieren + Wiedergabeliste konnte nicht in diese Datei exportiert werden + Importieren + Wiedergabeliste importiert + Wiedergabeliste exportiert \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 08dfc0973..924e8f202 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -325,4 +325,13 @@ Lista de reproducción exportada Exportar lista de reproducción No se puede exportar la lista de reproducción a este archivo + Importar lista de reproducción + Ajuste de pista de ganancia de reproducción + Donar + Partidarios + Ajuste del álbum de ganancia de reproducción + Autor + ¡Haga una donación al proyecto para que agreguen su nombre aquí! + Recordar la pausa + Permanecer en reproducción/pausa al saltar o editar la cola \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 87d236503..212667ca7 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -320,4 +320,11 @@ प्लेलिस्ट एक्सपोर्ट की गई प्लेलिस्ट एक्सपोर्ट करें प्लेलिस्ट को इस फ़ाइल में एक्सपोर्ट करने में असमर्थ + प्लेलिस्ट इम्पोर्ट करें + रीप्लेगेन ट्रैक एडजस्टमेंट + रीप्लेगेन एल्बम एडजस्टमेंट + समर्थक + लेखक + दान करें + अपना नाम यहां जुड़वाने के लिए परियोजना में दान करें! \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index cadb7a570..347c23830 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -301,9 +301,24 @@ Kopirano Nema albuma Uvezen popis pjesama - Staza + Putanja Demo snimka Demo snimke - Nije bilo moguće uvesti popis pjesama iz ove datoteke + Nije moguće uvesti popis pjesama iz ove datoteke Prazan popis pjesama + Autor + Doniraj + Podržavatelji + Doniraj projektu za dodavanje tvog imena ovdje! + Uvezi popis pjesama + Nije moguće izvesti popis pjesama u ovu datoteku + Uvezi + Izvezi + Izvezi popis pjesama + Stil putanje + Absolutno + Relativno + Koristi Windows kompatibilne putanje + Popis pjesama je uvezen + Popis pjesama je izvezen \ No newline at end of file diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index df16c74a2..e39afd69e 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -2,7 +2,7 @@ מוזיקה נטענת מוזיקה נטענת - לנסות שוב + נסה שוב ספריית המוזיקה שלך נסרקת כל השירים אלבומים @@ -158,7 +158,7 @@ המגבר מוחל על ההתאמה הקיימת בזמן השמעה רשימת השמעה חדשה הוספה לרשימת השמעה - לתת + הענק רשימת השמעה (פלייליסט) רשימות השמעה מחיקה @@ -299,4 +299,22 @@ %1$s, %2$s ליים %s נערך + אין אלבומים + מחלט + יבא + השתמש בנתיבים המותאמים למערכת חלונות + יבא רשימת השמעה + נתיב + יצא + רשימת השמעה מיובאת + דמו + יחסי + רשימת השמעה יובאה + דמו + אין יכולת לייבא רשימת השמעה מהקובץ הנ”ל + רשימת השמעה ריקה + צורת נתיב + רשימת השמעה יוצאה + יצא רשימת השמעה + אין יכולת לייצא רשימת השמעה מהקובץ הנ”ל \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7efc46d01..9d7f7f28b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -47,7 +47,7 @@ 버전 소스 코드 라이선스 - Alexander Capehart가 개발 + Alexander Capehart 라이브러리 통계 설정 @@ -309,4 +309,19 @@ 데모 데모 빈 재생 목록 + 재생 목록을 가져왔습니다. + 개발자 + 재생 목록 가져오기 + 후원 + 서포터 + 여기에 이름을 올리고 싶으시면 프로젝트를 후원해 주세요! + 재생 목록을 가져왔습니다. + 재생 목록을 내보냈습니다. + 절대 + 상대 + Windows 호환 경로 사용 + 가져오기 + 내보내기 + 재생 목록 내보내기 + 경로 스타일 \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 3fa2e9b91..a34b5961f 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -4,10 +4,10 @@ Visos dainos Paieška Filtruoti - Visos + Visi Rūšiavimas Pavadinimas - Metai + Data Trukmė Dainos skaičius Diskas @@ -21,10 +21,10 @@ Peržiūrėti ypatybes Dydis Bitų srautas - Mėginių ėmimo dažnis + Skaitmeninimo dažnis Automatinis - Šviesus - Tamsus + Šviesi + Tamsi Spalvų schema Juodoji tema Atlikėjai @@ -34,7 +34,7 @@ Groti Licencijos Maišyti - Pridėtas į eilę + Pridėta į eilę Dainų ypatybės Išsaugoti Apie @@ -44,21 +44,21 @@ Versija Nustatymai Tema - Naudoti grynai juodą tamsią temą + Naudoti grynai juodą tamsią temą. Paprastas, racionalus Android muzikos grotuvas. - Muzikos pakraunimas + Muzikos pakrovimas Peržiūrėk ir valdyk muzikos grojimą Žanrai Pakartoti Suteikti Kraunama muzika - Kraunamas tavo muzikos biblioteka… + Kraunama tavo muzikos biblioteka… Bibliotekos statistika Rožinis Albumas Mini albumas Singlas - Atlikėjas (-a) + Atlikėjas Nežinomas žanras Nėra datos Raudona @@ -82,7 +82,7 @@ ReplayGain strategija Singlai Gerai - Įjungti suapvalintų kampų papildomiems UI elementams (reikia, kad albumo viršeliai būtų suapvalinti) + Įjungti suapvalintų kampų papildomiems UI elementams (reikia, kad albumo viršeliai būtų suapvalinti). Garso takelis Garso takeliai Garsas @@ -120,56 +120,56 @@ Gyvai albumas Remikso albumas Gyvai - Visada pradėti groti, kai ausinės yra prijungtos (gali neveikti visuose įrenginiuose) + Visada pradėti groti, kai ausinės yra prijungtos (gali neveikti visuose įrenginiuose). Ogg garsas - Sukūrė Alexanderis Capehartas (angl. Alexander Capehart) - Pageidauti takelį - Jokių aplankų - Šis aplankas nepalaikomas + Aleksandras Keiphartas (angl. Alexander Capehart) + Pageidauti takeliui + Nėra aplankų + Šis aplankas nepalaikomas. Groti arba pristabdyti Praleisti į kitą dainą Praleisti į paskutinę dainą Mikstapas Mikstapai Bibliotekos skirtukai - Keisti bibliotekos skirtukų matomumą ir tvarką + Keisti bibliotekos skirtukų matomumą ir tvarką. Pageidauti albumui Pageidauti albumui, jei vienas groja - Jokią programą nerasta, kuri galėtų atlikti šią užduotį + Programėlę nerasta, kuri galėtų atlikti šią užduotį. Auxio piktograma Perkelti šią dainą Perkelti šį skirtuką - Muzikos įkrovimas nepavyko - Auxio reikia leidimo skaityti tavo muzikos biblioteką + Muzikos pakrovimas nepavyko. + Auxio reikia leidimo skaityti tavo muzikos biblioteką. Diskas %d +%.1f dB -%.1f dB Bendra trukmė: %s - Gyvas singlas + Gyvai singlas Remikso singlas - Kompiliacijos - Kompiliacija + Rinkiniai + Rinkinys Prisiminti maišymą - Palikti maišymą įjungtą, kai groja nauja daina + Palikti maišymą įjungtą, kai groja nauja daina. Persukti prieš praleistant atgal - Persukti atgal prieš praleistant į ankstesnę dainą - Pauzė ant kartojamo + Persukti atgal prieš praleistant į ankstesnę dainą. + Pauzė ant kartojimo Kai grojant iš bibliotekos Kai grojant iš elemento detalių Pašalinti aplanką Žanras - Ieškoti savo bibliotekoje… + Ieškok savo bibliotekoje… Ekvalaizeris Režimas - Automatinis įkrovimas - Jokios muzikos nerasta + Automatinis perkrauvimas + Muzikos nerasta. Sustabdyti grojimą Nėra takelio Praleisti į kitą Automatinis ausinių grojimas Kartojimo režimas Atidaryti eilę - Išvalyti paieškos paraišką + Išvalyti paieškos užklausą Muzika nebus kraunama iš pridėtų aplankų, kurių tu pridėsi. Įtraukti Pašalinti šią dainą @@ -181,39 +181,39 @@ Neįtraukti Muzika bus kraunama iš aplankų, kurių tu pridėsi. %d Hz - Perkrauti muzikos biblioteką, kai ji pasikeičia (reikia nuolatinio pranešimo) + Perkrauti muzikos biblioteką, kai ji pasikeičia (reikia nuolatinio pranešimo). Pakrautos dainos: %d Pakrautos žanros: %d Pakrauti albumai: %d Pakrauti atlikėjai: %d Kraunama tavo muzikos biblioteka… (%1$d/%2$d) Maišyti visas dainas - Personalizuotas - Įspėjimas: keičiant išankstinį stiprintuvą į didelę teigiamą vertę, kai kuriuose garso takeliuose gali atsirasti tarpų. + Suasmeninti + Įspėjimas: keičiant išankstinį stiprintuvą į didelę teigiamą reikšmę, kai kuriuose garso takeliuose gali atsirasti tarpų. Albumo viršelis %s Atlikėjo vaizdas %s Nėra grojančio muzikos - Sustabdyti, kai daina kartojasi + Sustabdyti, kai daina kartojasi. Turinys Muzikos aplankai Atnaujinti muziką - Perkrauti muzikos biblioteką, naudojant talpyklos žymes, kai įmanoma + Perkrauti muzikos biblioteką, naudojant talpyklos žymes, kai įmanoma. Pasirinktinis grojimo juostos veiksmas - Nepavyko atkurti būsenos + Nepavyksta atkurti būsenos. ReplayGain išankstinis stiprintuvas Išsaugoti grojimo būseną - Tvarkyti, kur muzika turėtų būti įkeliama iš + Tvarkyti, kur muzika turėtų būti kraunama iš. Žanro vaizdas %s Įjungti maišymą arba išjungti - Takelis %d + %d takelis Keisti kartojimo režimą Indigos %d kbps DJ miksai DJ miksas - Gyvai kompiliacija - Remikso kompiliacija - Išvalyti anksčiau išsaugotą grojimo būseną (jei yra) + Gyvai rinkinys + Remikso rinkinys + Išvalyti anksčiau išsaugotą grojimo būseną (jei yra). Daugiareikšmiai separatoriai Pasvirasis brūkšnys (/) Pliusas (+) @@ -221,56 +221,55 @@ Albumų viršeliai Išjungta Greitis - Išsaugoti dabartinę grojimo būseną dabar + Išsaugoti dabartinę grojimo būseną dabar. Išvalyti grojimo būseną - Konfigūruoti simbolius, kurie nurodo kelias žymių reikšmes + Konfigūruoti simbolius, kurie nurodo kelias žymių reikšmes. Kablelis (,) - Reguliavimas be žymų - Įspėjimas: naudojant šį nustatymą, kai kurios žymos gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus su agalinių brūkšniu (\\). + Koregavimas be žymių + Įspėjimas: naudojant šį nustatymą, kai kurios žymes gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus su agalinių brūkšniu (\\). Kabliataškis (;) Aukštos kokybės Atkurti grojimo būseną Neįtraukti nemuzikinių - Ignoruoti garso failus, kurie nėra muzika, pvz., tinklalaides - Išankstinis stiprintuvas taikomas esamam reguliavimui grojimo metu - Reguliavimas su žymėmis - Atkurti anksčiau išsaugotą grojimo būseną (jei yra) + Ignoruoti garso failus, kurie nėra muzika, tokius kaip tinklalaides. + Išankstinis stiprintuvas taikomas esamam koregavimui grojimo metu. + Koregavimas su žymėmis + Atkurti anksčiau išsaugotą grojimo būseną (jei yra). Slėpti bendradarbius - Rodyti tik tuos atlikėjus, kurie yra tiesiogiai įrašyti į albumą (geriausiai veikia gerai pažymėtose bibliotekose) - Nepavyko išvalyti būsenos - Nepavyko išsaugoti būsenos + Rodyti tik tuos atlikėjus, kurie yra tiesiogiai įtraukti į albumą (geriausiai veikia gerai pažymėtose bibliotekose). + Nepavyksta išvalyti būsenos. + Nepavyksta išsaugoti būsenos. - %d atlikėjas (-a) + %d atlikėjas %d atlikėjai %d atlikėjų - %d atlikėjų Perskenuoti muziką - Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta) + Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau užbaigta). %d pasirinkta Groti iš žanro Viki %1$s, %2$s - Nustatyti iš naujo + Atkurti Biblioteka Elgesys - Pakeisk programos temą ir spalvas - Valdyk, kaip muzika ir vaizdai įkeliami - Konfigūruok garso ir grojimo elgesį - Pritaikyk UI valdiklius ir elgseną + Pakeisk programėlės temą ir spalvas. + Valdyk, kaip muzika ir vaizdai kraunami. + Konfigūruok garso ir grojimo elgesį. + Pritaikyk UI valdiklius ir elgseną. Muzika Vaizdai Grojimas ReplayGain - Aplankalai + Aplankai Pastovumas Mažėjantis - Teisingai surūšiuoti pavadinimus, kurie prasideda skaičiais arba žodžiais, tokiais kaip „the“ (geriausiai veikia su anglų kalbos muzika) + Teisingai surūšiuoti pavadinimus, kurie prasideda skaičiais arba žodžiais, tokiais kaip „the“ (geriausiai veikia su anglų kalbos muzika). Išmanusis rūšiavimas Grojaraštis Grojaraščiai Grojaraščio vaizdas %s - Sukurti naują grojaraštį + Kurti naują grojaraštį Naujas grojaraštis Pridėti į grojaraštį Pridėta į grojaraštį @@ -283,7 +282,7 @@ Redaguoti Bendrinti Pervadintas grojaraštis - Grojaraštis %d + %d grojaraštis Sukurtas grojaraštis Ištrintas grojaraštis Nėra disko @@ -291,7 +290,7 @@ Pasirodo Daina Peržiūrėti - Apkarpyti visus albumų viršelius iki 1:1 kraštinių koeficiento + Apkarpyti visus albumų viršelius iki 1:1 kraštinių koeficiento. Priversti kvadratinių albumų viršelius Groti dainą pačią Rūšiuoti pagal @@ -305,4 +304,27 @@ Nėra albumų Demo Demos + Importuotas grojaraštis + Importuotas grojaraštis + Eksportuotas grojaraštis + Nepavyksta eksportuoti grojaraščio į šį failą + ReplayGain takelio koregavimas + ReplayGain albumo koregavimas + Autorius + Aukoti + Palaikytojai + Paaukok projektui, kad tavo vardas būtų pridėtas čia! + Nepavyksta importuoti grojaraščio iš šio failo. + Tuščias grojaraštis + Importuoti grojaraštį + Kelias + Importuoti + Eksportuoti + Eksportuoti grojaraštį + Kelio stilius + Absoliutinis + Santykinis + Naudoti Windows suderinamus kelius + Prisiminti pauzę + Išlieka grojimas ir (arba) pristabdomas, kai praleidžiama arba redaguojama eilė. \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index c720d353b..3d6cc1a73 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -313,4 +313,11 @@ ਪਲੇਲਿਸਟ ਨਿਰਯਾਤ ਕੀਤੀ ਗਈ ਪਲੇਲਿਸਟ ਨਿਰਯਾਤ ਕਰੋ ਪਲੇਲਿਸਟ ਨੂੰ ਇਸ ਫ਼ਾਈਲ ਵਿੱਚ ਨਿਰਯਾਤ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ + ਪਲੇਲਿਸਟ ਇੰਪੋਰਟ ਕਰੋ + ਰੀਪਲੇਅਗੇਨ ਟ੍ਰੈਕ ਐਡਜਸਟਮੈਂਟ + ਰੀਪਲੇਗੇਨ ਐਲਬਮ ਐਡਜਸਟਮੈਂਟ + ਲੇਖਕ + ਦਾਨ ਕਰੋ + ਸਮਰਥਕ + ਆਪਣਾ ਨਾਮ ਇੱਥੇ ਜੋੜਨ ਲਈ ਪ੍ਰੋਜੈਕਟ ਨੂੰ ਦਾਨ ਕਰੋ! \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e0a785ced..48cd52545 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -328,4 +328,11 @@ Плейлист экспортирован Экспортировать плейлист Невозможно экспортировать плейлист в этот файл + Импортировать плейлист + Подстройка ReplayGain альбома + Подстройка ReplayGain песни + Автор + Пожертвовать + Сторонники + Сделайте пожертвование проекту, чтобы ваше имя было добавлено сюда! \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c14a92f56..1cfd7c087 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -325,4 +325,11 @@ Список відтворення експортовано Експортувати список відтворення Неможливо експортувати список відтворення в цей файл + Імпортувати список відтворення + Пожертвувати + Прибічники + Підлаштування ReplayGain пісні + Підлаштування ReplayGain альбому + Пожертвуйте на проєкт, щоб ваше ім\'я було додано сюди! + Автор \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9991f91d0..825fd6716 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -319,4 +319,13 @@ 无法将播放列表导出到此文件 导入了播放列表 导出了播放列表 + 导入播放列表 + 回放增益曲目调整 + 支持者 + 回放增益专辑调整 + 作者 + 捐赠 + 要在此添加您的名字请给项目捐款! + 跳过或编辑队列时保留播放/暂停状态 + 记住暂停状态 \ No newline at end of file diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt index f5f8d2d17..6647c70f4 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -20,4 +20,4 @@ precizni/izvorni datumi, sortiranje oznaka i više - Automatska reprodukcija slušalica - Elegantni widgeti koji se automatski prilagođavaju njihovoj veličini - Potpuno privatno i izvan mreže -- Nema zaobljenih naslovnica albuma (Osim ako ih ne želite. Onda možete.) +- Bez zaobljenih naslovnica albuma (zadano) From 588b6e9abdb7d8ce638b458ae0f82a3383e04992 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 Jan 2024 19:27:28 -0700 Subject: [PATCH 20/36] info: explicitly declare notification perm Some OEMs might be looking for this before approving even media or foreground service notifications. --- README.md | 1 + app/src/main/AndroidManifest.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index be40f6388..3a805e5b7 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ precise/original dates, sort tags, and more - Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files - Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background +- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading ## Donate diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba4b27028..f7fa7b198 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + From b53a96f574cf7c35b689c263a5305421b3d93bc2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 Jan 2024 19:33:49 -0700 Subject: [PATCH 21/36] playback: fix crash when song cannot be restored The index is -1, which is not a valid position to seek through normally. In this case we should just ignore it. --- .../playback/state/PlaybackStateManager.kt | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 2eacddff5..aa9ca0078 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 @@ -774,18 +774,21 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { index }) - val oldStateMirror = stateMirror + if (index > -1) { + // Valid state where something needs to be played, direct the stateholder to apply + // this new state. + val oldStateMirror = stateMirror + if (oldStateMirror.rawQueue != rawQueue) { + logD("Queue changed, must reload player") + stateHolder?.applySavedState(parent, rawQueue, StateAck.NewPlayback) + stateHolder?.playing(false) + } - if (oldStateMirror.rawQueue != rawQueue) { - logD("Queue changed, must reload player") - stateHolder?.applySavedState(parent, rawQueue, StateAck.NewPlayback) - stateHolder?.playing(false) - } - - if (oldStateMirror.progression.calculateElapsedPositionMs() != savedState.positionMs) { - logD("Seeking to saved position ${savedState.positionMs}ms") - stateHolder?.seekTo(savedState.positionMs) - stateHolder?.playing(false) + if (oldStateMirror.progression.calculateElapsedPositionMs() != savedState.positionMs) { + logD("Seeking to saved position ${savedState.positionMs}ms") + stateHolder?.seekTo(savedState.positionMs) + stateHolder?.playing(false) + } } isInitialized = true From 8dc0be4a5248e2ca320242d5e168667cc01ac3ce Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 Jan 2024 20:01:26 -0700 Subject: [PATCH 22/36] all: cleanup --- app/build.gradle | 4 ++-- app/src/main/java/org/oxycblt/auxio/MainActivity.kt | 8 ++++---- .../java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt | 4 ++-- .../main/java/org/oxycblt/auxio/music/MusicSettings.kt | 6 ++++-- .../main/java/org/oxycblt/auxio/music/MusicViewModel.kt | 3 ++- .../auxio/music/decision/PlaylistPickerViewModel.kt | 2 +- .../org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt | 8 +++----- .../org/oxycblt/auxio/music/dirs/MusicDirectories.kt | 7 ++++--- .../org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt | 2 +- .../java/org/oxycblt/auxio/playback/PlaybackViewModel.kt | 9 ++++----- .../oxycblt/auxio/playback/state/PlaybackStateHolder.kt | 7 ++++++- .../oxycblt/auxio/playback/state/PlaybackStateManager.kt | 8 ++++---- .../oxycblt/auxio/playback/system/BetterShuffleOrder.kt | 3 ++- .../org/oxycblt/auxio/playback/system/PlaybackService.kt | 2 +- .../java/org/oxycblt/auxio/widgets/WidgetComponent.kt | 6 +++--- app/src/main/res/layout/fragment_home.xml | 2 ++ app/src/main/res/layout/widget_stick_thin.xml | 1 - app/src/main/res/layout/widget_stick_wide.xml | 1 - app/src/main/res/values-lt/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 2 +- 20 files changed, 47 insertions(+), 39 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 915f58a3e..af0bf21bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.3.3" - versionCode 40 + versionName "3.4.0" + versionCode 41 minSdk 24 targetSdk 34 diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 18af927b5..3fa2ad852 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -111,12 +111,12 @@ class MainActivity : AppCompatActivity() { } /** - * Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used - * in the playback system. + * Transform an [Intent] given to [MainActivity] into a [DeferredPlayback] that can be used in + * the playback system. * * @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent. - * @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started, - * false otherwise. + * @return true If the analogous [DeferredPlayback] to the given [Intent] was started, false + * otherwise. */ private fun startIntentAction(intent: Intent?): Boolean { if (intent == null) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt index 1a9b5174d..0db750c1a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt @@ -244,7 +244,7 @@ class ThemedSpeedDialView : SpeedDialView { companion object { private val VIEW_PROPERTY_BACKGROUND_TINT = object : Property(Int::class.java, "backgroundTint") { - override fun get(view: View): Int? = view.backgroundTintList!!.defaultColor + override fun get(view: View): Int = view.backgroundTintList!!.defaultColor override fun set(view: View, value: Int?) { view.backgroundTintList = ColorStateList.valueOf(value!!) @@ -253,7 +253,7 @@ class ThemedSpeedDialView : SpeedDialView { private val IMAGE_VIEW_PROPERTY_IMAGE_TINT = object : Property(Int::class.java, "imageTint") { - override fun get(view: ImageView): Int? = view.imageTintList!!.defaultColor + override fun get(view: ImageView): Int = view.imageTintList!!.defaultColor override fun set(view: ImageView, value: Int?) { view.imageTintList = ColorStateList.valueOf(value!!) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index e50b0c448..67b46e9ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -57,8 +57,10 @@ interface MusicSettings : Settings { class MusicSettingsImpl @Inject -constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) : - Settings.Impl(context), MusicSettings { +constructor( + @ApplicationContext context: Context, + private val documentPathFactory: DocumentPathFactory +) : Settings.Impl(context), MusicSettings { private val storageManager = context.getSystemServiceCompat(StorageManager::class) override var musicDirs: MusicDirectories diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 6140261a9..4c45dfc84 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -368,7 +368,8 @@ sealed interface PlaylistDecision { * @param songs The [Song]s to contain in the new [Playlist]. * @param template An existing playlist name that should be editable in the opened dialog. If * null, a placeholder should be created and shown as a hint instead. - * @param context The context in which this decision is being fulfilled. + * @param reason The reason why a new playlist is being created. For all intensive purposes, you + * do not need to specify this. */ data class New(val songs: List, val template: String?, val reason: Reason) : PlaylistDecision { diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt index 3e487437a..c9a5d293a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt @@ -210,7 +210,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } /** - * Set a new [currentPlaylisttoExport] from a [Playlist] [Music.UID]. + * Set a new [currentPlaylistToExport] from a [Playlist] [Music.UID]. * * @param playlistUid The [Music.UID] of the [Playlist] to export. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt index 9beedd79f..949d9282c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt @@ -29,7 +29,7 @@ import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.logD /** - * [RecyclerView.Adapter] that manages a list of [Directory] instances. + * [RecyclerView.Adapter] that manages a list of [Path] music directory instances. * * @param listener A [DirectoryAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) @@ -37,9 +37,7 @@ import org.oxycblt.auxio.util.logD class DirectoryAdapter(private val listener: Listener) : RecyclerView.Adapter() { private val _dirs = mutableListOf() - /** - * The current list of [SystemPath]s, may not line up with [MusicDirectories] due to removals. - */ + /** The current list of [Path]s, may not line up with [MusicDirectories] due to removals. */ val dirs: List = _dirs override fun getItemCount() = dirs.size @@ -94,7 +92,7 @@ class DirectoryAdapter(private val listener: Listener) : } /** - * A [RecyclerView.Recycler] that displays a [Directory]. Use [from] to create an instance. + * A [RecyclerView.Recycler] that displays a [Path]. Use [from] to create an instance. * * @author Alexander Capehart (OxygenCobalt) */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt index c85b21ad5..a4082cbf7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt @@ -23,9 +23,10 @@ import org.oxycblt.auxio.music.fs.Path /** * Represents the configuration for specific directories to filter to/from when loading music. * - * @param dirs A list of [Directory] instances. How these are interpreted depends on [shouldInclude] - * @param shouldInclude True if the library should only load from the [Directory] instances, false - * if the library should not load from the [Directory] instances. + * @param dirs A list of directory [Path] instances. How these are interpreted depends on + * [shouldInclude] + * @param shouldInclude True if the library should only load from the [Path] instances, false if the + * library should not load from the [Path] instances. * @author Alexander Capehart (OxygenCobalt) */ data class MusicDirectories(val dirs: List, val shouldInclude: Boolean) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 90b5e2051..e0e989133 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -90,7 +90,7 @@ interface MediaStoreExtractor { * Create a framework-backed instance. * * @param context [Context] required. - * @param volumeManager [VolumeManager] required. + * @param pathInterpreterFactory A [MediaStorePathInterpreter.Factory] to use. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ fun from( 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 73f7d1258..483a36d99 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -114,8 +114,7 @@ constructor( get() = _playbackDecision /** - * The current audio session ID of the internal player. Null if no [InternalPlayer] is - * available. + * The current audio session ID of the internal player. Null if no audio player is available. */ val currentAudioSessionId: Int? get() = playbackManager.currentAudioSessionId @@ -416,10 +415,10 @@ constructor( } /** - * Start the given [InternalPlayer.Action] to be completed eventually. This can be used to - * enqueue a playback action at startup to then occur when the music library is fully loaded. + * Start the given [DeferredPlayback] to be completed eventually. This can be used to enqueue a + * playback action at startup to then occur when the music library is fully loaded. * - * @param action The [InternalPlayer.Action] to perform eventually. + * @param action The [DeferredPlayback] to perform eventually. */ fun playDeferred(action: DeferredPlayback) { logD("Starting action $action") diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 7de33f20c..2374a421f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -374,6 +374,11 @@ private constructor( positionMs, SystemClock.elapsedRealtime()) - fun nil() = Progression(false, false, 0, SystemClock.elapsedRealtime()) + fun nil() = + Progression( + isPlaying = false, + isAdvancing = false, + initPositionMs = 0, + creationTime = 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 aa9ca0078..fd8477913 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 @@ -207,7 +207,7 @@ interface PlaybackStateManager { fun playDeferred(action: DeferredPlayback) /** - * Request that the pending [PlaybackStateHolder.Action] (if any) be passed to the given + * Request that the pending [DeferredPlayback] (if any) be passed to the given * [PlaybackStateHolder]. * * @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current @@ -271,7 +271,7 @@ interface PlaybackStateManager { fun onIndexMoved(index: Int) {} /** - * Called when the queue changed in a manner outlined by the given [Queue.Change]. + * Called when the queue changed in a manner outlined by the given [DeferredPlayback]. * * @param queue The songs of the new queue. * @param index The new index of the currently playing [Song]. @@ -305,9 +305,9 @@ interface PlaybackStateManager { ) {} /** - * Called when the state of the [InternalPlayer] changes. + * Called when the state of the audio player changes. * - * @param progression The new state of the [InternalPlayer]. + * @param progression The new state of the audio player. */ fun onProgressionChanged(progression: Progression) {} 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 099931baf..e09d9ba2a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt @@ -29,7 +29,7 @@ import java.util.* * * @author media3 team, Alexander Capehart (OxygenCobalt) */ -class BetterShuffleOrder constructor(private val shuffled: IntArray) : ShuffleOrder { +class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder { private val indexInShuffled: IntArray = IntArray(shuffled.size) constructor(length: Int, startIndex: Int) : this(createShuffledList(length, startIndex)) @@ -62,6 +62,7 @@ class BetterShuffleOrder constructor(private val shuffled: IntArray) : ShuffleOr return if (shuffled.isNotEmpty()) shuffled[0] else C.INDEX_UNSET } + @Suppress("KotlinConstantConditions") // Bugged for this function override fun cloneAndInsert(insertionIndex: Int, insertionCount: Int): ShuffleOrder { if (shuffled.isEmpty()) { return BetterShuffleOrder(insertionCount, -1) 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 6530bca91..a84b5775c 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 @@ -593,7 +593,7 @@ class PlaybackService : private fun ExoPlayer.unscrambleQueueIndices(): List { val timeline = currentTimeline - if (timeline.isEmpty()) { + if (timeline.isEmpty) { return emptyList() } val queue = mutableListOf() 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 9280d2ea2..09ac5e8f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -165,12 +165,12 @@ constructor( /** * A condensed form of the playback state that is safe to use in AppWidgets. * - * @param song [Queue.currentSong] + * @param song [PlaybackStateManager.currentSong] * @param cover A pre-loaded album cover [Bitmap] for [song]. * @param cover A pre-loaded album cover [Bitmap] for [song], with rounded corners. - * @param isPlaying [PlaybackStateManager.playerState] + * @param isPlaying [PlaybackStateManager.progression] * @param repeatMode [PlaybackStateManager.repeatMode] - * @param isShuffled [Queue.isShuffled] + * @param isShuffled [PlaybackStateManager.isShuffled] */ data class PlaybackState( val song: Song, diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 2ff9e9757..c92bab632 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -164,6 +164,7 @@ android:clickable="true" android:focusable="true" android:gravity="bottom|end" + android:contentDescription="@string/lbl_new_playlist" app:sdMainFabAnimationRotateAngle="135" app:sdMainFabClosedIconColor="@android:color/white" app:sdMainFabClosedSrc="@drawable/ic_add_24" /> @@ -172,6 +173,7 @@ android:id="@+id/home_shuffle_fab" android:layout_width="match_parent" android:layout_height="wrap_content" + android:contentDescription="@string/lbl_shuffle" android:layout_gravity="bottom|end" android:layout_margin="@dimen/spacing_medium" android:src="@drawable/ic_shuffle_off_24" /> diff --git a/app/src/main/res/layout/widget_stick_thin.xml b/app/src/main/res/layout/widget_stick_thin.xml index 776d26525..f4651eaf1 100644 --- a/app/src/main/res/layout/widget_stick_thin.xml +++ b/app/src/main/res/layout/widget_stick_thin.xml @@ -1,6 +1,5 @@ %d atlikėjas %d atlikėjai + %d atlikėjų %d atlikėjų Perskenuoti muziką diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 1011bf65d..d6d307c19 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -37,7 +37,7 @@ Esquema de cores Áudio Personalizar - Memorizar musica misturada + Memorizar música misturada Nenhuma música encontrada From d0817dd83b5f3648081f4f270d5e20717b47f711 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 Jan 2024 20:03:27 -0700 Subject: [PATCH 23/36] build: bump to 3.4.0 Bump to a testing version of 3.4.0. --- CHANGELOG.md | 2 +- app/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4688896cd..1df14ef9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## dev +## dev (3.4.0) #### What's New - Gapless playback is now used whenever possible diff --git a/app/build.gradle b/app/build.gradle index af0bf21bb..ea60f8d70 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId namespace - versionName "3.4.0" + versionName "3.4.0-testing" versionCode 41 minSdk 24 From 40fdf59a669ccd349af1e5271de6f40566151497 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 Jan 2024 20:04:45 -0700 Subject: [PATCH 24/36] build: bump to 3.4.0-dev Decided dev is a better suffix. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index ea60f8d70..7172c1d86 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId namespace - versionName "3.4.0-testing" + versionName "3.4.0-dev" versionCode 41 minSdk 24 From fbd8d4b361895161ccf724b9245d92c537afe3c7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 23 Jan 2024 19:41:46 -0700 Subject: [PATCH 25/36] info: update sponsors --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3a805e5b7..b71985fdc 100644 --- a/README.md +++ b/README.md @@ -71,14 +71,17 @@ precise/original dates, sort tags, and more You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even App Itself! -**$16/month supporters:** +

$16/month supporters:

-*Be the first to have their profile picture and username added here!* +

+

yrliet

+

-**$8/month supporters:** +

$8/month supporters:

-

+

+

## Building From ced462e7188c6f61581de2c87dcbca347dc97fbe Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 28 Jan 2024 20:22:34 -0700 Subject: [PATCH 26/36] playback: dont apply state mirror w/no song Otherwise, the StateHolder will crash. --- .../org/oxycblt/auxio/playback/state/PlaybackStateManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 fd8477913..0ebaf669f 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 @@ -418,7 +418,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } this.stateHolder = stateHolder - if (isInitialized) { + if (isInitialized && stateMirror.index > -1) { stateHolder.applySavedState(stateMirror.parent, stateMirror.rawQueue, null) stateHolder.seekTo(stateMirror.progression.calculateElapsedPositionMs()) stateHolder.playing(false) @@ -753,7 +753,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } var index = savedState.index - while (pointingAtSong() && index > -1) { + while (!pointingAtSong() && index > -1) { index-- } From 67307665041f5a36f6805b97f0f1cbd9865d9012 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 28 Jan 2024 21:37:20 -0700 Subject: [PATCH 27/36] music: increase music load timeout TagExtractor can take longer than 10 seconds to load, increase it to 60 seconds. --- .../main/java/org/oxycblt/auxio/music/MusicRepository.kt | 5 +++-- app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 97ecaa7fd..459c8fcbb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -42,6 +42,7 @@ import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.music.metadata.TagExtractor import org.oxycblt.auxio.music.user.MutableUserLibrary import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.util.DEFAULT_TIMEOUT import org.oxycblt.auxio.util.forEachWithTimeout import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE @@ -481,7 +482,7 @@ constructor( val rawSongs = LinkedList() // Use a longer timeout so that dependent components can timeout and throw errors that // provide more context than if we timed out here. - processedSongs.forEachWithTimeout(20000) { + processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) { rawSongs.add(it) // Since discovery takes up the bulk of the music loading process, we switch to // indicating a defined amount of loaded songs in comparison to the projected amount @@ -489,7 +490,7 @@ constructor( emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } - withTimeout(10000) { + withTimeout(DEFAULT_TIMEOUT) { mediaStoreJob.await() tagJob.await() } diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt index 6e60eadf2..d723dd5e3 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -152,6 +152,8 @@ private fun Fragment.launch( viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) } } +const val DEFAULT_TIMEOUT = 60000L + /** * Wraps [SendChannel.send] with a specified timeout. * @@ -160,7 +162,7 @@ private fun Fragment.launch( * @throws TimeoutException If the timeout is reached, provides context on what element * specifically. */ -suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = 10000) { +suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = DEFAULT_TIMEOUT) { try { withTimeout(timeout) { send(element) } } catch (e: TimeoutCancellationException) { @@ -179,7 +181,7 @@ suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = 10000 * specifically. */ suspend fun ReceiveChannel.forEachWithTimeout( - timeout: Long = 10000, + timeout: Long = DEFAULT_TIMEOUT, action: suspend (E) -> Unit ) { var exhausted = false From 46e98b84ed03e567ec51609bb95681615c863774 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 28 Jan 2024 21:40:47 -0700 Subject: [PATCH 28/36] info: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df14ef9c..68675857d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ or editing queue - Added 1x4 and 1x3 widget forms +#### What's Fixed +- Increased music timeout to 60 seconds to accomodate large cover arts +on slow storage drives + #### What's Improved - The playback state is now saved more often, improving persistence - The queue is now fully circular when repeat all is enabled From 7c2dd3ed2e5d9b62a40a90a64c9ac3e4d0f8ba97 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 12 Feb 2024 08:05:37 -0700 Subject: [PATCH 29/36] info: fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b71985fdc..a5902d1da 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ precise/original dates, sort tags, and more ## Donate -You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even App Itself! +You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!

$16/month supporters:

From c42996e492e0a0e74fa23a61996eda9838146fc7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Feb 2024 10:00:53 -0700 Subject: [PATCH 30/36] info: add sponsor to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a5902d1da..01e723819 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ You can support Auxio's development through [my Github Sponsors page](https://gi

+

## Building From fb531c8adc70f85ef18e31dd3b3976317769dbfa Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 17 Feb 2024 18:09:42 +0100 Subject: [PATCH 31/36] Translations update from Hosted Weblate (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.2% (298 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ * Translated using Weblate (Romanian) Currently translated at 82.4% (258 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ro/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Finnish) Currently translated at 90.7% (284 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/ * Translated using Weblate (German) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Swedish) Currently translated at 83.0% (260 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Added translation using Weblate (Interlingua) * Translated using Weblate (Hindi) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Swedish) Currently translated at 83.0% (260 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Swedish) Currently translated at 83.0% (260 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Croatian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Swedish) Currently translated at 83.7% (262 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Swedish) Currently translated at 93.2% (292 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Swedish) Currently translated at 93.6% (293 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Finnish) Currently translated at 92.6% (290 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/ * Translated using Weblate (Interlingua) Currently translated at 14.3% (45 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/ * Translated using Weblate (Russian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Swedish) Currently translated at 98.0% (307 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Swedish) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Hindi) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Interlingua) Currently translated at 24.2% (76 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ --------- Co-authored-by: santiago046 Co-authored-by: Ilie Co-authored-by: Сергій Co-authored-by: Jiri Grönroos Co-authored-by: qwerty287 Co-authored-by: Johan Hansén Co-authored-by: ShareASmile Co-authored-by: Milo Ivir Co-authored-by: Software In Interlingua Co-authored-by: Макар Разин Co-authored-by: Sourabh Mishra Co-authored-by: Vaclovas Intas --- app/src/main/res/values-be/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-fi/strings.xml | 27 +++ app/src/main/res/values-hi/strings.xml | 6 +- app/src/main/res/values-hr/strings.xml | 14 +- app/src/main/res/values-ia/strings.xml | 79 +++++++++ app/src/main/res/values-lt/strings.xml | 2 +- app/src/main/res/values-pa/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 26 +++ app/src/main/res/values-ro/strings.xml | 94 ++++++++++ app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-sv/strings.xml | 189 ++++++++++++--------- app/src/main/res/values-uk/strings.xml | 2 + 13 files changed, 360 insertions(+), 87 deletions(-) create mode 100644 app/src/main/res/values-ia/strings.xml diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index b00c7ba2c..45df86ad1 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -326,4 +326,6 @@ Ахвярнасць Прыхільнікі Ахвяруйце на праект, каб ваша імя было дададзена тут! + Запамінаць паўзу + Пакідаць прайграванне/паўзу падчас пропуску або рэдагаванні чаргі \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e99609e83..8ce66a7bb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -328,4 +328,6 @@ Importieren Wiedergabeliste importiert Wiedergabeliste exportiert + Pause merken + Wiedergabe/Pause beim Springen oder Bearbeiten der Warteschlange beibehalten \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 95271d9ff..89a526160 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -272,4 +272,31 @@ Lisää Kopioitu Ilmoita virheestä + Valinta + Tuotu soittolista + Tekijä + Lahjoita + Tukijat + Soittolista tuotu + Soittolista viety + Lahjoita projektille saadaksesi nimesi näkyviin tähän! + Ei albumeja + Tyhjä soittolista + Tuo soittolista + Polku + Tuo + Vie + Vie soittolista + Järjestys + Suunta + Polun tyyli + Absoluuttinen + Suhteellinen + Käytä Windows-yhteensopivia polkuja + Mukautettu ilmoituksen toiminto + ReplayGain-kappalesäätö + Muista keskeytys + ReplayGain-albumisäätö + Soittolistan tuonti tästä tiedostosta ei onnistu + Soittolistan vienti tähän tiedostoon ei onnistu \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 212667ca7..f40159028 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -44,7 +44,7 @@ %d एल्बम - %d एल्बम + %d एल्बमस नाम शैली @@ -58,7 +58,7 @@ तिथि जोड़ी गई गाने लोड हो रहे है गाने लोड हो रहे है - एंड्रॉयड के लिए एक सीधा साधा, विवेकशील गाने बजाने वाला ऐप। + एंड्रॉयड के लिए एक सीधा साधा,विवेकशील गाने बजाने वाला ऐप। नई प्लेलिस्ट अगला चलाएं लायब्रेरी टैब्स @@ -327,4 +327,6 @@ लेखक दान करें अपना नाम यहां जुड़वाने के लिए परियोजना में दान करें! + विराम याद रखें + कतार छोड़ते या संपादित करते समय चलता/रोका रखिए \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 347c23830..73c4b3fff 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -71,11 +71,11 @@ Zvuk Slušalice: odmah reproduciraj Uvijek pokreni reprodukciju kada su slušalice povezane (možda neće raditi na svim uređajima) - Strategija pojačanja + ReplayGain strategija Preferiraj zvučni zapis Preferiraj album Ako se reproducira album, preferiraj album - Pretpojačalo pojačanja + ReplayGain pretpojačalo Pretpojačalo je tijekom reprodukcije primijenjeno postojećoj prilagodbi Prilagođavanje s oznakama Prilagođavanje bez oznaka @@ -193,10 +193,10 @@ Zapamti miješanje glazbe Vrati prethodno spremljeno stanje reprodukcije (ako postoji) Reproduciraj iz albuma - Pauziraj čim se pjesma ponovi + Pauziraj pri ponavljanju pjesme Premotaj prije vraćanja na prethodnu pjesmu Reproduciraj ili pauziraj - Pauziraj na ponavljanje + Pauziraj pri ponavljanju Sadržaj Spremi stanje reprodukcije Vrati stanje reprodukcije @@ -249,7 +249,7 @@ Wiki %1$s, %2$s Resetiraj - ReplayGain izjednačavanje glasnoće + ReplayGain Mape Silazno Promijenite temu i boje aplikacije @@ -321,4 +321,8 @@ Koristi Windows kompatibilne putanje Popis pjesama je uvezen Popis pjesama je izvezen + Podešavanje ReplayGain pjesme + Podešavanje ReplayGain albuma + Zapamti pauzu + Nastavi reprodukciju/pauziranje prilikom preskakanja ili uređivanja slijeda \ No newline at end of file diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml new file mode 100644 index 000000000..c7aec119c --- /dev/null +++ b/app/src/main/res/values-ia/strings.xml @@ -0,0 +1,79 @@ + + + Cargante le musica + Retentar + Plus + Conceder + Cantos + Canto + Tote le cantos + Albumes + Album + EP + Album remix + EPs + EP de remix + Compilationes + Compilation + Compilatom de remix + Demo + Demos + Artista + Artistas + Lista de reproduction + Listas de reproduction + Nove lista de reproduction + Lista de reproduction vacue + Importar + Exportar + Renominar + Renominar lista de reproduction + Deler + Deler le lista de reproduction? + Modificar + Toto + Filtrar + Nomine + Duration + Numero de cantos + Tracia + Aleatori + Ordinar + Cauda + Ordinar per + Reproduction in curso + Reproducer + Reproducer sequente + Adder al cauda + Adder + Version + Codice fonte + Wiki + Licentias + Singles + Single + Single remix + Mixtapes + Mixtape + Exportar le lista de reproduction + Anno + Vider + Direction + Equalisator + Adder al lista de reproduction + Vider le proprietates + Compartir + Proprietates del canto + Percurso + Formato + Dimension + Taxa de monstra + OK + Cancellar + Salveguardar + Reinitialisar + Stylo de percurso + Usar percursos compatibile con Windows + Stato salveguardate + A proposito de + \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 2707bbb5e..15c4f41fd 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -257,7 +257,7 @@ Pakeisk programėlės temą ir spalvas. Valdyk, kaip muzika ir vaizdai kraunami. Konfigūruok garso ir grojimo elgesį. - Pritaikyk UI valdiklius ir elgseną. + Pritaikyk naudotojo sąsajos valdiklius ir elgseną. Muzika Vaizdai Grojimas diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 3d6cc1a73..9e23fb78b 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -320,4 +320,6 @@ ਦਾਨ ਕਰੋ ਸਮਰਥਕ ਆਪਣਾ ਨਾਮ ਇੱਥੇ ਜੋੜਨ ਲਈ ਪ੍ਰੋਜੈਕਟ ਨੂੰ ਦਾਨ ਕਰੋ! + ਕਤਾਰ ਨੂੰ ਛੱਡਣ ਜਾਂ ਸੰਪਾਦਿਤ ਕਰਨ ਵੇਲੇ ਚਲਾਉਂਦੇ/ਰੋਕੇ ਰਹੋ + ਵਿਰਾਮ ਯਾਦ ਰੱਖੋ \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a0f817cae..56ac63584 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -294,4 +294,30 @@ Renomear playlist Aparece em Apagar %s\? Esta ação não pode ser desfeita. + Demo + Playlist importada + Visualizar + Playlist importada + Playlist exportada + Incapaz de importar uma playlist deste arquivo + Incapaz de exportar a playlist para este arquivo + Demos + Autor + Doar + Apoiadores + Doe para o projeto para ter o seu nome adicionado aqui! + Lembrar pausa + Manter reproduzindo/pausado quando ao pular ou editar a fila + Playlist vazia + Importar playlist + Caminho + Seleção + Exportar + Exportar playlist + Direção + Estilo de caminho + Absoluto + Relativo + Importar + Usar caminhos compatíveis com Windows \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index e55370394..604fc6980 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -189,4 +189,98 @@ Coperți de album Adaugă către listă de redare Direcție + Fără dată + Playlist gol + Atenție: Folosirea acestei setări poate rezulta în unele taguri interpretate greșit ca având mai multe valori. +\nPoți rezolva asta punând un backslash (\\) înaintea caracterelor de separare nedorite. + Pornește mereu redarea când niște căști sunt conectate (s-ar putea să nu meargă pe toate dispozitivele) + Re-scanează muzica + Șterge memoria cache cu taguri și reîncarcă biblioteca de muzică de tot (mai încet, dar mai complet) + Restaurează starea redării + Cântece încărcate %d + Amestecă toate cântecele + Bleu + Nu se redă muzică + Ștergi %s? Nu te poți răzgândi după aceea. + Artiști încărcați: %d + Playlist importat + Dinamic + + %d artist + %d artiști + %d de artiști + + Arată doar artiști care sunt creditați direct pe albun (Funcționează mai bine pe bibloteci cu taguri puse bine) + Dosarul ăsta nu e suportat + Crează un nou playlist + Copertă album + Bibliotecă + Slash (/) + Deschide lista de așteptare + Salvează starea redării acum + Uită starea redării + Imagine gen pentru %s + Imagine playlist pentru %s + Artist necunoscut + Nu s-a pututu restaura starea + Nu s-a putut salva starea + Vezi mai mult + Configurează caracterele care denotă mai multe valori de taguri + Foldere cu muzică + Foldere + Exclude + Muzica nu va fi încărcată din dosarele pe care le adaugi aici. + Fără cântece + Imagine artist pentru %s + Playlist importat + Autor + Donează + Playlist exportat + Donează proiectului ca să ai numele adăugat aici! + Muzica va fi încărcată doar din folderele pe care le adaugi aici. + Redă automat la conectarea căștilor + N-a fost găsită nicio aplicație care poate face asta + Se editează %s + Discul %d + Playlist %d + Genuri încărcate: %d + Redare + Pauză la repetare + Configurează comportamentul sunetului și redării + Salvează starea redării + Fără track + Configurează de unde se încarcă muzica + Reîncarcă muzica + Copertă album pentru %s + Gen necunoscut + Fără disc + Muzică + Mov închis + Ține minte pauza + Ține minte pauza atunci când dai skip printre cântece + Elimină dosarul + Imagine selecție + Indigo + %d Selectate + Albume încărcate %d + Importă playlist + Include + Reîncarcă biblioteca cu muzică, folosind taguri din memoria cache + Informații despre eroare + Copiat + Raportează + Redă cântecul fără să facă parte din nicio listă + Pune pauză atunci când un cântec se repetă + Mod + Încărcarea muzicii a eșuat + Auxio are nevoie de permisiune ca să-ți acceseze biblioteca de muzică + Mută acest cântec + Niciun dosar + Pornește sau oprește amestecarea + Oprește redarea + Elimină acest cântec + Exportă + Exportă playlistul + Importă + Nu se poate importa un playlist din acest fișier \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 48cd52545..80fd5c6b3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -335,4 +335,6 @@ Пожертвовать Сторонники Сделайте пожертвование проекту, чтобы ваше имя было добавлено сюда! + Оставлять воспроизведение/паузу во время пропуска или редактирования очереди + Запоминать паузу \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 5485d4eb9..8043ac526 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1,29 +1,29 @@ Försök igen - Musik laddar - Laddar musik + Läser in musik + Läser in musik Alla spår Album - Albumet - Remix-album + Album + Remixskiva EP EP - Live-EP + Live EP Remix-EP Singlar Remix-singel Sammanställning Remix-sammanställning Ljudspår - Ljudspår + Soundtrack Blandband - DJ-mixar + DJ-Mixar Live Remixar Framträder på - Konstnär - Konstnärer + Artist + Artister Genrer Spellista Spellistor @@ -47,11 +47,11 @@ Spela nästa Lägg till spellista - Gå till konstnär + Gå till artist Gå till album Visa egenskaper Dela - Egenskaper för låt + Låtegenskaper Format Storlek Samplingsfrekvens @@ -60,34 +60,34 @@ Okej Avbryt Spara - Tillstånd återstallde + Tillstånd återställt Om Källkod Wiki Licenser Visa och kontrollera musikuppspelning Laddar ditt musikbibliotek… - Overvåker ditt musikbibliotek för ändringar… + Övervakar ändringar i ditt musikbibliotek… Tillagd i kö - Spellista skapade + Spellista skapad Tillagd till spellista Sök i ditt musikbibliotek… Inställningar Utseende - Ändra tema och färger på appen + Ändra färger och tema Automatisk Ljust Svart tema - Rundläge + Runt läge Bevilja En enkel, rationell musikspelare för Android. - Övervakar musikbiblioteket + Övervakar musikbibliotek Spår - Live-album + Liveskiva Ta bort Live-sammanställning Singel - Live-singel + Live singel Sammanställningar Blandband DJ-mix @@ -99,33 +99,33 @@ Sortera Lägg till kö Lägg till - Tillstånd tog bort - Bithastighet + Tillstånd togs bort + Överföringskapacitet Återställ Tillstånd sparat Version - Statistik över beroende - Byt namn av spellista + Bibliotekstatistik + Bytt namn på spellista Spellista tog bort - Utvecklad av Alexander Capeheart + Alexander Capeheart Tema Mörkt Färgschema - Använda rent svart för det mörka temat + Använd ren svart till det mörka temat Aktivera rundade hörn på ytterligare element i användargränssnittet (kräver att albumomslag är rundade) Anpassa - Ändra synlighet och ordningsföljd av bibliotekflikar + Ändra synlighet och ordningsföljd på bibliotekflikar Anpassad åtgärd för uppspelningsfält Anpassad aviseringsåtgärd Hoppa till nästa Upprepningsmodus Beteende - När spelar från artikeluppgifter + Vid uppspelning baserat på objektuppgifter Spela från genre - Komma ihåg blandningsstatus + Kom ihåg blanda-status Behåll blandning på när en ny låt spelas - Kontent - Kontrollera hur musik och bilar laddas + Innehåll + Kontrollera hur musik och bilder laddas in Musik Automatisk omladdning Inkludera bara musik @@ -134,33 +134,33 @@ Plus (+) Intelligent sortering Sorterar namn som börjar med siffror eller ord som \"the\" korrekt (fungerar bäst med engelskspråkig music) - Dölj medarbetare + Dölj medverkande Skärm Bibliotekflikar - När spelar från biblioteket - Spela från visad artikel + Vid uppspelning från biblioteket + Spela från visat objekt Spela från alla låtar - Spela från konstnär + Spela från artist Spela från album Semikolon (;) Ladda om musikbiblioteket när det ändras (kräver permanent meddelande) Komma (,) Snedstreck (/) Konfigurera tecken som separerar flera värden i taggar - Advarsel: Denna inställning kan leda till att vissa taggar separeras felaktigt. För att åtgärda detta, prefixa oönskade separatortecken med ett backslash (\\). + Varning: Denna inställning kan leda till att vissa taggar separeras felaktigt. För att åtgärda detta, prefixa oönskade separatortecken med ett backslash (\\). Anpassa UI-kontroller och beteende Av - Hörlurar-autouppspelning + Autouppspelning med hörlurar Pausa när en låt upprepas Musik laddas inte från mapparna som ni lägger till. - Öppna kö + Öppna kön Dynamisk - %d konstnärer som laddats + Inlästa artister: %d - %d konstnär - %d konstnärer + %d artist + %d artister - Bildar + Bilder Ljud Konfigurera ljud- och uppspelningsbeteende Spola tillbaka innan spår hoppar tillbaka @@ -168,23 +168,23 @@ Rensa det tidigare sparade uppspelningsläget om det finns Återställ uppspelningsläge -%.1f dB - Radera %s\? Detta kan inte ångras. + Ta bort %s? Detta kan inte ångras. Endast visa artister som är direkt krediterade på ett album (funkar bäst på välmärkta bibliotek) Albumomslag Snabbt Bibliotek Inkludera Uppdatera musik - Ladda musikbiblioteket om och använd cachad taggar när det är möjligt - Uthållighet + Läs in musik på nytt, vid möjlighet med användning av cachade taggar + Persistens Rensa uppspelningsläge Återställ det tidigare lagrade uppspelningsläget om det finns Misslyckades att spara uppspelningsläget - Blanda alla spår + Blanda alla låtar Rensa sökfrågan - Radera mappen + Ta bort mapp Genrebild för %s - Spellistabild för %s + Bild spellista för %s MPEG-1-ljud MPEG-4-ljud OGG-ljud @@ -204,84 +204,84 @@ Orange Brun Alltid börja uppspelning när hörlurar kopplas till (kanske inte fungerar på alla enheter) - Pausa vid upprepa - ReplayGain förförstärkare + Pausa vid upprepning + ReplayGain försteg Justering utan taggar Musikmappar Varning: Om man ändrar förförstärkaren till ett högt positivt värde kan det leda till toppning på vissa ljudspår. - Hantera var musik bör laddas in från + Hantera vart musik läses in ifrån Mappar Modus Utesluta Musik laddas endast från mapparna som ni lägger till. - Spara det aktuella uppspelningsläget - Skanna musik om + Spara aktuellt uppspelningsläge + Skanna om musik Rensa tagbiblioteket och ladda komplett om musikbiblioteket (långsammare, men mer komplett) Ingen musik på gång - Laddning av musik misslyckades - Auxio behöver tillstånd för att läsa ditt musikbibliotek - Ingen app på gång som kan hantera denna uppgift + Läsa in musik misslyckades + Auxio måste ges behörighet för att läsa in ditt musikbibliotek + Ingen lämplig app kunde hittas Denna mapp stöds inte Misslyckades att återställa uppspelningsläget Spår %d Spela eller pausa Flytta detta spår - Okänd konstnär + Okänd artist Okänd genre - Avancerad audio-koding (AAC) - %d utvalda + Avancerad audio-kodning (AAC) + %d valda Spellista %d +%.1f dB - %d spår - %d spår + %d låt + %d låtar %d album %d album - %d spår som laddats - Total längd: %s + Inlästa låtar: %d + Total spårlängd: %s Kopierade Urval Felinformation Rapportera - Ingen datum - Ingen disk + Inget datum + Ingen skiva Inget spår - Inga spår - Lilla + Inga låtar + Lila %d kbps %d Hz - %d album som laddats - %d genrer som laddats - Spela upp låten själv + Inlästa album: %d + Inlästa genrer: %d + Spela endast vald låt Hög kvalitet Tvinga fyrkantiga skivomslag - Beskär alla albumomslag till en 1:1 sidförhållande + Beskär alla albumomslag till ett 1:1 sidförhållande Spola tillbaka innan att hoppa till föregående låt Justering med taggar Inga mappar Misslyckades att rensa uppspelningsläget Skapa en ny spellista Stoppa uppspelning - Radera detta spår + Ta bort låt Auxio-ikon - Flytta denna flik + Flytta flik Albumomslag - Urvalbild + Urvalsbild Mörklila Indigo - Disk %d + Skiva %d Spara uppspelningsläge - Hoppa till nästa spår - Hoppa till sista spår + Hoppa till nästa låt + Hoppa till sista låt Ändra upprepningsläge - Slå på eller av blandningen + Blanda På/Av Albumomslag för %s - Konstnärbild för %s + Artistbild för %s Ingen musik spelas - Fritt tapsfritt ljudkodek (FLAC) + Fri förlustfri ljudkodek (FLAC) Rosa Laddar ditt musikbibliotek… (%1$d/%2$d) Ampersand (&) @@ -291,4 +291,35 @@ Föredra album om ett album spelar Förförstarkning användas för befintliga justeringar vid uppspelning Röd + Visa mera + Låt + Importerad spellista + Kunde inte importera spellista från denna fil + Lista efter + Riktning + Demo + Demos + Upphovsperson + Spellistan har importerats + Spellistan har exporterats + Skänk projektet ett bidrag så lägger vi till ditt namn här! + Kom ihåg pausat läge + Fortsätt uppspelning/pausat läge vid spårbyte och listredigering + Kunde inte importera spellistan till denna fil + Tom spellista + Importera spellista + Vy + Sökväg + ReplayGain Spårbaserad Volymjustering + ReplayGain Albumbaserad Volymjustering + Donera + Bidragsgivare + Importera + Exportera + Exportera spellista + Sökvägsform + Absolut + Relativ + Använd Windowskompatibla sökvägar + Inga album \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1cfd7c087..1429775f1 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -332,4 +332,6 @@ Підлаштування ReplayGain альбому Пожертвуйте на проєкт, щоб ваше ім\'я було додано сюди! Автор + Залишати відтворення/паузу під час пропуску або редагування черги + Запам\'ятовувати паузу \ No newline at end of file From eb3c320523d135092036b55d587eb6c0eb8dddf1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Feb 2024 12:05:19 -0700 Subject: [PATCH 32/36] home: move speed dial touch setup to onStart If we don't do this, the views won't be there and the app will crash in some circumstances. Resolves #701. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index f5c4c5bd0..1d5c5bdde 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -148,19 +148,6 @@ class HomeFragment : // --- UI SETUP --- - // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate - // it ourselves. - binding.root.rootView.apply { - post { - findViewById(R.id.main_scrim).setOnTouchListener { _, event -> - handleSpeedDialBoundaryTouch(event) - } - findViewById(R.id.sheet_scrim).setOnTouchListener { _, event -> - handleSpeedDialBoundaryTouch(event) - } - } - } - binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeNormalToolbar.apply { setOnMenuItemClickListener(this@HomeFragment) @@ -235,6 +222,23 @@ class HomeFragment : collect(detailModel.toShow.flow, ::handleShow) } + override fun onStart() { + super.onStart() + + // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate + // it ourselves. + requireBinding().root.rootView.apply { + post { + findViewById(R.id.main_scrim).setOnTouchListener { _, event -> + handleSpeedDialBoundaryTouch(event) + } + findViewById(R.id.sheet_scrim).setOnTouchListener { _, event -> + handleSpeedDialBoundaryTouch(event) + } + } + } + } + override fun onSaveInstanceState(outState: Bundle) { val transition = enterTransition if (transition is MaterialSharedAxis) { From 80e08fd74aefc9cf7f5796e9a627ef8ee91c1202 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Feb 2024 12:59:43 -0700 Subject: [PATCH 33/36] build: bump to 3.4.0 Bump to version 3.4.0 (41). --- CHANGELOG.md | 3 ++- README.md | 5 +++-- app/build.gradle | 2 +- fastlane/metadata/android/en-US/changelogs/41.txt | 2 ++ fastlane/metadata/android/en-US/full_description.txt | 1 + 5 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/41.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 68675857d..b7f65701a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## dev (3.4.0) +## 3.4.0 #### What's New - Gapless playback is now used whenever possible @@ -11,6 +11,7 @@ or editing queue #### What's Fixed - Increased music timeout to 60 seconds to accomodate large cover arts on slow storage drives +- Fixed app repeatedly crashing when automatic theme was on #### What's Improved - The playback state is now saved more often, improving persistence diff --git a/README.md b/README.md index 01e723819..aa6611ace 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases @@ -51,6 +51,7 @@ precise/original dates, sort tags, and more - SD Card-aware folder management - Reliable playlisting functionality - Playback state persistence + -Automatic gapless playback - Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files) - External equalizer support (ex. Wavelet) - Edge-to-edge diff --git a/app/build.gradle b/app/build.gradle index 7172c1d86..af0bf21bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId namespace - versionName "3.4.0-dev" + versionName "3.4.0" versionCode 41 minSdk 24 diff --git a/fastlane/metadata/android/en-US/changelogs/41.txt b/fastlane/metadata/android/en-US/changelogs/41.txt new file mode 100644 index 000000000..96c4f675b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/41.txt @@ -0,0 +1,2 @@ +Auxio 3.4.0 adds gapless playback and new widget designs, alongside a variety of fixes. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.4.0 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 3f4927359..fab8fccd9 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -12,6 +12,7 @@ precise/original dates, sort tags, and more - SD Card-aware folder management - Reliable playlisting functionality - Playback state persistence +- Automatic gapless playback - Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files) - External equalizer support (ex. Wavelet) - Edge-to-edge From 23d561cc440285fac76cb5de9085810d54dad387 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Feb 2024 13:37:02 -0700 Subject: [PATCH 34/36] playback: save when service closes This time, actually wait for the save to complete before stopping the foreground state. This requires some checks to make sure that we don't have any "randomly smashing on buttons" errors, but I'm unsure if that will be enough. The last case in which this becomes an issue is when the service is killed outright. I can't do much here to make the state more sane unless I did some playback position monitoring. --- .../auxio/playback/system/PlaybackService.kt | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) 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 a84b5775c..ba54a1f18 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 @@ -194,6 +194,7 @@ class PlaybackService : override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) if (!playbackManager.progression.isPlaying) { + playbackManager.playing(false) endSession() } } @@ -533,6 +534,7 @@ class PlaybackService : ) { super.onPositionDiscontinuity(oldPosition, newPosition, reason) if (reason == Player.DISCONTINUITY_REASON_SEEK) { + // TODO: Once position also naturally drifts by some threshold, save deferSave() } } @@ -638,18 +640,21 @@ class PlaybackService : // --- OTHER FUNCTIONS --- private fun deferSave() { + saveJob { + logD("Waiting for save buffer") + delay(SAVE_BUFFER) + yield() + logD("Committing saved state") + persistenceRepository.saveState(playbackManager.toSavedState()) + } + } + + private fun saveJob(block: suspend () -> Unit) { currentSaveJob?.let { logD("Discarding prior save job") it.cancel() } - currentSaveJob = - saveScope.launch { - logD("Waiting for save buffer") - delay(SAVE_BUFFER) - yield() - logD("Committing saved state") - persistenceRepository.saveState(playbackManager.toSavedState()) - } + currentSaveJob = saveScope.launch { block() } } private fun broadcastAudioEffectAction(event: String) { @@ -664,8 +669,19 @@ class PlaybackService : private fun endSession() { // This session has ended, so we need to reset this flag for when the next // session starts. - hasPlayed = false - foregroundManager.tryStopForeground() + saveJob { + logD("Committing saved state") + persistenceRepository.saveState(playbackManager.toSavedState()) + withContext(Dispatchers.Main) { + // User could feasibly start playing again if they were fast enough, so + // we need to avoid stopping the foreground state if that's the case. + if (!player.isPlaying) { + hasPlayed = false + playbackManager.playing(false) + foregroundManager.tryStopForeground() + } + } + } } /** From f3261ded43ff0e070e6c9cddd0a37dc09045d38d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Feb 2024 14:34:40 -0700 Subject: [PATCH 35/36] about: add yrliet to supporters --- .../org/oxycblt/auxio/settings/AboutFragment.kt | 4 ++++ .../{ic_author_24.xml => ic_person_24.xml} | 0 app/src/main/res/layout/fragment_about.xml | 14 +++++++++++++- app/src/main/res/values/donottranslate.xml | 3 +++ 4 files changed, 20 insertions(+), 1 deletion(-) rename app/src/main/res/drawable/{ic_author_24.xml => ic_person_24.xml} (100%) diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index a80bc446d..2ece33656 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -68,6 +68,9 @@ class AboutFragment : ViewBindingFragment() { binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) } binding.aboutProfile.setOnClickListener { requireContext().openInBrowser(LINK_PROFILE) } binding.aboutDonate.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) } + binding.aboutSupporterYrliet.setOnClickListener { + requireContext().openInBrowser(LINK_YRLIET) + } binding.aboutSupportersPromo.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) } @@ -97,5 +100,6 @@ class AboutFragment : ViewBindingFragment() { const val LINK_LICENSES = "$LINK_WIKI/Licenses" const val LINK_PROFILE = "https://github.com/OxygenCobalt" const val LINK_DONATE = "https://github.com/sponsors/OxygenCobalt" + const val LINK_YRLIET = "https://github.com/yrliet" } } diff --git a/app/src/main/res/drawable/ic_author_24.xml b/app/src/main/res/drawable/ic_person_24.xml similarity index 100% rename from app/src/main/res/drawable/ic_author_24.xml rename to app/src/main/res/drawable/ic_person_24.xml diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 1088aff21..06211fab4 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -182,7 +182,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/lbl_author_name" - app:drawableStartCompat="@drawable/ic_author_24" + app:drawableStartCompat="@drawable/ic_person_24" app:drawableTint="?attr/colorControlNormal" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -224,6 +224,18 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> + + Vorbis Opus Microsoft WAVE + + + yrliet \ No newline at end of file From be1ee55b96332b1dc082a901c13ec47f04eda0e2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Feb 2024 14:36:51 -0700 Subject: [PATCH 36/36] info: fix readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aa6611ace..084ea5a5a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ precise/original dates, sort tags, and more - SD Card-aware folder management - Reliable playlisting functionality - Playback state persistence - -Automatic gapless playback +- Automatic gapless playback - Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files) - External equalizer support (ex. Wavelet) - Edge-to-edge