mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
couple of queue fixes
This commit is contained in:
+17
-2
@@ -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(),
|
||||
|
||||
+2
-3
@@ -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) {
|
||||
|
||||
+68
-12
@@ -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()
|
||||
|
||||
+100
-32
@@ -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(
|
||||
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+97
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user