diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt index c516805..4999b57 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt @@ -30,6 +30,7 @@ class AstraQueueModule : Module() { private var lastRevision = Long.MIN_VALUE private var coordinator: QueueCoordinator? = null private var jankStats: JankStats? = null + private var pendingPlaybackRequestId: String? = null private val coordinatorListener: (NativeQueueSnapshot) -> Unit = { snapshot -> if (snapshot.revision != lastRevision) { @@ -88,13 +89,25 @@ class AstraQueueModule : Module() { } } + // The sheet's accent is derived from cover art, so it changes on every + // track change. The palette passed to present() is only correct until then. + Function("updatePalette") { values: Map? -> + val palette = QueuePalette.from(values) + appContext.mainQueue.launch { + dialogContent?.palette = palette + } + } + Function("resolvePlaybackRequest") { requestId: String, success: Boolean, message: String?, -> - requestId.length appContext.mainQueue.launch { + // Drop resolutions for a request that has already been superseded, + // otherwise an older failure can surface over a newer request. + if (requestId != pendingPlaybackRequestId) return@launch + pendingPlaybackRequestId = null dialogContent?.showPlaybackResult(success, message) } } @@ -205,10 +218,12 @@ class AstraQueueModule : Module() { } private fun emitPlaybackRequest(entryId: Long, revision: Long) { + val requestId = UUID.randomUUID().toString() + pendingPlaybackRequestId = requestId sendEvent( "onPlaybackRequest", mapOf( - "requestId" to UUID.randomUUID().toString(), + "requestId" to requestId, "kind" to "playEntry", "entryId" to entryId.toDouble(), "queueRevision" to revision.toDouble(), diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt index 8151c8e..828ebd9 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt @@ -7,6 +7,7 @@ import android.os.Build import android.os.Trace import androidx.room.Room import androidx.room.RoomDatabase +import expo.modules.astralibraryscanner.queue.QueueReorder import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder @@ -1135,9 +1136,7 @@ class AstraLibraryRepository private constructor( var nextEntryId = session.nextEntryId fun move(items: MutableList, from: Int, to: Int) { - if (from !in items.indices || to !in items.indices || from == to) return - val item = items.removeAt(from) - items.add(to.coerceIn(0, items.size), item) + QueueReorder.applyMove(items, from, to) } when (operation) { diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt index e965c3a..e0bb24b 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt @@ -40,6 +40,9 @@ import kotlinx.coroutines.launch private val NON_INTER_CHARACTER = Regex("[^\\u0000-\\u024F\\u0370-\\u03FF\\u0400-\\u04FF\\u2000-\\u206F\\u20A0-\\u20CF\\u2100-\\u214F]") private const val MAX_ANIMATED_REORDER_ROWS = 48 +/** Per-frame drag auto-scroll speed, in dp, from a nudge past the edge to a committed hold. */ +private const val DRAG_SCROLL_MIN_DP = 2f +private const val DRAG_SCROLL_MAX_DP = 12f class QueueContentView( context: Context, @@ -85,9 +88,14 @@ class QueueContentView( private var coordinatorAttached = false private var editMode = false private var dragFromId: Long? = null - private var dragTargetId: Long? = null private var swipeEntryId: Long? = null private var swipeArmed = false + /** + * Revision at the moment of a drag drop. The render that commits *that* drag + * must not re-anchor, but an unrelated render arriving in the meantime still + * should — so this is keyed to the revision rather than being a bare flag. + */ + private var dragCommitBaseRevision: Long? = null var playbackRequestListener: PlaybackRequestListener? = null @@ -140,7 +148,6 @@ class QueueContentView( recycler.itemAnimator = createDragItemAnimator() adapter.rowAt(viewHolder.bindingAdapterPosition)?.let { row -> dragFromId = row.entryId - dragTargetId = row.entryId } haptics.lift(viewHolder.itemView) viewHolder.itemView.alpha = 0.96f @@ -149,6 +156,26 @@ class QueueContentView( } } + override fun interpolateOutOfBoundsScroll( + recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long, + ): Int { + // The framework default ramps over several seconds keyed off + // msSinceStartScroll, which makes a deliberate hold at the edge feel + // stuck and then suddenly fast. Drive it off overshoot distance only: + // a nudge past the edge creeps, a committed hold moves quickly, and the + // speed is the same every time you do it. + if (viewSize <= 0) return 0 + val overshoot = (abs(viewSizeOutOfBounds).toFloat() / viewSize).coerceIn(0f, 1f) + val eased = overshoot * overshoot + val speed = DRAG_SCROLL_MIN_DP + (DRAG_SCROLL_MAX_DP - DRAG_SCROLL_MIN_DP) * eased + val pixels = dp(speed.toInt()).coerceAtLeast(1) + return if (viewSizeOutOfBounds > 0) pixels else -pixels + } + override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, @@ -156,9 +183,10 @@ class QueueContentView( ): Boolean { val from = viewHolder.bindingAdapterPosition val to = target.bindingAdapterPosition - val targetId = adapter.rowAt(to)?.entryId ?: return false + // The destination is read off the adapter at drop, so this only has to + // keep the visual reorder honest. + if (adapter.rowAt(to) == null) return false if (!adapter.move(from, to)) return false - dragTargetId = targetId haptics.step(target.itemView) return true } @@ -174,19 +202,34 @@ class QueueContentView( swipeEntryId = null swipeArmed = false val from = dragFromId - val to = dragTargetId dragFromId = null - dragTargetId = null if (from != null) { // Animation is useful while neighboring rows make room for the // dragged holder. End it at drop so later Room reconciliation and // swipe recovery stay visually exact. recycler.itemAnimator = null } - if (from == null || to == null || from == to) return + if (from == null) return + // Read the destination off the adapter's final state rather than the + // last onMove target: a fast auto-scroll drag skips callbacks, so the + // accumulated target can be hundreds of rows short of where the row + // actually came to rest. + val landedAt = adapter.positionOf(from) + if (landedAt < 0) return + val targetPosition = QueueReorder.queuePosition( + latestSnapshot.activePosition, + landedAt, + ) haptics.drop(viewHolder.itemView) - launchMutation("Could not reorder the queue") { - coordinator.move(from, to) + // The user scrolled the viewport here themselves during the drag, so + // the render that commits this move must not re-anchor and yank the + // list back to where the drag started. + dragCommitBaseRevision = latestSnapshot.revision + launchMutation( + "Could not reorder the queue", + onFailure = { dragCommitBaseRevision = null }, + ) { + coordinator.moveToPosition(from, targetPosition) } } @@ -439,14 +482,21 @@ class QueueContentView( nowArtist.text = current?.artist.orEmpty() loadArtwork(nowArtwork, current?.artworkThumbPath) - val firstVisible = layoutManager.findFirstVisibleItemPosition() + val dragBase = dragCommitBaseRevision + val committingDrag = dragBase != null && snapshot.revision > dragBase + if (committingDrag) dragCommitBaseRevision = null + val firstVisible = if (committingDrag) -1 else layoutManager.findFirstVisibleItemPosition() val anchorId = adapter.rowAt(firstVisible)?.entryId val anchorOffset = if (firstVisible >= 0) { layoutManager.findViewByPosition(firstVisible)?.top ?: 0 } else { 0 } - selectedIds.retainAll(upcoming.mapTo(hashSetOf(), QueueRowModel::entryId)) + // Pruning only matters while something is selected, and building the id set + // to prune against is a full pass over the queue. + if (selectedIds.isNotEmpty()) { + selectedIds.retainAll(upcoming.mapTo(hashSetOf(), QueueRowModel::entryId)) + } adapter.submit(upcoming, selectedIds, editMode) { val anchorPosition = anchorId?.let { id -> upcoming.indexOfFirst { it.entryId == id }.takeIf { it >= 0 } @@ -490,12 +540,18 @@ class QueueContentView( removeButton.text = "Remove ($count)" } - private fun launchMutation(errorMessage: String, block: suspend () -> Boolean): Job = + private fun launchMutation( + errorMessage: String, + onFailure: () -> Unit = {}, + block: suspend () -> Boolean, + ): Job = scope.launch { val success = runCatching { kotlinx.coroutines.withContext(Dispatchers.IO) { block() } }.getOrDefault(false) if (!success) { + // Back on the main dispatcher here, so view state is safe to touch. + onFailure() haptics.reject(this@QueueContentView) Snackbar.make(this@QueueContentView, errorMessage, Snackbar.LENGTH_SHORT).show() coordinator.refresh() diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt index 67a151f..406a926 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt @@ -16,11 +16,17 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -28,6 +34,8 @@ import kotlinx.coroutines.sync.withLock private const val FIRST_METADATA_ROWS = 64 private const val METADATA_CHUNK = 250 private const val MAX_CACHED_ROWS = 10_000 +/** One frame of settle, so Room's two invalidations per write become one hydration. */ +private const val SETTLE_MS = 16L data class QueueRowModel( val entryId: Long, @@ -71,6 +79,14 @@ private data class PendingQueueMutation( val previousEntryIds: List, ) +/** A track's display fields, derived once and reused across republishes. */ +private data class ResolvedRow( + val title: String, + val artist: String, + val artworkThumbPath: String?, + val durationSeconds: Double, +) + class QueueCoordinator private constructor( context: Context, ) { @@ -84,15 +100,30 @@ class QueueCoordinator private constructor( val snapshot: StateFlow = mutableSnapshot.asStateFlow() private var observationJob: Job? = null + private val refreshRequests = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + @Volatile private var hydrationGeneration = 0L @Volatile private var pendingMutation: PendingQueueMutation? = null @Volatile private var latestEntries: List = emptyList() + /** + * Display fields resolved once per track path. + * + * Deriving these (blank-fallback chains, filename decoding, artwork thumb + * path construction) used to run for every row on every publish. Caching the + * resolved form makes a republish a pointer copy instead of thousands of + * string builds. Access-ordered, so reads mutate it — see [start] for why all + * access stays on one coroutine. + */ private val metadataByPath = - object : LinkedHashMap(MAX_CACHED_ROWS, 0.75f, true) { + object : LinkedHashMap(MAX_CACHED_ROWS, 0.75f, true) { override fun removeEldestEntry( - eldest: MutableMap.MutableEntry, + eldest: MutableMap.MutableEntry, ): Boolean = size > MAX_CACHED_ROWS } @@ -109,32 +140,51 @@ class QueueCoordinator private constructor( if (observationJob != null) return observationJob = scope.launch { val dao = repository.userDb().userDao() - combine( + val observed = combine( dao.observePlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID), dao.observeQueueEntries(ACTIVE_PLAYBACK_CONTEXT_ID), ) { session, entries -> session to entries } + val requested = refreshRequests.map { + dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID) to + dao.getAllQueueEntries(ACTIVE_PLAYBACK_CONTEXT_ID) + } + // One collector for every source. Hydration touches `metadataByPath` (an + // access-ordered LinkedHashMap, so even reads mutate it) and + // `hydrationGeneration`, and neither is thread-safe — confining all of it + // to this single coroutine is what makes that safe, so refresh() must + // never launch its own. + merge(observed, requested) + .conflate() .collectLatest { (session, entries) -> + // Room invalidates playback_sessions and playback_queue_entries + // separately even though the write is one transaction, so a single + // mutation arrives as two emissions. One frame of settle collapses + // them into one hydration instead of starting one and cancelling it. + delay(SETTLE_MS) publishAndHydrate(session, entries) } } } fun refresh() { - scope.launch { - val dao = repository.userDb().userDao() - publishAndHydrate( - dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID), - dao.getAllQueueEntries(ACTIVE_PLAYBACK_CONTEXT_ID), - ) - } + refreshRequests.tryEmit(Unit) } - suspend fun move(entryId: Long, targetEntryId: Long): Boolean = + /** + * Move [entryId] so it ends up at [targetPosition] in the queue. + * + * The destination is a position rather than a neighbouring entry id on + * purpose: fast drag auto-scroll drops intermediate `onMove` callbacks, so an + * accumulated "last row I swapped with" target does not survive a long drag. + * The dragged row's final adapter index always does. + */ + suspend fun moveToPosition(entryId: Long, targetPosition: Long): Boolean = mutate { current -> val entries = latestEntries val from = entries.indexOfFirst { it.entryId == entryId } - val to = entries.indexOfFirst { it.entryId == targetEntryId } - if (from < 0 || to < 0 || from == to || from == current.activePosition.toInt()) { + val to = targetPosition.toInt() + val active = current.activePosition.toInt() + if (from < 0 || to !in entries.indices || from == to || from == active || to == active) { false } else { repository.mutatePlaybackContext( @@ -259,7 +309,13 @@ class QueueCoordinator private constructor( loadedEnd = loadedEnd, ) - var start = FIRST_METADATA_ROWS + // Resume from what the first publish already covered, not from + // FIRST_METADATA_ROWS. Every mutation on an open queue has its metadata + // cached already, so loadedEnd is the full list and this loop must not + // run at all — restarting at 64 republished the entire queue once per + // 250-row chunk with byte-identical content, and each republish costs a + // full row rebuild plus a DiffUtil pass. + var start = loadedEnd while (start < displayEntries.size && generation == hydrationGeneration) { val end = minOf(displayEntries.size, start + METADATA_CHUNK) resolveMetadata(displayEntries.subList(start, end), generation) @@ -270,7 +326,7 @@ class QueueCoordinator private constructor( entries = displayEntries, loadedEnd = end, ) - start += METADATA_CHUNK + start = end } if (generation == hydrationGeneration) { publish(mutableSnapshot.value.copy(loading = false)) @@ -303,32 +359,44 @@ class QueueCoordinator private constructor( .associateBy(ActiveTrackView::path) if (generation != hydrationGeneration) return unresolvedPaths.forEach { path -> - metadataByPath[path] = metadata[path] + metadataByPath[path] = resolveRow(path, metadata[path]) } } + /** Derive a row's display fields. Called once per path, not once per publish. */ + private fun resolveRow(path: String, track: ActiveTrackView?): ResolvedRow = ResolvedRow( + title = track?.title + ?.ifBlank { track.fileName } + ?.ifBlank { readableFileName(path) } + ?: readableFileName(path), + artist = track?.artist?.ifBlank { "Unknown artist" } ?: "Unknown artist", + artworkThumbPath = track?.artworkHash?.let { hash -> + File(applicationContext.filesDir, "artwork-thumbs/${thumbFileName(hash)}").path + }, + durationSeconds = track?.duration ?: 0.0, + ) + private fun publishHydrated( session: PlaybackSessionEntity, totalCount: Int, entries: List, loadedEnd: Int, ) { - val rows = entries.take(loadedEnd).map { entry -> - val track = metadataByPath[entry.trackPath] - QueueRowModel( - entryId = entry.entryId, - position = entry.position, - trackPath = entry.trackPath, - title = track?.title - ?.ifBlank { track.fileName } - ?.ifBlank { readableFileName(entry.trackPath) } - ?: readableFileName(entry.trackPath), - artist = track?.artist?.ifBlank { "Unknown artist" } ?: "Unknown artist", - artworkThumbPath = track?.artworkHash?.let { hash -> - File(applicationContext.filesDir, "artwork-thumbs/${thumbFileName(hash)}").path - }, - durationSeconds = track?.duration ?: 0.0, - hydrated = true, + val rows = ArrayList(loadedEnd) + for (index in 0 until loadedEnd) { + val entry = entries[index] + val resolved = metadataByPath[entry.trackPath] ?: resolveRow(entry.trackPath, null) + rows.add( + QueueRowModel( + entryId = entry.entryId, + position = entry.position, + trackPath = entry.trackPath, + title = resolved.title, + artist = resolved.artist, + artworkThumbPath = resolved.artworkThumbPath, + durationSeconds = resolved.durationSeconds, + hydrated = true, + ), ) } publish( diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueReorder.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueReorder.kt new file mode 100644 index 0000000..bba88e2 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueReorder.kt @@ -0,0 +1,38 @@ +package expo.modules.astralibraryscanner.queue + +/** + * Pure index math shared by the queue view and the playback repository. + * + * The native queue only renders rows after the active track, so adapter indices + * and Room queue positions differ by the active position. Keeping that + * translation and the commit-side move semantics in one tested place is what + * stops the view and the database from drifting — the same divergence that bit + * the vendored kotlin-audio metadata list. + */ +object QueueReorder { + /** Room queue position of the row rendered at [adapterIndex]. */ + fun queuePosition(activePosition: Long, adapterIndex: Int): Long = + activePosition + 1L + adapterIndex + + /** Adapter index of the row at [queuePosition], or -1 when it is not upcoming. */ + fun adapterIndex(activePosition: Long, queuePosition: Long): Int { + val index = queuePosition - activePosition - 1L + return if (index < 0L || index > Int.MAX_VALUE) -1 else index.toInt() + } + + /** + * Move [from] to [to] as remove-then-insert. + * + * Both directions land the item at exactly [to]. That is the contract + * `ItemTouchHelper.onMove` reports, and it is what lets the view derive a + * destination from the dragged row's *final* adapter index instead of + * accumulating per-step targets — fast auto-scroll drops intermediate + * `onMove` callbacks, so the accumulated target is not reliable. + */ + fun applyMove(items: MutableList, from: Int, to: Int): Boolean { + if (from !in items.indices || to !in items.indices || from == to) return false + val item = items.removeAt(from) + items.add(to.coerceIn(0, items.size), item) + return true + } +} diff --git a/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/queue/QueueReorderTest.kt b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/queue/QueueReorderTest.kt new file mode 100644 index 0000000..4b1bf6c --- /dev/null +++ b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/queue/QueueReorderTest.kt @@ -0,0 +1,97 @@ +package expo.modules.astralibraryscanner.queue + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QueueReorderTest { + @Test + fun `moving down lands the item at the target index`() { + val items = mutableListOf("A", "B", "C", "D", "E") + + assertTrue(QueueReorder.applyMove(items, from = 0, to = 2)) + + assertEquals(listOf("B", "C", "A", "D", "E"), items) + assertEquals(2, items.indexOf("A")) + } + + @Test + fun `moving up lands the item at the target index`() { + val items = mutableListOf("A", "B", "C", "D", "E") + + assertTrue(QueueReorder.applyMove(items, from = 3, to = 1)) + + assertEquals(listOf("A", "D", "B", "C", "E"), items) + assertEquals(1, items.indexOf("D")) + } + + @Test + fun `dragging the last row to the front puts it at position zero`() { + val items = mutableListOf("A", "B", "C", "D", "E") + + assertTrue(QueueReorder.applyMove(items, from = 4, to = 0)) + + assertEquals(listOf("E", "A", "B", "C", "D"), items) + } + + @Test + fun `dragging the first row to the end puts it last`() { + val items = mutableListOf("A", "B", "C", "D", "E") + + assertTrue(QueueReorder.applyMove(items, from = 0, to = 4)) + + assertEquals(listOf("B", "C", "D", "E", "A"), items) + } + + @Test + fun `every destination index is reachable in both directions`() { + // The whole point of the contract: `to` is the final index, no off-by-one + // depending on travel direction. + for (from in 0..4) { + for (to in 0..4) { + val items = mutableListOf("A", "B", "C", "D", "E") + val moved = items[from] + val changed = QueueReorder.applyMove(items, from, to) + + assertEquals(from != to, changed) + assertEquals("from=$from to=$to", to, items.indexOf(moved)) + assertEquals("from=$from to=$to", 5, items.size) + assertEquals("from=$from to=$to", setOf("A", "B", "C", "D", "E"), items.toSet()) + } + } + } + + @Test + fun `out of range and no-op moves leave the list untouched`() { + val items = mutableListOf("A", "B", "C") + + assertFalse(QueueReorder.applyMove(items, from = -1, to = 1)) + assertFalse(QueueReorder.applyMove(items, from = 1, to = 9)) + assertFalse(QueueReorder.applyMove(items, from = 1, to = 1)) + + assertEquals(listOf("A", "B", "C"), items) + } + + @Test + fun `adapter indices translate to queue positions after the active row`() { + assertEquals(6L, QueueReorder.queuePosition(activePosition = 5L, adapterIndex = 0)) + assertEquals(9L, QueueReorder.queuePosition(activePosition = 5L, adapterIndex = 3)) + // Nothing is playing yet: the first upcoming row is position zero. + assertEquals(0L, QueueReorder.queuePosition(activePosition = -1L, adapterIndex = 0)) + } + + @Test + fun `queue positions translate back to adapter indices`() { + for (adapterIndex in 0..64) { + val position = QueueReorder.queuePosition(activePosition = 5L, adapterIndex = adapterIndex) + assertEquals(adapterIndex, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = position)) + } + } + + @Test + fun `the active row and everything before it are not upcoming`() { + assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 5L)) + assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 0L)) + } +} diff --git a/modules/astra-library-scanner/queue.ts b/modules/astra-library-scanner/queue.ts index 135703e..063b44d 100644 --- a/modules/astra-library-scanner/queue.ts +++ b/modules/astra-library-scanner/queue.ts @@ -50,6 +50,12 @@ type AstraQueueEvents = { declare class AstraQueueModuleType extends NativeModule { present(options: NativeQueuePresentationOptions): Promise; dismiss(): void; + /** + * Re-theme an already-presented sheet. The palette given to `present` is only + * correct until the next track change, because the accent is derived from + * cover art. + */ + updatePalette(palette: NativeQueuePalette): void; resolvePlaybackRequest( requestId: string, success: boolean, diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 7491808..8a4a4b4 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -799,6 +799,14 @@ export function NowPlayingOverlay() { }; }, [nativeQueueEnabled]); + // `colors` is accent-scoped to the cover art, so it changes on every track + // change. present() captured the palette once, which left an open sheet + // wearing the previous track's accent. + useEffect(() => { + if (!nativeQueueEnabled || isDesktopTarget || !queueOpen) return; + AstraQueue.updatePalette(toNativeQueuePalette(colors)); + }, [colors, isDesktopTarget, nativeQueueEnabled, queueOpen]); + // Hardware back, innermost layer first: menu → queue tray → player. Registered // only while open, so it sits above the focused screen's own handlers (LIFO) // — e.g. the library-detail back interceptor underneath.