playback: ramshack initial gapless playback impl

This commit is contained in:
Alexander Capehart 2024-01-07 15:52:19 -07:00
parent 5d5356e46e
commit 26d14ec6e1
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
15 changed files with 1043 additions and 785 deletions

View file

@ -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
}

View file

@ -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() {

View file

@ -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 ---

View file

@ -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 {

View file

@ -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<Int>
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<Int>
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)

View file

@ -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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song>,
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<Song>)
fun addToQueue(songs: List<Song>)
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<Song>) {
companion object {
fun nil() = Queue(-1, emptyList())
}
}
data class SavedQueue(
val heap: List<Song?>,
val orderedMapping: List<Int>,
val shuffledMapping: List<Int>,
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())
}
}

View file

@ -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<PlaybackStateManager.Listener>()
@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<Song>, 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<Song>) {
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<Song>) {
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)
}
}
}

View file

@ -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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song>, 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<Song>, 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<Song>) {
addMediaItems(nextMediaItemIndex, songs.map { it.toMediaItem() })
}
fun ExoPlayer.addToQueue(songs: List<Song>) {
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<Song> {
return unscrambleQueue { index -> getMediaItemAt(index).song }
}
inline fun <T> ExoPlayer.unscrambleQueue(mapper: (Int) -> T): List<T> {
val timeline = currentTimeline
if (timeline.isEmpty()) {
return emptyList()
}
val queue = mutableListOf<T>()
// 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

View file

@ -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

View file

@ -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")

View file

@ -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<Song>,
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<Song>) {
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<Song>) {
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)
}
}
}

View file

@ -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]
*/