From e32c687c61b0133d59d9fd477d5be19208628b6a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 23 Sep 2024 11:46:54 -0600 Subject: [PATCH] playback: extract gapless playback impl I need to make a setting to switch between gapless and single-item playback to accomodate extremely large queues, so extract the crazy hacky queue stuff into a new PlayerKernel construct. Single-item will be added at a later point. --- .../service/ExoPlaybackStateHolder.kt | 313 ++++++------------ .../playback/service/GaplessPlayerKernel.kt | 181 ++++++++++ .../auxio/playback/service/PlayerKernel.kt | 51 +++ 3 files changed, 335 insertions(+), 210 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/GaplessPlayerKernel.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/PlayerKernel.kt diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 4e95e54d4..bb51708b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -63,20 +63,74 @@ import org.oxycblt.auxio.util.logE class ExoPlaybackStateHolder( private val context: Context, - private val player: ExoPlayer, + private val kernel: PlayerKernel, private val playbackManager: PlaybackStateManager, private val persistenceRepository: PersistenceRepository, private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, private val replayGainProcessor: ReplayGainAudioProcessor, private val musicRepository: MusicRepository, - private val imageSettings: ImageSettings + private val imageSettings: ImageSettings, ) : PlaybackStateHolder, Player.Listener, MusicRepository.UpdateListener, - PlaybackSettings.Listener, ImageSettings.Listener { + class Factory + @Inject + constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val persistenceRepository: PersistenceRepository, + private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val mediaSourceFactory: MediaSource.Factory, + private val replayGainProcessor: ReplayGainAudioProcessor, + private val musicRepository: MusicRepository, + private val imageSettings: ImageSettings, + ) { + fun create(): ExoPlaybackStateHolder { + // Since Auxio is a music player, only specify an audio renderer to save + // battery/apk size/cache size + val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> + arrayOf( + FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), + MediaCodecAudioRenderer( + context, + MediaCodecSelector.DEFAULT, + handler, + audioListener, + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + replayGainProcessor)) + } + + val exoPlayer = + ExoPlayer.Builder(context, audioRenderer) + .setMediaSourceFactory(mediaSourceFactory) + // Enable automatic WakeLock support + .setWakeMode(C.WAKE_MODE_LOCAL) + .setAudioAttributes( + // Signal that we are a music player. + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + true) + .build() + + return ExoPlaybackStateHolder( + context, + GaplessPlayerKernel(exoPlayer, playbackSettings), + playbackManager, + persistenceRepository, + playbackSettings, + commandFactory, + replayGainProcessor, + musicRepository, + imageSettings) + } + } + private val saveJob = Job() private val saveScope = CoroutineScope(Dispatchers.IO + saveJob) private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob) @@ -88,20 +142,19 @@ class ExoPlaybackStateHolder( fun attach() { imageSettings.registerListener(this) - player.addListener(this) + kernel.addListener(this) playbackManager.registerStateHolder(this) - playbackSettings.registerListener(this) musicRepository.addUpdateListener(this) } fun release() { saveJob.cancel() - player.removeListener(this) + kernel.removeListener(this) playbackManager.unregisterStateHolder(this) musicRepository.removeUpdateListener(this) replayGainProcessor.release() imageSettings.unregisterListener(this) - player.release() + kernel.release() } override var parent: MusicParent? = null @@ -109,15 +162,15 @@ class ExoPlaybackStateHolder( override val progression: Progression get() { - val mediaItem = player.currentMediaItem ?: return Progression.nil() + val mediaItem = kernel.currentMediaItem ?: return Progression.nil() val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE - val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration) - return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition) + val clampedPosition = kernel.currentPosition.coerceAtLeast(0).coerceAtMost(duration) + return Progression.from(kernel.playWhenReady, kernel.isPlaying, clampedPosition) } override val repeatMode get() = - when (val repeatMode = player.repeatMode) { + when (val repeatMode = kernel.repeatMode) { Player.REPEAT_MODE_OFF -> RepeatMode.NONE Player.REPEAT_MODE_ONE -> RepeatMode.TRACK Player.REPEAT_MODE_ALL -> RepeatMode.ALL @@ -125,21 +178,12 @@ class ExoPlaybackStateHolder( } override val audioSessionId: Int - get() = player.audioSessionId + get() = kernel.audioSessionId override fun resolveQueue(): RawQueue { - val deviceLibrary = - musicRepository.deviceLibrary - // No library, cannot do anything. - ?: return RawQueue(emptyList(), emptyList(), 0) - val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) } - val shuffledMapping = - if (player.shuffleModeEnabled) { - player.unscrambleQueueIndices() - } else { - emptyList() - } - return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex) + val heap = kernel.computeHeap() + val shuffledMapping = if (kernel.shuffleModeEnabled) kernel.computeMapping() else emptyList() + return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, kernel.currentMediaItemIndex) } override fun handleDeferred(action: DeferredPlayback): Boolean { @@ -192,53 +236,41 @@ class ExoPlaybackStateHolder( } override fun playing(playing: Boolean) { - player.playWhenReady = playing + kernel.playWhenReady = playing } override fun seekTo(positionMs: Long) { - player.seekTo(positionMs) + kernel.seekTo(positionMs) deferSave() // Ack handled w/ExoPlayer events } override fun repeatMode(repeatMode: RepeatMode) { - player.repeatMode = + kernel.repeatMode = when (repeatMode) { RepeatMode.NONE -> Player.REPEAT_MODE_OFF RepeatMode.ALL -> Player.REPEAT_MODE_ALL RepeatMode.TRACK -> Player.REPEAT_MODE_ONE } - updatePauseOnRepeat() playbackManager.ack(this, StateAck.RepeatModeChanged) deferSave() } override fun newPlayback(command: PlaybackCommand) { parent = command.parent - player.shuffleModeEnabled = command.shuffled - player.setMediaItems(command.queue.map { it.buildMediaItem() }) + val mediaItems = command.queue.map { it.buildMediaItem() } val startIndex = command.song ?.let { command.queue.indexOf(it) } .also { check(it != -1) { "Start song not in queue" } } - if (command.shuffled) { - player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1)) - } - val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled) - player.seekTo(target, C.TIME_UNSET) - player.prepare() - player.play() + kernel.prepareNew(mediaItems, startIndex, command.shuffled) + kernel.play() playbackManager.ack(this, StateAck.NewPlayback) deferSave() } override fun shuffled(shuffled: Boolean) { - player.setShuffleModeEnabled(shuffled) - if (player.shuffleModeEnabled) { - // Have to manually refresh the shuffle seed and anchor it to the new current songs - player.setShuffleOrder( - BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) - } + kernel.shuffled(shuffled) playbackManager.ack(this, StateAck.QueueReordered) deferSave() } @@ -247,18 +279,17 @@ class ExoPlaybackStateHolder( // 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_ALL || player.hasNextMediaItem()) { - player.seekToNext() + if (kernel.repeatMode == Player.REPEAT_MODE_ALL || kernel.hasNextMediaItem()) { + kernel.seekToNext() if (!playbackSettings.rememberPause) { - player.play() + kernel.play() } } else { - player.seekTo( - player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET) + kernel.goto(kernel.computeFirstMediaItemIndex()) // 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() + kernel.pause() } } playbackManager.ack(this, StateAck.IndexMoved) @@ -267,94 +298,70 @@ class ExoPlaybackStateHolder( override fun prev() { if (playbackSettings.rewindWithPrev) { - player.seekToPrevious() - } else if (player.hasPreviousMediaItem()) { - player.seekToPreviousMediaItem() + kernel.seekToPrevious() + } else if (kernel.hasPreviousMediaItem()) { + kernel.seekToPreviousMediaItem() } else { - player.seekTo(0) + kernel.seekTo(0) } if (!playbackSettings.rememberPause) { - player.play() + kernel.play() } playbackManager.ack(this, StateAck.IndexMoved) deferSave() } override fun goto(index: Int) { - val indices = player.unscrambleQueueIndices() + val indices = kernel.computeMapping() if (indices.isEmpty()) { return } - val trueIndex = indices[index] - player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic + kernel.goto(trueIndex) if (!playbackSettings.rememberPause) { - player.play() + kernel.play() } playbackManager.ack(this, StateAck.IndexMoved) deferSave() } override fun playNext(songs: List, ack: StateAck.PlayNext) { - val currTimeline = player.currentTimeline - val nextIndex = - if (currTimeline.isEmpty) { - C.INDEX_UNSET - } else { - currTimeline.getNextWindowIndex( - player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled) - } - - if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.buildMediaItem() }) - } else { - player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() }) - } + kernel.addBottomMediaItems(songs.map { it.buildMediaItem() }) playbackManager.ack(this, ack) deferSave() } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.buildMediaItem() }) + kernel.addTopMediaItems(songs.map { it.buildMediaItem() }) playbackManager.ack(this, ack) deferSave() } override fun move(from: Int, to: Int, ack: StateAck.Move) { - val indices = player.unscrambleQueueIndices() + val indices = kernel.computeMapping() if (indices.isEmpty()) { return } val trueFrom = indices[from] val trueTo = indices[to] - // ExoPlayer does not actually update it's ShuffleOrder when moving items. Retain a - // semblance of "normalcy" by doing a weird no-op swap that actually moves the item. - when { - trueFrom > trueTo -> { - player.moveMediaItem(trueFrom, trueTo) - player.moveMediaItem(trueTo + 1, trueFrom) - } - trueTo > trueFrom -> { - player.moveMediaItem(trueFrom, trueTo) - player.moveMediaItem(trueTo - 1, trueFrom) - } - } + + kernel.moveMediaItem(trueFrom, trueTo) playbackManager.ack(this, ack) deferSave() } override fun remove(at: Int, ack: StateAck.Remove) { - val indices = player.unscrambleQueueIndices() + val indices = kernel.computeMapping() if (indices.isEmpty()) { return } val trueIndex = indices[at] - val songWillChange = player.currentMediaItemIndex == trueIndex - player.removeMediaItem(trueIndex) + val songWillChange = kernel.currentMediaItemIndex == trueIndex + kernel.removeMediaItem(trueIndex) if (songWillChange && !playbackSettings.rememberPause) { - player.play() + kernel.play() } playbackManager.ack(this, ack) deferSave() @@ -372,16 +379,8 @@ class ExoPlaybackStateHolder( sendEvent = true } if (rawQueue != resolveQueue()) { - player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) - 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() - player.pause() + kernel.prepareSaved(rawQueue.heap.map { it.buildMediaItem() }, rawQueue.shuffledMapping, rawQueue.heapIndex, rawQueue.isShuffled) + kernel.pause() sendEvent = true } if (sendEvent) { @@ -404,7 +403,7 @@ class ExoPlaybackStateHolder( } override fun reset(ack: StateAck.NewPlayback) { - player.setMediaItems(listOf()) + kernel.discard() playbackManager.ack(this, ack) deferSave() } @@ -414,7 +413,7 @@ class ExoPlaybackStateHolder( override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { super.onPlayWhenReadyChanged(playWhenReady, reason) - if (player.playWhenReady) { + if (kernel.playWhenReady) { // Mark that we have started playing so that the notification can now be posted. logD("Player has started playing") sessionOngoing = true @@ -436,9 +435,9 @@ class ExoPlaybackStateHolder( override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) - if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) { + if (playbackState == Player.STATE_ENDED && kernel.repeatMode == Player.REPEAT_MODE_OFF) { goto(0) - player.pause() + kernel.pause() } } @@ -491,18 +490,6 @@ class ExoPlaybackStateHolder( } } - // --- PLAYBACKSETTINGS OVERRIDES --- - - override fun onPauseOnRepeatChanged() { - super.onPauseOnRepeatChanged() - updatePauseOnRepeat() - } - - private fun updatePauseOnRepeat() { - player.pauseAtEndOfMediaItems = - player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat - } - private fun save(cb: () -> Unit) { saveJob { persistenceRepository.saveState(playbackManager.toSavedState()) @@ -533,100 +520,6 @@ class ExoPlaybackStateHolder( private val MediaItem.song: Song? get() = this.localConfiguration?.tag as? Song? - private fun Player.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 - } - - class Factory - @Inject - constructor( - @ApplicationContext private val context: Context, - private val playbackManager: PlaybackStateManager, - private val persistenceRepository: PersistenceRepository, - private val playbackSettings: PlaybackSettings, - private val commandFactory: PlaybackCommand.Factory, - private val mediaSourceFactory: MediaSource.Factory, - private val replayGainProcessor: ReplayGainAudioProcessor, - private val musicRepository: MusicRepository, - private val imageSettings: ImageSettings, - ) { - fun create(): ExoPlaybackStateHolder { - // Since Auxio is a music player, only specify an audio renderer to save - // battery/apk size/cache size] - val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> - arrayOf( - FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), - MediaCodecAudioRenderer( - context, - MediaCodecSelector.DEFAULT, - handler, - audioListener, - AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, - replayGainProcessor)) - } - - val exoPlayer = - ExoPlayer.Builder(context, audioRenderer) - .setMediaSourceFactory(mediaSourceFactory) - // Enable automatic WakeLock support - .setWakeMode(C.WAKE_MODE_LOCAL) - .setAudioAttributes( - // Signal that we are a music player. - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - true) - .build() - - return ExoPlaybackStateHolder( - context, - exoPlayer, - playbackManager, - persistenceRepository, - playbackSettings, - commandFactory, - replayGainProcessor, - musicRepository, - imageSettings) - } - } - private companion object { const val SAVE_BUFFER = 5000L } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/GaplessPlayerKernel.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/GaplessPlayerKernel.kt new file mode 100644 index 000000000..d0fbefd4f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/GaplessPlayerKernel.kt @@ -0,0 +1,181 @@ +package org.oxycblt.auxio.playback.service + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import org.oxycblt.auxio.playback.PlaybackSettings + +class GaplessPlayerKernel(private val exoPlayer: ExoPlayer, private val playbackSettings: PlaybackSettings) : PlayerKernel, PlaybackSettings.Listener { + init { + playbackSettings.registerListener(this) + } + + override val isPlaying: Boolean = exoPlayer.isPlaying + override var playWhenReady: Boolean = exoPlayer.playWhenReady + set(value) { + field = value + exoPlayer.playWhenReady = value + } + override val currentPosition: Long = exoPlayer.currentPosition + @get:Player.RepeatMode override var repeatMode: Int = exoPlayer.repeatMode + set(value) { + field = value + exoPlayer.repeatMode = value + updatePauseOnRepeat() + } + override val audioSessionId: Int = exoPlayer.audioSessionId + + override fun computeHeap(): List { + return (0 until exoPlayer.mediaItemCount).map { exoPlayer.getMediaItemAt(it) } + } + + override fun computeMapping(): List { + val timeline = exoPlayer.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 = exoPlayer.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 + } + + override fun computeFirstMediaItemIndex() = + exoPlayer.currentTimeline.getFirstWindowIndex(exoPlayer.shuffleModeEnabled) + + override fun addListener(player: Player.Listener) = exoPlayer.addListener(player) + override fun removeListener(player: Player.Listener) = exoPlayer.removeListener(player) + override fun release() { + exoPlayer.release() + playbackSettings.unregisterListener(this) + } + + override val currentMediaItem: MediaItem? = exoPlayer.currentMediaItem + override val currentMediaItemIndex: Int = exoPlayer.currentMediaItemIndex + override val shuffleModeEnabled: Boolean = exoPlayer.shuffleModeEnabled + + override fun play() = exoPlayer.play() + override fun pause() = exoPlayer.pause() + override fun seekTo(positionMs: Long) = exoPlayer.seekTo(positionMs) + override fun goto(mediaItemIndex: Int) = exoPlayer.seekTo(mediaItemIndex, C.TIME_UNSET) + + override fun seekToNext() = exoPlayer.seekToNext() + override fun hasNextMediaItem() = exoPlayer.hasNextMediaItem() + override fun seekToPrevious() = exoPlayer.seekToPrevious() + override fun seekToPreviousMediaItem() = exoPlayer.seekToPreviousMediaItem() + override fun hasPreviousMediaItem() = exoPlayer.hasPreviousMediaItem() + + override fun prepareNew(mediaItems: List, startIndex: Int?, shuffled: Boolean) { + exoPlayer.shuffleModeEnabled = shuffled + exoPlayer.setMediaItems(mediaItems) + if (shuffled) { + exoPlayer.setShuffleOrder(BetterShuffleOrder(mediaItems.size, startIndex ?: -1)) + } + val target = startIndex ?: exoPlayer.currentTimeline.getFirstWindowIndex(shuffled) + exoPlayer.seekTo(target, C.TIME_UNSET) + exoPlayer.prepare() + } + + override fun prepareSaved(mediaItems: List, mapping: List, index: Int, shuffled: Boolean) { + exoPlayer.setMediaItems(mediaItems) + if (shuffled) { + exoPlayer.shuffleModeEnabled = true + exoPlayer.setShuffleOrder(BetterShuffleOrder(mapping.toIntArray())) + } else { + exoPlayer.shuffleModeEnabled = false + } + exoPlayer.seekTo(index, C.TIME_UNSET) + exoPlayer.prepare() + } + + override fun discard() { + exoPlayer.setMediaItems(emptyList()) + } + + override fun addTopMediaItems(mediaItems: List) { + val currTimeline = exoPlayer.currentTimeline + val nextIndex = + if (currTimeline.isEmpty) { + C.INDEX_UNSET + } else { + currTimeline.getNextWindowIndex( + exoPlayer.currentMediaItemIndex, Player.REPEAT_MODE_OFF, exoPlayer.shuffleModeEnabled) + } + + if (nextIndex == C.INDEX_UNSET) { + exoPlayer.addMediaItems(mediaItems) + } else { + exoPlayer.addMediaItems(nextIndex, mediaItems) + } + } + + override fun addBottomMediaItems(mediaItems: List) { + exoPlayer.addMediaItems(mediaItems) + } + + override fun moveMediaItem(fromIndex: Int, toIndex: Int) { + // ExoPlayer does not actually update it's ShuffleOrder when moving items. Retain a + // semblance of "normalcy" by doing a weird no-op swap that actually moves the item. + when { + fromIndex > toIndex -> { + exoPlayer.moveMediaItem(fromIndex, toIndex) + exoPlayer.moveMediaItem(toIndex + 1, fromIndex) + } + toIndex > fromIndex -> { + exoPlayer.moveMediaItem(fromIndex, toIndex) + exoPlayer.moveMediaItem(toIndex - 1, fromIndex) + } + } + } + + override fun removeMediaItem(index: Int) = exoPlayer.removeMediaItem(index) + + override fun shuffled(shuffled: Boolean) { + exoPlayer.setShuffleModeEnabled(shuffled) + if (exoPlayer.shuffleModeEnabled) { + // Have to manually refresh the shuffle seed and anchor it to the new current songs + exoPlayer.setShuffleOrder( + BetterShuffleOrder(exoPlayer.mediaItemCount, exoPlayer.currentMediaItemIndex)) + } + } + + + override fun onPauseOnRepeatChanged() { + super.onPauseOnRepeatChanged() + updatePauseOnRepeat() + } + + private fun updatePauseOnRepeat() { + exoPlayer.pauseAtEndOfMediaItems = + exoPlayer.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlayerKernel.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlayerKernel.kt new file mode 100644 index 000000000..1615088b3 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlayerKernel.kt @@ -0,0 +1,51 @@ +package org.oxycblt.auxio.playback.service + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.state.RawQueue +import org.oxycblt.auxio.playback.state.RepeatMode + +interface PlayerKernel { + // REPLICAS + val isPlaying: Boolean + var playWhenReady: Boolean + val currentPosition: Long + @get:Player.RepeatMode var repeatMode: Int + val audioSessionId: Int + val currentMediaItem: MediaItem? + val currentMediaItemIndex: Int + val shuffleModeEnabled: Boolean + + fun addListener(player: Player.Listener) + fun removeListener(player: Player.Listener) + fun release() + + fun play() + fun pause() + fun seekTo(positionMs: Long) + fun goto(mediaItemIndex: Int) + + fun seekToNext() + fun hasNextMediaItem(): Boolean + fun seekToPrevious() + fun seekToPreviousMediaItem() + fun hasPreviousMediaItem(): Boolean + + fun moveMediaItem(fromIndex: Int, toIndex: Int) + fun removeMediaItem(index: Int) + + // EXTENSIONS + fun computeHeap(): List + fun computeMapping(): List + fun computeFirstMediaItemIndex(): Int + + fun prepareNew(mediaItems: List, startIndex: Int?, shuffled: Boolean) + fun prepareSaved(mediaItems: List, mapping: List, index: Int, shuffled: Boolean) + fun discard() + + fun addTopMediaItems(mediaItems: List) + fun addBottomMediaItems(mediaItems: List) + fun shuffled(shuffled: Boolean) +} +