diff --git a/app/src/main/java/org/oxycblt/auxio/image/coil/CoilModule.kt b/app/src/main/java/org/oxycblt/auxio/image/coil/CoilModule.kt index f59fe3492d..6b5f8291eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/coil/CoilModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/coil/CoilModule.kt @@ -28,6 +28,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers @Module @InstallIn(SingletonComponent::class) @@ -60,5 +61,17 @@ class CoilModule { .transitionFactory(ErrorCrossfadeTransitionFactory()) // Not downloading anything, so no disk-caching .diskCachePolicy(CachePolicy.DISABLED) + // Coil defaults these to Dispatchers.IO (64 threads), which lets fast scrolling + // saturate every core with cover decodes and starve the UI thread. One shared + // limiter caps total concurrent cover work. + .fetcherCoroutineContext(coverDispatcher) + .decoderCoroutineContext(coverDispatcher) .build() + + private companion object { + val coverDispatcher = + Dispatchers.IO.limitedParallelism( + (Runtime.getRuntime().availableProcessors() / 2).coerceIn(2, 4) + ) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCompositionFetcher.kt b/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCompositionFetcher.kt index 2f7bc5ac3c..18606117a8 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCompositionFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/coil/CoverCompositionFetcher.kt @@ -53,11 +53,13 @@ abstract class CoverCompositionFetcher( private val size: Size, ) : Fetcher { final override suspend fun fetch(): FetchResult? { + val squareSize = + min(size.width.pxOrElse { 512 }, size.height.pxOrElse { 512 }).coerceAtLeast(1) val bitmaps = data.covers.covers .asFlow() - .mapNotNull { cover -> cover.open() } - .mapNotNull { stream -> BitmapFactory.decodeStream(stream).also { stream.close() } } + .mapNotNull { cover -> cover.open()?.use { it.readBytes() } } + .mapNotNull { bytes -> decodeSampled(bytes, squareSize) } .take(4) .toList() if (bitmaps.size < 4) { @@ -69,8 +71,6 @@ abstract class CoverCompositionFetcher( ) } - val squareSize = - min(size.width.pxOrElse { 512 }, size.height.pxOrElse { 512 }).coerceAtLeast(1) val random = Random(data.seed) for (i in 0..10) { // cycle random a few times @@ -96,6 +96,30 @@ abstract class CoverCompositionFetcher( */ protected abstract fun compose(bitmaps: List, size: Int, random: Random): Bitmap + private fun decodeSampled(bytes: ByteArray, target: Int): Bitmap? { + val options = + BitmapFactory.Options().apply { + inJustDecodeBounds = true + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, this) + inSampleSize = calculateInSampleSize(outWidth, outHeight, target) + inJustDecodeBounds = false + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + } + + /** + * The largest power-of-two downscale keeping the cover at or above [target] pixels. Four + * full-resolution covers held at once is enough to exhaust the heap. + */ + private fun calculateInSampleSize(width: Int, height: Int, target: Int): Int { + if (width <= 0 || height <= 0) return 1 + var inSampleSize = 1 + while (min(width, height) / (inSampleSize * 2) >= target) { + inSampleSize *= 2 + } + return inSampleSize + } + protected fun drawBitmapCover(canvas: Canvas, bitmap: Bitmap, dest: RectF, paint: Paint) { val bitmapRatio = bitmap.width.toFloat() / bitmap.height.toFloat() val destRatio = dest.width() / dest.height() diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt index 0b6d796b0c..278d2ad655 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt @@ -50,6 +50,8 @@ import androidx.core.widget.TextViewCompat import androidx.dynamicanimation.animation.FloatValueHolder import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.R as MR import com.google.android.material.motion.MotionUtils @@ -276,8 +278,35 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } var popupProvider: PopupProvider? = null + set(value) { + field = value + invalidatePopupData() + } + var listener: Listener? = null + // Deriving popup text is not free and onPreDraw runs on every draw pass, so memoize it + // against the position it was derived from. + private var popupDataPos = NO_POSITION + private var popupDataText: String? = null + + private val popupDataObserver = + object : AdapterDataObserver() { + override fun onChanged() = invalidatePopupData() + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) = + invalidatePopupData() + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) = + invalidatePopupData() + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) = + invalidatePopupData() + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) = + invalidatePopupData() + } + init { overlay.add(thumbView) overlay.add(popupView) @@ -347,17 +376,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr val provider = popupProvider val hasPopupProvider = firstAdapterPos != NO_POSITION && provider != null - val popupData = - if (hasPopupProvider) { - provider.getPopupData(firstAdapterPos) - } else { - null - } val popupText: String if (hasPopupProvider) { popupView.isInvisible = false - // Get the popup text. If there is none, we default to "?". - popupText = popupData?.text ?: "?" + if (firstAdapterPos != popupDataPos) { + popupDataPos = firstAdapterPos + // Get the popup text. If there is none, we default to "?". + popupDataText = provider.getPopupData(firstAdapterPos)?.text ?: "?" + } + popupText = popupDataText ?: "?" } else { // No valid position or provider, do not show the popup. popupView.isInvisible = true @@ -420,6 +447,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight) } + override fun setAdapter(adapter: Adapter<*>?) { + this.adapter?.unregisterAdapterDataObserver(popupDataObserver) + super.setAdapter(adapter) + adapter?.registerAdapterDataObserver(popupDataObserver) + invalidatePopupData() + } + + private fun invalidatePopupData() { + popupDataPos = NO_POSITION + popupDataText = null + } + override fun onScrolled(dx: Int, dy: Int) { super.onScrolled(dx, dy) @@ -531,9 +570,49 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr return } val dy = newOffsetY - previousOffsetY + // scrollBy lays out every row it travels past, which on a large list can be thousands of + // rows in one touch event. Past a couple of screenfuls, jump instead of walking. + if (abs(dy) > height * MAX_SCROLL_BY_SCREENS && jumpToOffset(newOffsetY)) { + return + } scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset())) } + /** + * Jump directly to the row nearest [offsetY], estimated off the average laid-out row height. + * Returns false if there is nothing laid out to estimate from. + */ + private fun jumpToOffset(offsetY: Float): Boolean { + val layoutManager = layoutManager as? LinearLayoutManager ?: return false + val rowHeight = averageRowHeight() + if (rowHeight <= 0) { + return false + } + val itemCount = adapter?.itemCount ?: return false + if (itemCount == 0) { + return false + } + val spanCount = (layoutManager as? GridLayoutManager)?.spanCount ?: 1 + val row = (offsetY / rowHeight).toInt() + val position = (row * spanCount).coerceIn(0, itemCount - 1) + val positionOffset = -(offsetY.toInt() % rowHeight) + stopScroll() + layoutManager.scrollToPositionWithOffset(position, positionOffset) + return true + } + + private fun averageRowHeight(): Int { + val childCount = childCount + if (childCount == 0) { + return 0 + } + var total = 0 + for (i in 0 until childCount) { + total += getChildAt(i).height + } + return total / childCount + } + // --- SCROLLBAR APPEARANCE --- private fun postAutoHideScrollbar() { @@ -759,6 +838,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr const val POPUP_SHAPE_HIDDEN_SCALE = 0.62f const val POPUP_TEXT_HIDDEN_SCALE = 0.78f const val POPUP_TEXT_SINGLE_LINE_COUNT = 1 + + /** + * How many screenfuls of content a fast-scroll drag may cover with an incremental scroll + * before it jumps instead. Bounds the worst-case number of rows laid out inside a single + * touch event. + */ + const val MAX_SCROLL_BY_SCREENS = 2 } private data class PopupTextAutoScaleKey(val exemplar: String?, val popupSize: Int) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackUtil.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackUtil.kt index 64cd7f2969..137c55ecb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackUtil.kt @@ -60,6 +60,21 @@ fun Long.dsToSecs() = floorDiv(10) */ fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed) +private var popupDurationLocale: Locale? = null +private var popupDurationFormat: MeasureFormat? = null + +/** The [MeasureFormat] used by [formatDurationMsPopup], memoized against the current locale. */ +private fun popupDurationFormat(): MeasureFormat { + val locale = Locale.getDefault() + var format = popupDurationFormat + if (format == null || popupDurationLocale != locale) { + format = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.NARROW) + popupDurationFormat = format + popupDurationLocale = locale + } + return format +} + /** * Format a millisecond duration into a compact, locale-aware bucket string suitable for fast-scroll * popups. Durations are bucketed into the most significant time unit: @@ -70,7 +85,7 @@ fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(is fun Long.formatDurationMsPopup(): String { val totalMinutes = floorDiv(60_000) val totalHours = totalMinutes / 60 - val fmt = MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.NARROW) + val fmt = popupDurationFormat() return when { totalMinutes < 1 -> "<" + fmt.format(Measure(1, MeasureUnit.MINUTE)) totalHours < 1 -> fmt.format(Measure(totalMinutes, MeasureUnit.MINUTE)) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt deleted file mode 100644 index 0b95e4c4b1..0000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2017 Auxio Project - * BetterShuffleOrder.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback.service - -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.source.ShuffleOrder - -/** - * A ShuffleOrder that fixes the poorly defined default implementation of cloneAndInsert. Whereas - * the default implementation will randomly spread out added media items, this implementation will - * insert them in the order they are added contiguously. - * - * @author media3 team, Alexander Capehart (OxygenCobalt) - */ -@OptIn(UnstableApi::class) -class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder { - private val indexInShuffled: IntArray = IntArray(shuffled.size) - - constructor(length: Int, startIndex: Int) : this(createShuffledList(length, startIndex)) - - init { - for (i in shuffled.indices) { - indexInShuffled[shuffled[i]] = i - } - } - - override fun getLength(): Int { - return shuffled.size - } - - override fun getNextIndex(index: Int): Int { - var shuffledIndex = indexInShuffled[index] - return if (++shuffledIndex < shuffled.size) shuffled[shuffledIndex] else C.INDEX_UNSET - } - - override fun getPreviousIndex(index: Int): Int { - var shuffledIndex = indexInShuffled[index] - return if (--shuffledIndex >= 0) shuffled[shuffledIndex] else C.INDEX_UNSET - } - - override fun getLastIndex(): Int { - return if (shuffled.isNotEmpty()) shuffled[shuffled.size - 1] else C.INDEX_UNSET - } - - override fun getFirstIndex(): Int { - return if (shuffled.isNotEmpty()) shuffled[0] else C.INDEX_UNSET - } - - @Suppress("KotlinConstantConditions") // Bugged for this function - override fun cloneAndInsert(insertionIndex: Int, insertionCount: Int): ShuffleOrder { - if (shuffled.isEmpty()) { - return BetterShuffleOrder(insertionCount, -1) - } - - // TODO: Fix this scuffed hacky logic - // TODO: Play next ordering needs to persist in unshuffle - - val newShuffled = IntArray(shuffled.size + insertionCount) - val pivot: Int = - if (insertionIndex < shuffled.size) { - indexInShuffled[insertionIndex] - } else { - indexInShuffled.size - } - for (i in shuffled.indices) { - var currentIndex = shuffled[i] - if (currentIndex > insertionIndex) { - currentIndex += insertionCount - } - - if (i <= pivot) { - newShuffled[i] = currentIndex - } else if (i > pivot) { - newShuffled[i + insertionCount] = currentIndex - } - } - if (insertionIndex < shuffled.size) { - for (i in 0 until insertionCount) { - newShuffled[pivot + i + 1] = insertionIndex + i + 1 - } - } else { - for (i in 0 until insertionCount) { - newShuffled[pivot + i] = insertionIndex + i - } - } - return BetterShuffleOrder(newShuffled) - } - - override fun cloneAndRemove(indexFrom: Int, indexToExclusive: Int): ShuffleOrder { - val numberOfElementsToRemove = indexToExclusive - indexFrom - val newShuffled = IntArray(shuffled.size - numberOfElementsToRemove) - var foundElementsCount = 0 - for (i in shuffled.indices) { - if (shuffled[i] in indexFrom until indexToExclusive) { - foundElementsCount++ - } else { - newShuffled[i - foundElementsCount] = - if (shuffled[i] >= indexFrom) shuffled[i] - numberOfElementsToRemove - else shuffled[i] - } - } - return BetterShuffleOrder(newShuffled) - } - - override fun cloneAndClear(): ShuffleOrder { - return BetterShuffleOrder(0, -1) - } - - companion object { - private fun createShuffledList(length: Int, startIndex: Int): IntArray { - val shuffled = IntArray(length) - for (i in 0 until length) { - val swapIndex = (0..i).random() - shuffled[i] = shuffled[swapIndex] - shuffled[swapIndex] = i - } - if (startIndex != -1) { - val startIndexInShuffled = shuffled.indexOf(startIndex) - val temp = shuffled[0] - shuffled[0] = shuffled[startIndexInShuffled] - shuffled[startIndexInShuffled] = temp - } - return shuffled - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index f177ffaf4f..b0488c471c 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 @@ -88,6 +88,17 @@ class ExoPlaybackStateHolder( private var currentSaveJob: Job? = null private var openAudioEffectSession = false + // The queue is owned here rather than by ExoPlayer, since a MediaItem per queue entry does + // not scale to library-sized queues. The representation mirrors RawQueue. + private val heap = mutableListOf() + private val mapping = mutableListOf() + private var heapIndex = -1 + private var repeatModeState = RepeatMode.NONE + + // The player's playlist as resolved queue positions, a small contiguous run around the + // current song. Wraps circularly under RepeatMode.ALL. + private val window = ArrayDeque() + var sessionOngoing = false private set @@ -123,31 +134,12 @@ class ExoPlaybackStateHolder( } override val repeatMode - get() = - when (val repeatMode = player.repeatMode) { - Player.REPEAT_MODE_OFF -> RepeatMode.NONE - Player.REPEAT_MODE_ONE -> RepeatMode.TRACK - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") - } + get() = repeatModeState override val audioSessionId: Int get() = player.audioSessionId - override fun resolveQueue(): RawQueue { - val library = - musicRepository.library - // 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) - } + override fun resolveQueue() = RawQueue(heap.toList(), mapping.toList(), heapIndex) override fun handleDeferred(action: DeferredPlayback): Boolean { val library = @@ -234,30 +226,36 @@ class ExoPlaybackStateHolder( } 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 - } + repeatModeState = repeatMode + syncPlayerRepeatMode() updatePauseOnRepeat() + // The repeat mode decides whether the window wraps, so it may need to be reshaped. + slideWindow() 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 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) + heap.clear() + heap.addAll(command.queue) + mapping.clear() + val startHeapIndex = + command.song?.let { song -> + command.queue.indexOf(song).also { check(it != -1) { "Start song not in queue" } } + } + if (command.shuffled && heap.isNotEmpty()) { + mapping.addAll(shuffledMapping(anchor = startHeapIndex)) + } + heapIndex = + when { + heap.isEmpty() -> -1 + startHeapIndex != null -> startHeapIndex + isShuffled -> mapping[0] + else -> 0 + } + syncPlayerRepeatMode() + hardResetWindow() player.prepare() player.play() playbackManager.ack(this, StateAck.NewPlayback) @@ -265,13 +263,15 @@ class ExoPlaybackStateHolder( } 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) - ) + if (heap.isEmpty()) { + return } + mapping.clear() + if (shuffled) { + mapping.addAll(shuffledMapping(anchor = heapIndex)) + } + syncPlayerRepeatMode() + refreshWindow() playbackManager.ack(this, StateAck.QueueReordered) deferSave() } @@ -280,16 +280,14 @@ 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()) { + if (repeatModeState == RepeatMode.ALL || player.hasNextMediaItem()) { player.seekToNext() + syncIndexFromPlayer() if (!playbackSettings.rememberPause) { player.play() } } else { - player.seekTo( - player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), - C.TIME_UNSET, - ) + gotoImpl(0) // TODO: Dislike the UX implications of this, I feel should I bite the bullet // and switch to dynamic skip enable/disable? if (!playbackSettings.rememberPause) { @@ -308,6 +306,7 @@ class ExoPlaybackStateHolder( } else { player.seekTo(0) } + syncIndexFromPlayer() if (!playbackSettings.rememberPause) { player.play() } @@ -316,13 +315,10 @@ class ExoPlaybackStateHolder( } override fun goto(index: Int) { - val indices = player.unscrambleQueueIndices() - if (indices.isEmpty()) { + if (heap.isEmpty()) { return } - - val trueIndex = indices[index] - player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic + gotoImpl(index) if (!playbackSettings.rememberPause) { player.play() } @@ -331,66 +327,90 @@ class ExoPlaybackStateHolder( } 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 (heap.isEmpty()) { + return + } + val insertAt = heapIndex + 1 + heap.addAll(insertAt, songs) + if (isShuffled) { + for (i in mapping.indices) { + if (mapping[i] >= insertAt) { + mapping[i] += songs.size + } } - - if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.buildMediaItem() }) - } else { - player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() }) + mapping.addAll(resolvedIndex() + 1, List(songs.size) { insertAt + it }) } + refreshWindow() playbackManager.ack(this, ack) deferSave() } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.buildMediaItem() }) + if (heap.isEmpty()) { + return + } + val base = heap.size + heap.addAll(songs) + if (isShuffled) { + mapping.addAll(List(songs.size) { base + it }) + } + refreshWindow() playbackManager.ack(this, ack) deferSave() } override fun move(from: Int, to: Int, ack: StateAck.Move) { - val indices = player.unscrambleQueueIndices() - if (indices.isEmpty()) { + if (heap.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) - } + if (isShuffled) { + mapping.add(to, mapping.removeAt(from)) + } else { + heap.add(to, heap.removeAt(from)) + heapIndex = + when { + heapIndex == from -> to + from < heapIndex && to >= heapIndex -> heapIndex - 1 + from > heapIndex && to <= heapIndex -> heapIndex + 1 + else -> heapIndex + } } + refreshWindow() playbackManager.ack(this, ack) deferSave() } override fun remove(at: Int, ack: StateAck.Remove) { - val indices = player.unscrambleQueueIndices() - if (indices.isEmpty()) { + if (heap.isEmpty()) { return } - - val trueIndex = indices[at] - val songWillChange = player.currentMediaItemIndex == trueIndex - player.removeMediaItem(trueIndex) + val removedHeapIndex = heapIndexAt(at) + val songWillChange = removedHeapIndex == heapIndex + heap.removeAt(removedHeapIndex) + if (isShuffled) { + mapping.removeAt(at) + for (i in mapping.indices) { + if (mapping[i] > removedHeapIndex) { + mapping[i] -= 1 + } + } + } + if (removedHeapIndex < heapIndex) { + heapIndex -= 1 + } + when { + heap.isEmpty() -> { + heapIndex = -1 + window.clear() + player.clearMediaItems() + } + songWillChange -> { + // Playback moves to the song now occupying the removed song's position. + heapIndex = heapIndexAt(at.coerceAtMost(heap.size - 1)) + hardResetWindow() + } + else -> refreshWindow() + } if (songWillChange && !playbackSettings.rememberPause) { player.play() } @@ -412,14 +432,14 @@ class ExoPlaybackStateHolder( sendNewPlaybackEvent = 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) + heap.clear() + heap.addAll(rawQueue.heap) + mapping.clear() + mapping.addAll(rawQueue.shuffledMapping) + heapIndex = rawQueue.heapIndex + repeatModeState = repeatMode + syncPlayerRepeatMode() + hardResetWindow() player.prepare() player.pause() sendNewPlaybackEvent = true @@ -457,11 +477,190 @@ class ExoPlaybackStateHolder( } override fun reset(ack: StateAck.NewPlayback) { + heap.clear() + mapping.clear() + heapIndex = -1 + window.clear() player.setMediaItems(listOf()) playbackManager.ack(this, ack) deferSave() } + // --- QUEUE WINDOW MANAGEMENT --- + + private val isShuffled + get() = mapping.isNotEmpty() + + private fun resolvedIndex(): Int = if (isShuffled) mapping.indexOf(heapIndex) else heapIndex + + private fun heapIndexAt(resolved: Int): Int = if (isShuffled) mapping[resolved] else resolved + + private fun songAt(resolved: Int): Song = heap[heapIndexAt(resolved)] + + /** Create a new random play order over the heap, with [anchor] first if given. */ + private fun shuffledMapping(anchor: Int?): List { + val indices = MutableList(heap.size) { it } + indices.shuffle() + if (anchor != null) { + val at = indices.indexOf(anchor) + indices[at] = indices[0] + indices[0] = anchor + } + return indices + } + + /** + * The resolved positions the player should currently hold: the whole queue when it's small, + * otherwise a fixed-size run centered on [center]. Under [RepeatMode.ALL] the run wraps around + * the queue edges so the player can always advance (and skip back) across them. + */ + private fun computeWindowPositions(center: Int): List { + val size = heap.size + if (size == 0 || center < 0) { + return emptyList() + } + if (size <= WINDOW_MAX_SIZE) { + return (0 until size).toList() + } + return if (repeatModeState == RepeatMode.ALL) { + (center - WINDOW_RADIUS..center + WINDOW_RADIUS).map { ((it % size) + size) % size } + } else { + val start = (center - WINDOW_RADIUS).coerceAtLeast(0) + val end = (center + WINDOW_RADIUS).coerceAtMost(size - 1) + (start..end).toList() + } + } + + /** + * When the whole queue fits in the window the player behaves exactly as it did before windowing + * and can use its native repeat handling. Otherwise repeat-all is emulated by wrapping the + * window, so the player itself must not repeat. + */ + private fun syncPlayerRepeatMode() { + player.repeatMode = + when (repeatModeState) { + RepeatMode.TRACK -> Player.REPEAT_MODE_ONE + RepeatMode.ALL -> + if (heap.size <= WINDOW_MAX_SIZE) { + Player.REPEAT_MODE_ALL + } else { + Player.REPEAT_MODE_OFF + } + RepeatMode.NONE -> Player.REPEAT_MODE_OFF + } + } + + /** Replace the playlist outright, (re)starting the current song. */ + private fun hardResetWindow() { + val desired = computeWindowPositions(resolvedIndex()) + window.clear() + window.addAll(desired) + if (desired.isEmpty()) { + player.clearMediaItems() + return + } + player.setMediaItems(desired.map { songAt(it).buildMediaItem() }) + player.seekTo(desired.indexOf(resolvedIndex()), C.TIME_UNSET) + } + + /** Rebuild the playlist around the currently-playing item without interrupting it. */ + private fun refreshWindow() { + val current = resolvedIndex() + val desired = computeWindowPositions(current) + if (desired.isEmpty()) { + window.clear() + player.clearMediaItems() + return + } + val playing = player.currentMediaItem?.song + if (playing == null || playing != heap.getOrNull(heapIndex)) { + hardResetWindow() + return + } + val cur = player.currentMediaItemIndex + if (cur + 1 < player.mediaItemCount) { + player.removeMediaItems(cur + 1, player.mediaItemCount) + } + if (cur > 0) { + player.removeMediaItems(0, cur) + } + val split = desired.indexOf(current) + if (split > 0) { + player.addMediaItems(0, desired.subList(0, split).map { songAt(it).buildMediaItem() }) + } + player.addMediaItems( + desired.subList(split + 1, desired.size).map { songAt(it).buildMediaItem() } + ) + window.clear() + window.addAll(desired) + } + + /** + * Slide the window towards the current position after an index move, keeping untouched items in + * place so the player's preload of the next song survives. Falls back to a rebuild if the + * window somehow diverged. + */ + private fun slideWindow() { + val current = resolvedIndex() + val desired = computeWindowPositions(current) + if (desired == window) { + return + } + if (desired.isEmpty()) { + window.clear() + player.clearMediaItems() + return + } + val desiredSet = desired.toHashSet() + // Never removes the current item, since the window is always computed around it. + while (window.isNotEmpty() && window.first() !in desiredSet) { + player.removeMediaItem(0) + window.removeFirst() + } + while (window.isNotEmpty() && window.last() !in desiredSet) { + player.removeMediaItem(window.size - 1) + window.removeLast() + } + if (window.isEmpty()) { + hardResetWindow() + return + } + val i0 = desired.indexOf(window.first()) + val i1 = desired.indexOf(window.last()) + if (i0 == -1 || i1 == -1) { + hardResetWindow() + return + } + if (i0 > 0) { + val pre = desired.subList(0, i0) + player.addMediaItems(0, pre.map { songAt(it).buildMediaItem() }) + pre.asReversed().forEach { window.addFirst(it) } + } + if (i1 < desired.size - 1) { + val post = desired.subList(i1 + 1, desired.size) + player.addMediaItems(post.map { songAt(it).buildMediaItem() }) + window.addAll(post) + } + } + + /** Adopt the player's current item after the player moved on its own. */ + private fun syncIndexFromPlayer() { + val resolved = window.getOrNull(player.currentMediaItemIndex) ?: return + heapIndex = heapIndexAt(resolved) + slideWindow() + } + + private fun gotoImpl(resolved: Int) { + heapIndex = heapIndexAt(resolved) + val at = window.indexOf(resolved) + if (at != -1) { + player.seekTo(at, C.TIME_UNSET) + slideWindow() + } else { + hardResetWindow() + } + } + // --- PLAYER OVERRIDES --- override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { @@ -489,7 +688,7 @@ 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 && repeatModeState == RepeatMode.NONE) { goto(0) player.pause() } @@ -499,6 +698,7 @@ class ExoPlaybackStateHolder( super.onMediaItemTransition(mediaItem, reason) if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { + syncIndexFromPlayer() playbackManager.ack(this, StateAck.IndexMoved) deferSave() } @@ -559,7 +759,7 @@ class ExoPlaybackStateHolder( private fun updatePauseOnRepeat() { player.pauseAtEndOfMediaItems = - player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat + repeatModeState == RepeatMode.TRACK && playbackSettings.pauseOnRepeat } private fun save(cb: () -> Unit) { @@ -596,51 +796,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( @@ -703,5 +858,14 @@ class ExoPlaybackStateHolder( private companion object { const val SAVE_BUFFER = 5000L + + /** + * How many songs to keep in the player on either side of the current one. Only needs to be + * big enough that a burst of rapid skips can't outrun the window before it slides. + */ + const val WINDOW_RADIUS = 25 + + /** Queues at or below this size skip windowing entirely and behave exactly as before. */ + const val WINDOW_MAX_SIZE = WINDOW_RADIUS * 2 + 1 } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index f0b9d1c625..55df0fad31 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -35,6 +35,8 @@ import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.allowHardware import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification @@ -99,6 +101,10 @@ private constructor( val notification: ForegroundServiceNotification get() = _notification + // Bounds of the queue window currently published, as absolute queue indices. + private var queueWindowStart = 0 + private var queueWindowEnd = 0 + fun attach() { playbackManager.addListener(this) imageSettings.registerListener(this) @@ -130,11 +136,19 @@ private constructor( override fun onIndexMoved(index: Int) { updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + // The published queue window has to follow playback as the index walks out of it. + val queue = playbackManager.queue + if ( + max(0, index - QUEUE_WINDOW_RADIUS) != queueWindowStart || + min(queue.size, index + QUEUE_WINDOW_RADIUS + 1) != queueWindowEnd + ) { + updateQueue(queue, index) + } invalidateSessionState() } override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { - updateQueue(queue) + updateQueue(queue, index) when (change.type) { // Nothing special to do with mapping changes. QueueChange.Type.MAPPING -> {} @@ -147,7 +161,7 @@ private constructor( } override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { - updateQueue(queue) + updateQueue(queue, index) invalidateSessionState() mediaSession.setShuffleMode( if (isShuffled) { @@ -166,7 +180,7 @@ private constructor( isShuffled: Boolean, ) { updateMediaMetadata(playbackManager.currentSong, parent) - updateQueue(queue) + updateQueue(queue, index) invalidateSessionState() } @@ -309,19 +323,25 @@ private constructor( * * @param queue The current queue to upload. */ - private fun updateQueue(queue: List) { + private fun updateQueue(queue: List, index: Int) { + // setQueue parcels the whole list to the system, which for a library-sized queue blocks + // the main thread for seconds and risks TransactionTooLargeException. Publish a window + // around the current song instead. + val start = max(0, index - QUEUE_WINDOW_RADIUS) + val end = min(queue.size, index + QUEUE_WINDOW_RADIUS + 1) val queueItems = - queue.mapIndexed { i, song -> + (start until end).map { i -> val description = - song.toMediaDescription( + queue[i].toMediaDescription( context, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }, ) - // Store the item index so we can then use the analogous index in the - // playback state. + // Ids stay absolute so skip-to-item still addresses the right song. MediaSessionCompat.QueueItem(description, i.toLong()) } - L.d("Uploading ${queueItems.size} songs to MediaSession queue") + queueWindowStart = start + queueWindowEnd = end + L.d("Uploading songs $start..$end of ${queue.size} to MediaSession queue") mediaSession.setQueue(queueItems) } @@ -382,6 +402,13 @@ private constructor( companion object { private val emptyMetadata = MediaMetadataCompat.Builder().build() + + /** + * How many items on either side of the current song to publish to the media session. Large + * enough that a head unit's queue view never runs dry, small enough that building and + * parceling it stays off the critical path. + */ + private const val QUEUE_WINDOW_RADIUS = 100 } } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 013f3b01a1..57649e81d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -23,7 +23,6 @@ import android.graphics.Bitmap import android.os.Build import coil3.request.ImageRequest import coil3.request.transformations -import coil3.size.Size import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider @@ -120,7 +119,10 @@ private constructor( } } - return builder.size(Size.ORIGINAL).transformations(transformations) + // Bounded, not ORIGINAL: this bitmap is marshalled into a RemoteViews and + // drawn at a couple hundred pixels, and update() runs on nearly every + // playback event. + return builder.size(COVER_MAX_PX, COVER_MAX_PX).transformations(transformations) } override fun onCompleted(bitmap: Bitmap?) { @@ -187,4 +189,8 @@ private constructor( val repeatMode: RepeatMode, val isShuffled: Boolean, ) + + private companion object { + const val COVER_MAX_PX = 512 + } } diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 8230a0d63b..ba35284cb8 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -379,9 +379,20 @@ @style/ThemeOverlay.Auxio.UncheckableIconButton +