couple of queue fixes

This commit is contained in:
Boof2015
2026-07-30 16:42:11 -04:00
parent 771d7a034c
commit e2934dbf5c
8 changed files with 336 additions and 49 deletions
@@ -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<String, Any?>? ->
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(),
@@ -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<OrderedQueueItem>, 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) {
@@ -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()
@@ -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<Long>,
)
/** 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<NativeQueueSnapshot> = mutableSnapshot.asStateFlow()
private var observationJob: Job? = null
private val refreshRequests = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@Volatile
private var hydrationGeneration = 0L
@Volatile
private var pendingMutation: PendingQueueMutation? = null
@Volatile
private var latestEntries: List<PlaybackQueueEntryEntity> = 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<String, ActiveTrackView?>(MAX_CACHED_ROWS, 0.75f, true) {
object : LinkedHashMap<String, ResolvedRow>(MAX_CACHED_ROWS, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, ActiveTrackView?>,
eldest: MutableMap.MutableEntry<String, ResolvedRow>,
): 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<PlaybackQueueEntryEntity>,
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<QueueRowModel>(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(
@@ -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 <T> applyMove(items: MutableList<T>, 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
}
}
@@ -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))
}
}
+6
View File
@@ -50,6 +50,12 @@ type AstraQueueEvents = {
declare class AstraQueueModuleType extends NativeModule<AstraQueueEvents> {
present(options: NativeQueuePresentationOptions): Promise<void>;
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,
@@ -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.