rewrite entire queue system to native

This commit is contained in:
Boof2015
2026-07-29 19:25:54 -04:00
parent d4c88042e0
commit 771d7a034c
29 changed files with 4376 additions and 220 deletions
@@ -14,6 +14,16 @@ apply plugin: 'com.google.devtools.ksp'
group = 'expo.modules.astralibraryscanner'
version = '0.1.0'
def queueFontAssetsDir = layout.buildDirectory.dir("generated/astraQueueFonts")
def prepareQueueFontAssets = tasks.register("prepareQueueFontAssets", Copy) {
from(
"$rootDir/../node_modules/@expo-google-fonts/inter/400Regular/Inter_400Regular.ttf",
"$rootDir/../node_modules/@expo-google-fonts/inter/500Medium/Inter_500Medium.ttf",
"$rootDir/../node_modules/@expo-google-fonts/inter/600SemiBold/Inter_600SemiBold.ttf",
)
into(queueFontAssetsDir)
}
android {
namespace "expo.modules.astralibraryscanner"
defaultConfig {
@@ -23,12 +33,17 @@ android {
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas")
main.assets.srcDir(queueFontAssetsDir)
}
lintOptions {
abortOnError false
}
}
tasks.named("preBuild").configure {
dependsOn(prepareQueueFontAssets)
}
dependencies {
// ExoPlayer 2.19.0 (same version the vendored kotlin-audio fork pulls in) for
// MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without
@@ -36,6 +51,10 @@ dependencies {
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0'
implementation 'androidx.room:room-runtime:2.8.4'
implementation 'androidx.room:room-ktx:2.8.4'
implementation 'androidx.recyclerview:recyclerview:1.4.0'
implementation 'androidx.metrics:metrics-performance:1.0.0'
implementation 'com.google.android.material:material:1.13.0'
implementation 'com.github.bumptech.glide:glide:5.0.5'
ksp 'androidx.room:room-compiler:2.8.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
@@ -208,6 +208,69 @@ class RoomLibraryRepositoryTest {
assertEquals(2L, dao.countQueueEntries(session.id))
}
@Test
fun stableQueueMutationPreservesIdsAcrossMoveRemoveInsertAndDuplicates() = runBlocking {
val dao = user.userDao()
val session = PlaybackSessionEntity(
id = "active-context",
contextJson = """{"kind":"manual"}""",
anchorPath = "/a.flac",
shuffleSeed = 42,
activePosition = 0,
createdAt = 1,
updatedAt = 1,
queueRevision = 1,
nextEntryId = 14,
)
dao.replacePlaybackQueue(
session,
listOf(
PlaybackQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackQueueEntryEntity(session.id, 1, "/b.flac", 11),
PlaybackQueueEntryEntity(session.id, 2, "/a.flac", 12),
PlaybackQueueEntryEntity(session.id, 3, "/c.flac", 13),
),
listOf(
PlaybackOriginalQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackOriginalQueueEntryEntity(session.id, 1, "/b.flac", 11),
PlaybackOriginalQueueEntryEntity(session.id, 2, "/a.flac", 12),
PlaybackOriginalQueueEntryEntity(session.id, 3, "/c.flac", 13),
),
)
dao.applyPlaybackQueueMutation(
session.copy(queueRevision = 2, nextEntryId = 15),
listOf(
PlaybackQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackQueueEntryEntity(session.id, 1, "/c.flac", 13),
PlaybackQueueEntryEntity(session.id, 2, "/a.flac", 12),
PlaybackQueueEntryEntity(session.id, 3, "/d.flac", 14),
),
listOf(
PlaybackOriginalQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackOriginalQueueEntryEntity(session.id, 1, "/a.flac", 12),
PlaybackOriginalQueueEntryEntity(session.id, 2, "/c.flac", 13),
PlaybackOriginalQueueEntryEntity(session.id, 3, "/d.flac", 14),
),
)
assertEquals(
listOf(10L, 13L, 12L, 14L),
dao.getAllQueueEntries(session.id).map(PlaybackQueueEntryEntity::entryId),
)
assertEquals(
listOf("/a.flac", "/c.flac", "/a.flac", "/d.flac"),
dao.getAllQueueEntries(session.id).map(PlaybackQueueEntryEntity::trackPath),
)
assertEquals(
listOf(10L, 12L, 13L, 14L),
dao.getOriginalQueueEntries(session.id)
.map(PlaybackOriginalQueueEntryEntity::entryId),
)
assertEquals(2L, dao.getPlaybackSession(session.id)?.queueRevision)
assertEquals(15L, dao.getPlaybackSession(session.id)?.nextEntryId)
}
@Test
fun playbackWindowNeverClampsPastTheEndBackToTheLastTrack() {
assertEquals(0L, boundedPlaybackWindowStart(-10, 3))
@@ -104,6 +104,83 @@ class UserMigrationTest {
assertTrue("listening_segments" in tables)
}
@Test
fun queueV3MigrationAssignsStableIdsByDuplicateOccurrence() {
helper.createDatabase(TEST_DATABASE, 2).apply {
execSQL(
"""
INSERT INTO playback_sessions
(id, context_json, anchor_path, shuffle_seed, active_position, created_at, updated_at)
VALUES ('active-context', '{}', '/a.flac', 42, 0, 10, 20)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playback_queue_entries (session_id, position, track_path) VALUES
('active-context', 0, '/a.flac'),
('active-context', 1, '/b.flac'),
('active-context', 2, '/a.flac')
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playback_original_queue_entries (session_id, position, track_path) VALUES
('active-context', 0, '/a.flac'),
('active-context', 1, '/a.flac'),
('active-context', 2, '/b.flac')
""".trimIndent(),
)
close()
}
val database = helper.runMigrationsAndValidate(
TEST_DATABASE,
3,
true,
USER_MIGRATION_2_3,
)
val current = database.query(
"""
SELECT entry_id, track_path
FROM playback_queue_entries
WHERE session_id = 'active-context'
ORDER BY position
""".trimIndent(),
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getString(1))
}
}
val original = database.query(
"""
SELECT entry_id, track_path
FROM playback_original_queue_entries
WHERE session_id = 'active-context'
ORDER BY position
""".trimIndent(),
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getString(1))
}
}
assertEquals(listOf(0L to "/a.flac", 1L to "/b.flac", 2L to "/a.flac"), current)
assertEquals(listOf(0L to "/a.flac", 2L to "/a.flac", 1L to "/b.flac"), original)
assertEquals(
3,
database.singleInt(
"SELECT next_entry_id FROM playback_sessions WHERE id = 'active-context'",
),
)
assertEquals(
1,
database.singleInt(
"SELECT queue_revision FROM playback_sessions WHERE id = 'active-context'",
),
)
}
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
this.query(query).use { cursor ->
assertTrue(cursor.moveToFirst())
@@ -0,0 +1,218 @@
package expo.modules.astralibraryscanner
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.WindowManager
import android.content.pm.ApplicationInfo
import android.os.Trace
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.metrics.performance.JankStats
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import expo.modules.astralibraryscanner.queue.AstraQueueView
import expo.modules.astralibraryscanner.queue.NativeQueueSnapshot
import expo.modules.astralibraryscanner.queue.QueueContentView
import expo.modules.astralibraryscanner.queue.QueueCoordinator
import expo.modules.astralibraryscanner.queue.QueuePalette
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AstraQueueModule : Module() {
private var dialog: BottomSheetDialog? = null
private var dialogContent: QueueContentView? = null
private var lastRevision = Long.MIN_VALUE
private var coordinator: QueueCoordinator? = null
private var jankStats: JankStats? = null
private val coordinatorListener: (NativeQueueSnapshot) -> Unit = { snapshot ->
if (snapshot.revision != lastRevision) {
lastRevision = snapshot.revision
sendEvent(
"onQueueRevision",
mapOf(
"sessionId" to snapshot.sessionId,
"queueRevision" to snapshot.revision.toDouble(),
"activePosition" to snapshot.activePosition.toDouble(),
"totalCount" to snapshot.totalCount,
),
)
}
}
override fun definition() = ModuleDefinition {
Name("AstraQueue")
Events(
"onDismissed",
"onPlaybackRequest",
"onQueueRevision",
)
OnCreate {
val context = appContext.reactContext ?: return@OnCreate
QueueCoordinator.get(context).also {
coordinator = it
it.addListener(coordinatorListener)
it.start()
}
}
OnDestroy {
coordinator?.removeListener(coordinatorListener)
coordinator = null
appContext.mainQueue.launch {
jankStats?.isTrackingEnabled = false
jankStats = null
dialog?.dismiss()
dialog = null
dialogContent = null
}
}
AsyncFunction("present") Coroutine { options: Map<String, Any?> ->
withContext(Dispatchers.Main) {
presentDialog(options)
}
}
Function("dismiss") {
appContext.mainQueue.launch {
dialog?.dismiss()
}
}
Function("resolvePlaybackRequest") {
requestId: String,
success: Boolean,
message: String?,
->
requestId.length
appContext.mainQueue.launch {
dialogContent?.showPlaybackResult(success, message)
}
}
AsyncFunction("resolveEntryPosition") Coroutine {
entryId: Double,
expectedRevision: Double,
->
coordinator?.positionForEntry(entryId.toLong(), expectedRevision.toLong())?.toDouble()
}
View(AstraQueueView::class) {
Prop("active") { view, active: Boolean ->
view.active = active
}
Prop("palette") { view, values: Map<String, Any?>? ->
view.palette = QueuePalette.from(values)
}
OnViewDidUpdateProps { view ->
view.setPlaybackRequestListener { entryId, revision ->
emitPlaybackRequest(entryId, revision)
}
}
}
}
private fun presentDialog(options: Map<String, Any?>) {
Trace.beginSection("AstraQueue.present")
try {
val activity = appContext.currentActivity
?: error("AstraQueue requires a foreground Activity")
dialog?.dismiss()
@Suppress("UNCHECKED_CAST")
val paletteValues = options["palette"] as? Map<String, Any?>
val content = QueueContentView(activity).apply {
sheetMode = true
palette = QueuePalette.from(paletteValues)
playbackRequestListener = QueueContentView.PlaybackRequestListener { entryId, revision ->
emitPlaybackRequest(entryId, revision)
}
attach()
}
val next = BottomSheetDialog(activity).apply {
setContentView(content)
setCanceledOnTouchOutside(true)
setOnShowListener {
val displayHeight = activity.resources.displayMetrics.heightPixels
findViewById<FrameLayout>(
com.google.android.material.R.id.design_bottom_sheet,
)?.apply {
layoutParams = layoutParams.apply {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
background = ColorDrawable(Color.TRANSPARENT)
}
content.layoutParams = content.layoutParams.apply {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
behavior.peekHeight = (displayHeight * 0.58f).toInt()
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
behavior.isFitToContents = false
behavior.expandedOffset = 0
behavior.isHideable = true
behavior.skipCollapsed = false
window?.apply {
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
attributes = attributes.apply { dimAmount = 0.58f }
setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
)
}
val debuggable =
activity.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
if (debuggable) {
window?.let { queueWindow ->
jankStats?.isTrackingEnabled = false
jankStats = JankStats.createAndTrack(queueWindow) { frame ->
if (frame.isJank) {
Log.d(
"AstraQueueJank",
"frameMs=${frame.frameDurationUiNanos / 1_000_000.0}",
)
}
}
}
}
}
setOnDismissListener {
jankStats?.isTrackingEnabled = false
jankStats = null
content.detach()
if (dialog === this) {
dialog = null
dialogContent = null
sendEvent("onDismissed", emptyMap<String, Any?>())
}
}
}
dialog = next
dialogContent = content
next.show()
} finally {
Trace.endSection()
}
}
private fun emitPlaybackRequest(entryId: Long, revision: Long) {
sendEvent(
"onPlaybackRequest",
mapOf(
"requestId" to UUID.randomUUID().toString(),
"kind" to "playEntry",
"entryId" to entryId.toDouble(),
"queueRevision" to revision.toDouble(),
),
)
}
}
@@ -3,6 +3,8 @@ package expo.modules.astralibraryscanner.data
import android.content.Context
import android.database.sqlite.SQLiteDatabaseCorruptException
import android.database.sqlite.SQLiteException
import android.os.Build
import android.os.Trace
import androidx.room.Room
import androidx.room.RoomDatabase
import java.io.File
@@ -12,6 +14,7 @@ import java.text.Normalizer
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicInteger
import kotlin.random.Random
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -33,7 +36,26 @@ private const val CUTOVER_PREFS = "astra-room-cutover"
private const val CUTOVER_COMPLETE = "room-cutover-v1-complete"
private const val SNAPSHOT_DEBOUNCE_MS = 2_000L
private const val MOBILE_SESSION_ID = "mobile"
private const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context"
internal const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context"
internal const val PLAYBACK_HISTORY_WINDOW = 8
internal const val PLAYBACK_UPCOMING_WINDOW = 32
internal const val PLAYBACK_WINDOW_SIZE =
PLAYBACK_HISTORY_WINDOW + 1 + PLAYBACK_UPCOMING_WINDOW
private val traceCookie = AtomicInteger()
private suspend fun <T> traceAsyncSection(
name: String,
block: suspend () -> T,
): T {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return block()
val cookie = traceCookie.incrementAndGet()
Trace.beginAsyncSection(name, cookie)
return try {
block()
} finally {
Trace.endAsyncSection(name, cookie)
}
}
class StaleRevisionException : IllegalStateException("STALE_REVISION")
internal class ScanCancelledException : IllegalStateException("SCAN_CANCELLED")
@@ -59,6 +81,11 @@ private data class RemoteSyncHandle(
val seenPaths: MutableSet<String> = ConcurrentHashMap.newKeySet(),
)
private data class OrderedQueueItem(
val entryId: Long,
val trackPath: String,
)
/**
* Single owner for both Room files. Every app surface—including Android Auto—
* reaches SQLite through this repository so connection and recovery policy
@@ -915,11 +942,25 @@ class AstraLibraryRepository private constructor(
initialize()
val catalogDao = requireCatalog().catalogDao()
val userDao = requireUser().userDao()
val paths = resolvePlaybackPaths(context, catalogDao, userDao)
val availablePaths = filterAvailablePaths(paths, catalogDao)
val contextKind = context["kind"] as? String ?: "library"
val catalogRevision = catalogDao.getRevision()
val persistedContextJson = org.json.JSONObject(context).toString()
val paths = traceAsyncSection("AstraQueue.contextCreate") {
resolvePlaybackPaths(context, catalogDao, userDao)
}
val availablePaths = when (contextKind) {
"library", "album", "artist", "folder", "search", "dynamicPlaylist" -> paths
else -> filterAvailablePaths(paths, catalogDao)
}
val seed = requestedSeed ?: System.currentTimeMillis()
val ordered = availablePaths.toMutableList()
var activePosition = anchorPath?.let(ordered::indexOf)?.takeIf { it >= 0 } ?: 0
val original = availablePaths.mapIndexed { index, path ->
OrderedQueueItem(index.toLong(), path)
}
val ordered = original.toMutableList()
var activePosition = anchorPath
?.let { anchor -> ordered.indexOfFirst { it.trackPath == anchor } }
?.takeIf { it >= 0 }
?: 0
if (shuffle && ordered.size > 1) {
val anchor = ordered.getOrNull(activePosition)
if (anchor != null) ordered.removeAt(activePosition)
@@ -928,36 +969,80 @@ class AstraLibraryRepository private constructor(
activePosition = 0
}
val now = System.currentTimeMillis()
val reusableCatalogContext = contextKind in setOf(
"library",
"album",
"artist",
"folder",
"search",
"dynamicPlaylist",
)
val previous = if (!shuffle && reusableCatalogContext) {
userDao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID)
} else {
null
}
if (
previous?.contextJson == persistedContextJson &&
previous.catalogRevision == catalogRevision &&
!previous.isDirty &&
previous.shuffleSeed == null &&
userDao.countQueueEntries(ACTIVE_PLAYBACK_CONTEXT_ID) == ordered.size.toLong()
) {
userDao.updatePlaybackPosition(
ACTIVE_PLAYBACK_CONTEXT_ID,
activePosition.toLong(),
ordered.getOrNull(activePosition)?.trackPath,
now,
)
return playbackWindow(
ACTIVE_PLAYBACK_CONTEXT_ID,
(activePosition - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong(),
PLAYBACK_WINDOW_SIZE,
)
}
traceAsyncSection("AstraQueue.roomCommit") {
userDao.replacePlaybackQueue(
PlaybackSessionEntity(
id = ACTIVE_PLAYBACK_CONTEXT_ID,
contextJson = org.json.JSONObject(context).toString(),
anchorPath = ordered.getOrNull(activePosition),
contextJson = persistedContextJson,
anchorPath = ordered.getOrNull(activePosition)?.trackPath,
shuffleSeed = if (shuffle) seed else null,
activePosition = activePosition.toLong(),
createdAt = now,
updatedAt = now,
queueRevision = (previous?.queueRevision ?: 0) + 1,
catalogRevision = catalogRevision,
isDirty = false,
nextEntryId = original.size.toLong(),
),
ordered.mapIndexed { index, path ->
ordered.mapIndexed { index, item ->
PlaybackQueueEntryEntity(
sessionId = ACTIVE_PLAYBACK_CONTEXT_ID,
position = index.toLong(),
trackPath = path,
trackPath = item.trackPath,
entryId = item.entryId,
)
},
availablePaths.mapIndexed { index, path ->
if (shuffle) {
original.mapIndexed { index, item ->
PlaybackOriginalQueueEntryEntity(
sessionId = ACTIVE_PLAYBACK_CONTEXT_ID,
position = index.toLong(),
trackPath = path,
trackPath = item.trackPath,
entryId = item.entryId,
)
}
} else {
emptyList()
},
)
}
scheduleSnapshot()
return playbackWindow(
ACTIVE_PLAYBACK_CONTEXT_ID,
(activePosition - 25).coerceAtLeast(0).toLong(),
226,
(activePosition - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong(),
PLAYBACK_WINDOW_SIZE,
)
}
@@ -978,7 +1063,6 @@ class AstraLibraryRepository private constructor(
val bounded = activePosition.coerceIn(0, total - 1)
val anchor = dao.getQueueWindow(sessionId, bounded, 1).firstOrNull()?.trackPath
dao.updatePlaybackPosition(sessionId, bounded, anchor, System.currentTimeMillis())
scheduleSnapshot()
}
suspend fun restorePlaybackContext(): Map<String, Any?>? {
@@ -1006,20 +1090,23 @@ class AstraLibraryRepository private constructor(
val normalized = retained.mapIndexed { index, row ->
row.copy(position = index.toLong())
}
val retainedIds = normalized.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
val original = dao.getOriginalQueueEntries(session.id)
.filter { it.trackPath in available }
.filter { it.entryId in retainedIds && it.trackPath in available }
.mapIndexed { index, row -> row.copy(position = index.toLong()) }
database.userDao().replacePlaybackQueue(
session.copy(
anchorPath = normalized[activeIndex].trackPath,
activePosition = activeIndex.toLong(),
updatedAt = System.currentTimeMillis(),
queueRevision = session.queueRevision + 1,
isDirty = true,
),
normalized,
original,
)
val start = (activeIndex - 25).coerceAtLeast(0).toLong()
return playbackWindow(session.id, start, 226)
val start = (activeIndex - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong()
return playbackWindow(session.id, start, PLAYBACK_WINDOW_SIZE)
}
suspend fun mutatePlaybackContext(
@@ -1030,18 +1117,27 @@ class AstraLibraryRepository private constructor(
val database = requireUser()
val dao = database.userDao()
val session = dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID) ?: return null
val current = dao.getAllQueueEntries(session.id).map(PlaybackQueueEntryEntity::trackPath).toMutableList()
val current = dao.getAllQueueEntries(session.id)
.map { OrderedQueueItem(it.entryId, it.trackPath) }
.toMutableList()
if (current.isEmpty()) return null
val originalRows = dao.getOriginalQueueEntries(session.id)
val original = (if (originalRows.isEmpty()) current else originalRows.map(PlaybackOriginalQueueEntryEntity::trackPath))
val original = (
if (originalRows.isEmpty()) {
current
} else {
originalRows.map { OrderedQueueItem(it.entryId, it.trackPath) }
}
)
.toMutableList()
var active = session.activePosition.toInt().coerceIn(current.indices)
val activePath = current[active]
val activeEntryId = current[active].entryId
var nextEntryId = session.nextEntryId
fun move(paths: MutableList<String>, from: Int, to: Int) {
if (from !in paths.indices || to !in paths.indices || from == to) return
val item = paths.removeAt(from)
paths.add(to.coerceIn(0, paths.size), item)
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)
}
when (operation) {
@@ -1055,16 +1151,19 @@ class AstraLibraryRepository private constructor(
}
val paths = filterAvailablePaths(requested, requireCatalog().catalogDao())
if (paths.isNotEmpty()) {
val additions = paths.map { path ->
OrderedQueueItem(nextEntryId++, path)
}
val append = operation == "append" || operation == "appendQuery"
val insertAt = if (append) current.size else active + 1
current.addAll(insertAt, paths)
val originalAnchor = original.indexOf(activePath)
current.addAll(insertAt, additions)
val originalAnchor = original.indexOfFirst { it.entryId == activeEntryId }
val originalInsert = if (append || originalAnchor < 0) {
original.size
} else {
originalAnchor + 1
}
original.addAll(originalInsert, paths)
original.addAll(originalInsert, additions)
}
}
"remove" -> {
@@ -1077,7 +1176,9 @@ class AstraLibraryRepository private constructor(
for (position in positions) {
if (position !in current.indices || position == active) continue
val removed = current.removeAt(position)
original.indexOf(removed).takeIf { it >= 0 }?.let(original::removeAt)
original.indexOfFirst { it.entryId == removed.entryId }
.takeIf { it >= 0 }
?.let(original::removeAt)
if (position < active) active -= 1
}
}
@@ -1085,13 +1186,13 @@ class AstraLibraryRepository private constructor(
val from = (values["from"] as? Number)?.toInt() ?: -1
val to = (values["to"] as? Number)?.toInt() ?: -1
if (from in current.indices && to in current.indices && from != active && to != active) {
val movedPath = current[from]
val targetPath = current[to]
val movedEntryId = current[from].entryId
val targetEntryId = current[to].entryId
move(current, from, to)
val originalFrom = original.indexOf(movedPath)
val originalTo = original.indexOf(targetPath)
val originalFrom = original.indexOfFirst { it.entryId == movedEntryId }
val originalTo = original.indexOfFirst { it.entryId == targetEntryId }
if (originalFrom >= 0 && originalTo >= 0) move(original, originalFrom, originalTo)
active = current.indexOf(activePath).coerceAtLeast(0)
active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
}
}
"moveManyAfterActive" -> {
@@ -1105,18 +1206,14 @@ class AstraLibraryRepository private constructor(
if (positions.isNotEmpty()) {
val selected = positions.map(current::get)
positions.asReversed().forEach { current.removeAt(it) }
active = current.indexOf(activePath).coerceAtLeast(0)
active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
current.addAll(active + 1, selected)
val selectedCounts = selected.groupingBy { it }.eachCount().toMutableMap()
val remainingOriginal = original.filter { path ->
val count = selectedCounts[path] ?: 0
if (count <= 0) true else {
if (count == 1) selectedCounts.remove(path) else selectedCounts[path] = count - 1
false
}
}.toMutableList()
val originalActive = remainingOriginal.indexOf(activePath)
val selectedIds = selected.mapTo(hashSetOf(), OrderedQueueItem::entryId)
val remainingOriginal = original
.filterNot { it.entryId in selectedIds }
.toMutableList()
val originalActive = remainingOriginal.indexOfFirst { it.entryId == activeEntryId }
remainingOriginal.addAll(
if (originalActive >= 0) originalActive + 1 else 0,
selected,
@@ -1137,7 +1234,7 @@ class AstraLibraryRepository private constructor(
} else {
current.clear()
current.addAll(original)
active = current.indexOf(activePath).coerceAtLeast(0)
active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
}
}
else -> error("Unknown playback context mutation.")
@@ -1150,22 +1247,45 @@ class AstraLibraryRepository private constructor(
shuffleEnabled -> (values["seed"] as? Number)?.toLong() ?: now
else -> null
}
dao.replacePlaybackQueue(
traceAsyncSection("AstraQueue.roomMutation") {
dao.applyPlaybackQueueMutation(
session.copy(
anchorPath = current.getOrNull(active),
anchorPath = current.getOrNull(active)?.trackPath,
activePosition = active.toLong(),
shuffleSeed = nextSeed,
updatedAt = now,
queueRevision = session.queueRevision + 1,
isDirty = true,
nextEntryId = nextEntryId,
),
current.mapIndexed { index, path ->
PlaybackQueueEntryEntity(session.id, index.toLong(), path)
current.mapIndexed { index, item ->
PlaybackQueueEntryEntity(
sessionId = session.id,
position = index.toLong(),
trackPath = item.trackPath,
entryId = item.entryId,
)
},
original.mapIndexed { index, path ->
PlaybackOriginalQueueEntryEntity(session.id, index.toLong(), path)
if (nextSeed != null) {
original.mapIndexed { index, item ->
PlaybackOriginalQueueEntryEntity(
sessionId = session.id,
position = index.toLong(),
trackPath = item.trackPath,
entryId = item.entryId,
)
}
} else {
emptyList()
},
)
}
scheduleSnapshot()
return playbackWindow(session.id, (active - 25).coerceAtLeast(0).toLong(), 226)
return playbackWindow(
session.id,
(active - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong(),
PLAYBACK_WINDOW_SIZE,
)
}
suspend fun recordTrackPlayed(path: String): Boolean {
@@ -2450,6 +2570,7 @@ class AstraLibraryRepository private constructor(
val items = entries.mapNotNull { entry ->
tracks[entry.trackPath]?.toBridgeMap()?.toMutableMap()?.apply {
this["queuePosition"] = entry.position.toDouble()
this["queueEntryId"] = entry.entryId.toDouble()
}
}
return mapOf(
@@ -2460,6 +2581,7 @@ class AstraLibraryRepository private constructor(
"totalCount" to total.toDouble(),
"contextJson" to session.contextJson,
"shuffleSeed" to session.shuffleSeed?.toDouble(),
"queueRevision" to session.queueRevision.toDouble(),
"catalogRevision" to requireCatalog().catalogDao().getRevision().toString(),
)
}
@@ -2482,6 +2604,12 @@ class AstraLibraryRepository private constructor(
return requireUser()
}
internal suspend fun nativeQueueTracks(paths: List<String>): List<ActiveTrackView> {
initialize()
if (paths.isEmpty()) return emptyList()
return requireCatalog().catalogDao().getActiveTracks(paths.distinct())
}
suspend fun catalogDb(): AstraCatalogDatabase {
initialize()
return requireCatalog()
@@ -2792,7 +2920,7 @@ class AstraLibraryRepository private constructor(
private fun buildUserDatabase(): AstraUserDatabase =
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(USER_MIGRATION_1_2)
.addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3)
.build()
private fun buildCatalogDatabase(): AstraCatalogDatabase =
@@ -11,6 +11,7 @@ import androidx.room.Transaction
import androidx.room.Upsert
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
data class RemotePlaylistSyncPlan(
val playlist: PlaylistEntity,
@@ -326,6 +327,9 @@ interface UserDao {
@Query("SELECT * FROM playback_sessions WHERE id = :id")
suspend fun getPlaybackSession(id: String): PlaybackSessionEntity?
@Query("SELECT * FROM playback_sessions WHERE id = :id")
fun observePlaybackSession(id: String): Flow<PlaybackSessionEntity?>
@Query("SELECT * FROM playback_sessions ORDER BY updated_at DESC LIMIT 1")
suspend fun getLatestPlaybackSession(): PlaybackSessionEntity?
@@ -372,12 +376,36 @@ interface UserDao {
@Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position")
suspend fun getAllQueueEntries(sessionId: String): List<PlaybackQueueEntryEntity>
@Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position")
fun observeQueueEntries(sessionId: String): Flow<List<PlaybackQueueEntryEntity>>
@Upsert
suspend fun putQueueEntries(entries: List<PlaybackQueueEntryEntity>)
@Query("DELETE FROM playback_queue_entries WHERE session_id = :sessionId")
suspend fun clearQueueEntries(sessionId: String)
@Query(
"""
UPDATE playback_queue_entries
SET position = position + :offset
WHERE session_id = :sessionId AND position >= :start
""",
)
suspend fun parkQueuePositions(
sessionId: String,
start: Long,
offset: Long,
)
@Query(
"""
DELETE FROM playback_queue_entries
WHERE session_id = :sessionId AND entry_id IN (:entryIds)
""",
)
suspend fun deleteQueueEntriesById(sessionId: String, entryIds: List<Long>)
@Query("SELECT COUNT(*) FROM playback_queue_entries WHERE session_id = :sessionId")
suspend fun countQueueEntries(sessionId: String): Long
@@ -390,6 +418,27 @@ interface UserDao {
@Query("DELETE FROM playback_original_queue_entries WHERE session_id = :sessionId")
suspend fun clearOriginalQueueEntries(sessionId: String)
@Query(
"""
UPDATE playback_original_queue_entries
SET position = position + :offset
WHERE session_id = :sessionId AND position >= :start
""",
)
suspend fun parkOriginalQueuePositions(
sessionId: String,
start: Long,
offset: Long,
)
@Query(
"""
DELETE FROM playback_original_queue_entries
WHERE session_id = :sessionId AND entry_id IN (:entryIds)
""",
)
suspend fun deleteOriginalQueueEntriesById(sessionId: String, entryIds: List<Long>)
@Query("SELECT * FROM snapshot_metadata WHERE id = 1")
suspend fun getSnapshotMetadata(): SnapshotMetadataEntity?
@@ -409,6 +458,88 @@ interface UserDao {
if (originalEntries.isNotEmpty()) putOriginalQueueEntries(originalEntries)
}
/**
* Rewrites only the position range changed by an edit. Rows are parked in a
* disjoint position space first so the unique (session, position) index never
* observes a transient collision during moves.
*/
@Transaction
suspend fun applyPlaybackQueueMutation(
session: PlaybackSessionEntity,
entries: List<PlaybackQueueEntryEntity>,
originalEntries: List<PlaybackOriginalQueueEntryEntity>,
) {
val oldEntries = getAllQueueEntries(session.id)
putPlaybackSession(session)
val changedAt = firstChangedQueuePosition(oldEntries, entries)
if (changedAt != null) {
parkQueuePositions(session.id, changedAt.toLong(), 1_000_000_000_000L)
val retainedIds = entries.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
val removedIds = oldEntries
.asSequence()
.map(PlaybackQueueEntryEntity::entryId)
.filterNot(retainedIds::contains)
.toList()
if (removedIds.isNotEmpty()) deleteQueueEntriesById(session.id, removedIds)
val changedRows = entries.drop(changedAt)
if (changedRows.isNotEmpty()) putQueueEntries(changedRows)
}
val oldOriginal = getOriginalQueueEntries(session.id)
if (originalEntries.isEmpty()) {
if (oldOriginal.isNotEmpty()) clearOriginalQueueEntries(session.id)
return
}
val originalChangedAt = firstChangedOriginalPosition(oldOriginal, originalEntries)
if (originalChangedAt != null) {
parkOriginalQueuePositions(
session.id,
originalChangedAt.toLong(),
1_000_000_000_000L,
)
val retainedIds = originalEntries
.mapTo(hashSetOf(), PlaybackOriginalQueueEntryEntity::entryId)
val removedIds = oldOriginal
.asSequence()
.map(PlaybackOriginalQueueEntryEntity::entryId)
.filterNot(retainedIds::contains)
.toList()
if (removedIds.isNotEmpty()) {
deleteOriginalQueueEntriesById(session.id, removedIds)
}
val changedRows = originalEntries.drop(originalChangedAt)
if (changedRows.isNotEmpty()) putOriginalQueueEntries(changedRows)
}
}
private fun firstChangedQueuePosition(
before: List<PlaybackQueueEntryEntity>,
after: List<PlaybackQueueEntryEntity>,
): Int? {
val shared = minOf(before.size, after.size)
for (index in 0 until shared) {
if (
before[index].entryId != after[index].entryId ||
before[index].trackPath != after[index].trackPath
) return index
}
return shared.takeIf { before.size != after.size }
}
private fun firstChangedOriginalPosition(
before: List<PlaybackOriginalQueueEntryEntity>,
after: List<PlaybackOriginalQueueEntryEntity>,
): Int? {
val shared = minOf(before.size, after.size)
for (index in 0 until shared) {
if (
before[index].entryId != after[index].entryId ||
before[index].trackPath != after[index].trackPath
) return index
}
return shared.takeIf { before.size != after.size }
}
@Transaction
suspend fun replaceRemoteUserState(
sourceId: Long,
@@ -525,7 +656,7 @@ interface UserDao {
PlaybackOriginalQueueEntryEntity::class,
SnapshotMetadataEntity::class,
],
version = 2,
version = 3,
exportSchema = true,
)
abstract class AstraUserDatabase : RoomDatabase() {
@@ -608,3 +739,151 @@ internal val USER_MIGRATION_1_2 = object : Migration(1, 2) {
)
}
}
internal val USER_MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `queue_revision` INTEGER NOT NULL DEFAULT 0",
)
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `catalog_revision` INTEGER",
)
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `is_dirty` INTEGER NOT NULL DEFAULT 0",
)
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `next_entry_id` INTEGER NOT NULL DEFAULT 0",
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `playback_queue_entries_new` (
`session_id` TEXT NOT NULL,
`entry_id` INTEGER NOT NULL,
`position` INTEGER NOT NULL,
`track_path` TEXT NOT NULL,
PRIMARY KEY(`session_id`, `entry_id`),
FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent(),
)
database.execSQL(
"""
INSERT INTO `playback_queue_entries_new` (`session_id`, `entry_id`, `position`, `track_path`)
SELECT `session_id`, `position`, `position`, `track_path`
FROM `playback_queue_entries`
""".trimIndent(),
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `playback_original_queue_entries_new` (
`session_id` TEXT NOT NULL,
`entry_id` INTEGER NOT NULL,
`position` INTEGER NOT NULL,
`track_path` TEXT NOT NULL,
PRIMARY KEY(`session_id`, `entry_id`),
FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent(),
)
val idsBySessionAndPath = mutableMapOf<Pair<String, String>, java.util.ArrayDeque<Long>>()
var nextSyntheticId = 0L
database.query(
"""
SELECT `session_id`, `entry_id`, `track_path`
FROM `playback_queue_entries_new`
ORDER BY `session_id`, `position`
""".trimIndent(),
).use { cursor ->
while (cursor.moveToNext()) {
val sessionId = cursor.getString(0)
val entryId = cursor.getLong(1)
val path = cursor.getString(2)
idsBySessionAndPath
.getOrPut(sessionId to path) { java.util.ArrayDeque() }
.addLast(entryId)
nextSyntheticId = maxOf(nextSyntheticId, entryId + 1)
}
}
database.compileStatement(
"""
INSERT INTO `playback_original_queue_entries_new`
(`session_id`, `entry_id`, `position`, `track_path`)
VALUES (?, ?, ?, ?)
""".trimIndent(),
).use { insert ->
database.query(
"""
SELECT `session_id`, `position`, `track_path`
FROM `playback_original_queue_entries`
ORDER BY `session_id`, `position`
""".trimIndent(),
).use { cursor ->
while (cursor.moveToNext()) {
val sessionId = cursor.getString(0)
val position = cursor.getLong(1)
val path = cursor.getString(2)
val matchingIds = idsBySessionAndPath[sessionId to path]
val entryId = if (matchingIds != null && matchingIds.isNotEmpty()) {
matchingIds.removeFirst()
} else {
nextSyntheticId++
}
insert.clearBindings()
insert.bindString(1, sessionId)
insert.bindLong(2, entryId)
insert.bindLong(3, position)
insert.bindString(4, path)
insert.executeInsert()
}
}
}
database.execSQL("DROP TABLE `playback_queue_entries`")
database.execSQL("ALTER TABLE `playback_queue_entries_new` RENAME TO `playback_queue_entries`")
database.execSQL("DROP TABLE `playback_original_queue_entries`")
database.execSQL(
"ALTER TABLE `playback_original_queue_entries_new` RENAME TO `playback_original_queue_entries`",
)
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_position` " +
"ON `playback_queue_entries` (`session_id`, `position`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_track_path` " +
"ON `playback_queue_entries` (`session_id`, `track_path`)",
)
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_position` " +
"ON `playback_original_queue_entries` (`session_id`, `position`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_track_path` " +
"ON `playback_original_queue_entries` (`session_id`, `track_path`)",
)
database.execSQL(
"""
UPDATE `playback_sessions`
SET `queue_revision` = CASE
WHEN EXISTS (
SELECT 1 FROM `playback_queue_entries`
WHERE `session_id` = `playback_sessions`.`id`
) THEN 1
ELSE 0
END,
`next_entry_id` = COALESCE(
(
SELECT MAX(`entry_id`) + 1
FROM `playback_queue_entries`
WHERE `session_id` = `playback_sessions`.`id`
),
0
)
""".trimIndent(),
)
}
}
@@ -226,11 +226,15 @@ data class PlaybackSessionEntity(
@ColumnInfo(name = "active_position") val activePosition: Long,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "queue_revision") val queueRevision: Long = 0,
@ColumnInfo(name = "catalog_revision") val catalogRevision: Long? = null,
@ColumnInfo(name = "is_dirty") val isDirty: Boolean = false,
@ColumnInfo(name = "next_entry_id") val nextEntryId: Long = 0,
)
@Entity(
tableName = "playback_queue_entries",
primaryKeys = ["session_id", "position"],
primaryKeys = ["session_id", "entry_id"],
foreignKeys = [
ForeignKey(
entity = PlaybackSessionEntity::class,
@@ -240,6 +244,7 @@ data class PlaybackSessionEntity(
),
],
indices = [
Index(value = ["session_id", "position"], unique = true),
Index(value = ["session_id", "track_path"]),
],
)
@@ -247,11 +252,12 @@ data class PlaybackQueueEntryEntity(
@ColumnInfo(name = "session_id") val sessionId: String,
val position: Long,
@ColumnInfo(name = "track_path") val trackPath: String,
@ColumnInfo(name = "entry_id") val entryId: Long = position,
)
@Entity(
tableName = "playback_original_queue_entries",
primaryKeys = ["session_id", "position"],
primaryKeys = ["session_id", "entry_id"],
foreignKeys = [
ForeignKey(
entity = PlaybackSessionEntity::class,
@@ -260,12 +266,16 @@ data class PlaybackQueueEntryEntity(
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index(value = ["session_id", "track_path"])],
indices = [
Index(value = ["session_id", "position"], unique = true),
Index(value = ["session_id", "track_path"]),
],
)
data class PlaybackOriginalQueueEntryEntity(
@ColumnInfo(name = "session_id") val sessionId: String,
val position: Long,
@ColumnInfo(name = "track_path") val trackPath: String,
@ColumnInfo(name = "entry_id") val entryId: Long = position,
)
@Entity(tableName = "snapshot_metadata")
@@ -336,6 +336,10 @@ private fun PlaybackSessionEntity.toJson() = JSONObject()
.put("activePosition", activePosition)
.put("createdAt", createdAt)
.put("updatedAt", updatedAt)
.put("queueRevision", queueRevision)
.putNullable("catalogRevision", catalogRevision)
.put("isDirty", isDirty)
.put("nextEntryId", nextEntryId)
private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity(
id = json.getString("id"),
@@ -345,26 +349,34 @@ private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity(
activePosition = json.getLong("activePosition"),
createdAt = json.getLong("createdAt"),
updatedAt = json.getLong("updatedAt"),
queueRevision = json.optLong("queueRevision", 0),
catalogRevision = json.nullableLong("catalogRevision"),
isDirty = json.optBoolean("isDirty", false),
nextEntryId = json.optLong("nextEntryId", 0),
)
private fun PlaybackQueueEntryEntity.toJson() = JSONObject()
.put("sessionId", sessionId)
.put("entryId", entryId)
.put("position", position)
.put("trackPath", trackPath)
private fun playbackQueueFromJson(json: JSONObject) = PlaybackQueueEntryEntity(
sessionId = json.getString("sessionId"),
entryId = json.optLong("entryId", json.getLong("position")),
position = json.getLong("position"),
trackPath = json.getString("trackPath"),
)
private fun PlaybackOriginalQueueEntryEntity.toJson() = JSONObject()
.put("sessionId", sessionId)
.put("entryId", entryId)
.put("position", position)
.put("trackPath", trackPath)
private fun playbackOriginalQueueFromJson(json: JSONObject) = PlaybackOriginalQueueEntryEntity(
sessionId = json.getString("sessionId"),
entryId = json.optLong("entryId", json.getLong("position")),
position = json.getLong("position"),
trackPath = json.getString("trackPath"),
)
@@ -0,0 +1,45 @@
package expo.modules.astralibraryscanner.queue
import android.content.Context
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
class AstraQueueView(
context: Context,
appContext: AppContext,
) : ExpoView(context, appContext) {
private val content = QueueContentView(context)
var active: Boolean = true
set(value) {
field = value
content.active = value
}
var palette: QueuePalette = QueuePalette()
set(value) {
field = value
content.palette = value
}
init {
addView(
content,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
)
}
fun setPlaybackRequestListener(listener: QueueContentView.PlaybackRequestListener?) {
content.playbackRequestListener = listener
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
content.attach()
}
override fun onDetachedFromWindow() {
content.detach()
super.onDetachedFromWindow()
}
}
@@ -0,0 +1,376 @@
package expo.modules.astralibraryscanner.queue
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Trace
import expo.modules.astralibraryscanner.data.ACTIVE_PLAYBACK_CONTEXT_ID
import expo.modules.astralibraryscanner.data.ActiveTrackView
import expo.modules.astralibraryscanner.data.AstraLibraryRepository
import expo.modules.astralibraryscanner.data.PlaybackQueueEntryEntity
import expo.modules.astralibraryscanner.data.PlaybackSessionEntity
import java.io.File
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
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.launch
import kotlinx.coroutines.sync.Mutex
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
data class QueueRowModel(
val entryId: Long,
val position: Long,
val trackPath: String,
val title: String,
val artist: String,
val artworkThumbPath: String?,
val durationSeconds: Double,
val hydrated: Boolean,
)
data class NativeQueueSnapshot(
val sessionId: String?,
val revision: Long,
val activePosition: Long,
val totalCount: Int,
val rows: List<QueueRowModel>,
val loading: Boolean,
) {
init {
require(rows.all(QueueRowModel::hydrated)) {
"Native queue snapshots must never expose unresolved fallback rows"
}
}
companion object {
val Empty = NativeQueueSnapshot(
sessionId = null,
revision = 0,
activePosition = -1,
totalCount = 0,
rows = emptyList(),
loading = false,
)
}
}
private data class PendingQueueMutation(
val baseRevision: Long,
val previousEntryIds: List<Long>,
)
class QueueCoordinator private constructor(
context: Context,
) {
private val applicationContext = context.applicationContext
private val repository = AstraLibraryRepository.get(applicationContext)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutationMutex = Mutex()
private val traceCookie = AtomicInteger()
private val listeners = CopyOnWriteArraySet<(NativeQueueSnapshot) -> Unit>()
private val mutableSnapshot = MutableStateFlow(NativeQueueSnapshot.Empty)
val snapshot: StateFlow<NativeQueueSnapshot> = mutableSnapshot.asStateFlow()
private var observationJob: Job? = null
private var hydrationGeneration = 0L
@Volatile
private var pendingMutation: PendingQueueMutation? = null
@Volatile
private var latestEntries: List<PlaybackQueueEntryEntity> = emptyList()
private val metadataByPath =
object : LinkedHashMap<String, ActiveTrackView?>(MAX_CACHED_ROWS, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, ActiveTrackView?>,
): Boolean = size > MAX_CACHED_ROWS
}
fun addListener(listener: (NativeQueueSnapshot) -> Unit) {
listeners += listener
listener(mutableSnapshot.value)
}
fun removeListener(listener: (NativeQueueSnapshot) -> Unit) {
listeners -= listener
}
fun start() {
if (observationJob != null) return
observationJob = scope.launch {
val dao = repository.userDb().userDao()
combine(
dao.observePlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID),
dao.observeQueueEntries(ACTIVE_PLAYBACK_CONTEXT_ID),
) { session, entries -> session to entries }
.collectLatest { (session, entries) ->
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),
)
}
}
suspend fun move(entryId: Long, targetEntryId: 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()) {
false
} else {
repository.mutatePlaybackContext(
"move",
mapOf("from" to from, "to" to to),
)
true
}
}
suspend fun remove(entryIds: Set<Long>): Boolean =
mutate { current ->
val positions = latestEntries
.mapIndexedNotNull { index, row ->
index.takeIf {
row.entryId in entryIds && index != current.activePosition.toInt()
}
}
if (positions.isEmpty()) {
false
} else {
repository.mutatePlaybackContext("remove", mapOf("positions" to positions))
true
}
}
suspend fun moveAfterActive(entryIds: Set<Long>): Boolean =
mutate { current ->
val positions = latestEntries
.mapIndexedNotNull { index, row ->
index.takeIf {
row.entryId in entryIds && index != current.activePosition.toInt()
}
}
if (positions.isEmpty()) {
false
} else {
repository.mutatePlaybackContext(
"moveManyAfterActive",
mapOf("positions" to positions),
)
true
}
}
fun positionForEntry(entryId: Long, expectedRevision: Long): Long? {
val current = mutableSnapshot.value
if (current.revision != expectedRevision) return null
return latestEntries.firstOrNull { it.entryId == entryId }?.position
}
private suspend fun mutate(block: suspend (NativeQueueSnapshot) -> Boolean): Boolean =
mutationMutex.withLock {
traceAsync("AstraQueue.mutate") {
val marker = PendingQueueMutation(
baseRevision = mutableSnapshot.value.revision,
previousEntryIds = latestEntries.map(PlaybackQueueEntryEntity::entryId),
)
pendingMutation = marker
try {
block(mutableSnapshot.value).also { changed ->
if (!changed && pendingMutation === marker) pendingMutation = null
}
} catch (error: Throwable) {
if (pendingMutation === marker) pendingMutation = null
throw error
}
}
}
private suspend fun publishAndHydrate(
session: PlaybackSessionEntity?,
entries: List<PlaybackQueueEntryEntity>,
) {
pendingMutation?.let { pending ->
val incomingEntryIds = entries.map(PlaybackQueueEntryEntity::entryId)
if (incomingEntryIds != pending.previousEntryIds) {
if (pendingMutation === pending) pendingMutation = null
} else if (
session != null &&
session.queueRevision > pending.baseRevision
) {
// Room invalidates the session and queue Flow separately even though
// the write itself is transactional. Ignore the brief mixed pair
// ("new revision, old rows") so an optimistic drag/remove/play-next
// never jumps back before the queue invalidation arrives.
return
}
}
val generation = ++hydrationGeneration
if (session == null || entries.isEmpty()) {
latestEntries = emptyList()
publish(NativeQueueSnapshot.Empty)
return
}
latestEntries = entries
traceAsync("AstraQueue.rowHydration") {
val activeIndex = session.activePosition
.coerceIn(0L, (entries.size - 1).toLong())
.toInt()
val displayEntries = entries
.subList(activeIndex, entries.size)
.take(MAX_CACHED_ROWS)
resolveMetadata(
displayEntries.take(FIRST_METADATA_ROWS),
generation,
)
if (generation != hydrationGeneration) return@traceAsync
var loadedEnd = minOf(FIRST_METADATA_ROWS, displayEntries.size)
while (
loadedEnd < displayEntries.size &&
metadataResolved(displayEntries[loadedEnd])
) {
loadedEnd += 1
}
publishHydrated(
session = session,
totalCount = entries.size,
entries = displayEntries,
loadedEnd = loadedEnd,
)
var start = FIRST_METADATA_ROWS
while (start < displayEntries.size && generation == hydrationGeneration) {
val end = minOf(displayEntries.size, start + METADATA_CHUNK)
resolveMetadata(displayEntries.subList(start, end), generation)
if (generation != hydrationGeneration) return@traceAsync
publishHydrated(
session = session,
totalCount = entries.size,
entries = displayEntries,
loadedEnd = end,
)
start += METADATA_CHUNK
}
if (generation == hydrationGeneration) {
publish(mutableSnapshot.value.copy(loading = false))
}
}
}
private suspend fun <T> traceAsync(name: String, block: suspend () -> T): T {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return block()
val cookie = traceCookie.incrementAndGet()
Trace.beginAsyncSection(name, cookie)
return try {
block()
} finally {
Trace.endAsyncSection(name, cookie)
}
}
private suspend fun resolveMetadata(
entries: List<PlaybackQueueEntryEntity>,
generation: Long,
) {
if (entries.isEmpty() || generation != hydrationGeneration) return
val unresolvedPaths = entries
.map(PlaybackQueueEntryEntity::trackPath)
.distinct()
.filterNot(metadataByPath::containsKey)
if (unresolvedPaths.isEmpty()) return
val metadata = repository.nativeQueueTracks(unresolvedPaths)
.associateBy(ActiveTrackView::path)
if (generation != hydrationGeneration) return
unresolvedPaths.forEach { path ->
metadataByPath[path] = metadata[path]
}
}
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,
)
}
publish(
NativeQueueSnapshot(
sessionId = session.id,
revision = session.queueRevision,
activePosition = session.activePosition,
totalCount = totalCount,
rows = rows,
loading = loadedEnd < entries.size,
),
)
}
private fun metadataResolved(entry: PlaybackQueueEntryEntity): Boolean =
metadataByPath.containsKey(entry.trackPath)
private fun readableFileName(path: String): String {
val decoded = Uri.decode(path)
val leaf = decoded
.substringAfterLast('/')
.substringAfterLast(':')
return leaf.substringBeforeLast('.', leaf).ifBlank { "Unknown title" }
}
private fun publish(next: NativeQueueSnapshot) {
mutableSnapshot.value = next
listeners.forEach { it(next) }
}
private fun thumbFileName(hash: String): String {
val stem = hash.substringBeforeLast('.', hash)
return "$stem.jpg"
}
companion object {
@Volatile
private var instance: QueueCoordinator? = null
fun get(context: Context): QueueCoordinator =
instance ?: synchronized(this) {
instance ?: QueueCoordinator(context).also { instance = it }
}
}
}
@@ -0,0 +1,144 @@
package expo.modules.astralibraryscanner.queue
import android.content.Context
import android.media.AudioAttributes
import android.os.Build
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.provider.Settings
import android.view.HapticFeedbackConstants
import android.view.View
/**
* Native equivalents of Astra's selected queue recipes. Keeping these in the
* queue renderer avoids a JS round-trip at the exact moment a gesture arms,
* crosses a row boundary, or lands.
*/
class QueueHaptics(context: Context) {
private val applicationContext = context.applicationContext
private val vibrator: Vibrator =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(
applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE)
as VibratorManager
).defaultVibrator
} else {
@Suppress("DEPRECATION")
applicationContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
fun lift(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 0.7f),
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.5f, 45),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.DRAG_START)
}
}
fun drop(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.7f),
Primitive(VibrationEffect.Composition.PRIMITIVE_THUD, 0.5f, 30),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)
}
}
fun step(view: View) {
if (!compose(Primitive(VibrationEffect.Composition.PRIMITIVE_TICK, 0.35f))) {
view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK)
}
}
fun threshold(view: View, armed: Boolean) {
view.performHapticFeedback(
if (armed) {
HapticFeedbackConstants.GESTURE_START
} else {
HapticFeedbackConstants.GESTURE_END
},
)
}
fun selection(view: View) {
if (!compose(Primitive(VibrationEffect.Composition.PRIMITIVE_TICK, 0.55f))) {
view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_TICK)
}
}
fun confirm(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 0.45f),
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.75f, 45),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
}
}
fun reject(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.75f),
Primitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, 0.7f, 45),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
}
}
private fun compose(vararg primitives: Primitive): Boolean {
if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
primitives.isEmpty() ||
!vibrator.hasVibrator() ||
!touchFeedbackEnabled()
) {
return false
}
val ids = primitives.map(Primitive::id).toIntArray()
if (!vibrator.areAllPrimitivesSupported(*ids)) return false
return runCatching {
val composition = VibrationEffect.startComposition()
primitives.forEach { primitive ->
composition.addPrimitive(primitive.id, primitive.scale, primitive.delayMs)
}
vibrate(composition.compose())
true
}.getOrDefault(false)
}
private fun vibrate(effect: VibrationEffect) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
vibrator.vibrate(
effect,
VibrationAttributes.createForUsage(VibrationAttributes.USAGE_TOUCH),
)
} else {
vibrator.vibrate(
effect,
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.build(),
)
}
}
private fun touchFeedbackEnabled(): Boolean =
Settings.System.getInt(
applicationContext.contentResolver,
Settings.System.HAPTIC_FEEDBACK_ENABLED,
1,
) != 0
private data class Primitive(
val id: Int,
val scale: Float,
val delayMs: Int = 0,
)
}
@@ -0,0 +1,44 @@
package expo.modules.astralibraryscanner.queue
import android.graphics.Color
data class QueuePalette(
val background: Int = Color.rgb(24, 24, 28),
val surface: Int = Color.rgb(35, 35, 41),
val elevatedSurface: Int = Color.rgb(48, 48, 56),
val selectedSurface: Int = Color.rgb(57, 62, 75),
val nowPlayingSurface: Int = Color.rgb(31, 34, 43),
val divider: Int = Color.rgb(72, 72, 82),
val ripple: Int = Color.argb(38, 140, 162, 208),
val text: Int = Color.WHITE,
val textSecondary: Int = Color.rgb(190, 190, 200),
val textTertiary: Int = Color.rgb(135, 135, 148),
val accent: Int = Color.rgb(122, 162, 255),
val accentText: Int = Color.rgb(155, 181, 239),
val accentTextStrong: Int = Color.rgb(186, 205, 248),
val warning: Int = Color.rgb(255, 105, 105),
) {
companion object {
fun from(values: Map<String, Any?>?): QueuePalette {
val defaults = QueuePalette()
fun color(key: String, fallback: Int): Int =
(values?.get(key) as? Number)?.toInt() ?: fallback
return QueuePalette(
background = color("background", defaults.background),
surface = color("surface", defaults.surface),
elevatedSurface = color("elevatedSurface", defaults.elevatedSurface),
selectedSurface = color("selectedSurface", defaults.selectedSurface),
nowPlayingSurface = color("nowPlayingSurface", defaults.nowPlayingSurface),
divider = color("divider", defaults.divider),
ripple = color("ripple", defaults.ripple),
text = color("text", defaults.text),
textSecondary = color("textSecondary", defaults.textSecondary),
textTertiary = color("textTertiary", defaults.textTertiary),
accent = color("accent", defaults.accent),
accentText = color("accentText", defaults.accentText),
accentTextStrong = color("accentTextStrong", defaults.accentTextStrong),
warning = color("warning", defaults.warning),
)
}
}
}
@@ -3,7 +3,8 @@
"android": {
"modules": [
"expo.modules.astralibraryscanner.AstraLibraryScannerModule",
"expo.modules.astralibraryscanner.AstraLibraryDataModule"
"expo.modules.astralibraryscanner.AstraLibraryDataModule",
"expo.modules.astralibraryscanner.AstraQueueModule"
]
}
}
+15 -1
View File
@@ -251,12 +251,13 @@ export type LibraryQuery =
export interface NativePlaybackWindow<T> {
sessionId: string;
items: (T & { queuePosition: number })[];
items: (T & { queuePosition: number; queueEntryId: number })[];
windowStart: number;
activePosition: number;
totalCount: number;
contextJson: string;
shuffleSeed: number | null;
queueRevision: number;
catalogRevision: string;
}
@@ -532,3 +533,16 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
export const AstraLibraryData =
requireNativeModule<AstraLibraryDataModuleType>('AstraLibraryData');
export {
AstraQueue,
AstraQueueView,
toNativeQueuePalette,
} from './queue';
export type {
AstraQueueViewProps,
NativeQueuePlaybackRequest,
NativeQueuePalette,
NativeQueuePresentationOptions,
NativeQueueRevisionEvent,
} from './queue';
+97
View File
@@ -0,0 +1,97 @@
import {
requireNativeModule,
requireNativeViewManager,
type NativeModule,
} from 'expo-modules-core';
import { processColor, type ViewProps } from 'react-native';
import type { Palette } from '../../src/theme/palettes';
export interface NativeQueuePalette {
background: number;
surface: number;
elevatedSurface: number;
selectedSurface: number;
nowPlayingSurface: number;
divider: number;
ripple: number;
text: number;
textSecondary: number;
textTertiary: number;
accent: number;
accentText: number;
accentTextStrong: number;
warning: number;
}
export interface NativeQueuePresentationOptions {
palette: NativeQueuePalette;
}
export interface NativeQueuePlaybackRequest {
requestId: string;
kind: 'playEntry';
entryId: number;
queueRevision: number;
}
export interface NativeQueueRevisionEvent {
sessionId: string | null;
queueRevision: number;
activePosition: number;
totalCount: number;
}
type AstraQueueEvents = {
onDismissed: () => void;
onPlaybackRequest: (event: NativeQueuePlaybackRequest) => void;
onQueueRevision: (event: NativeQueueRevisionEvent) => void;
};
declare class AstraQueueModuleType extends NativeModule<AstraQueueEvents> {
present(options: NativeQueuePresentationOptions): Promise<void>;
dismiss(): void;
resolvePlaybackRequest(
requestId: string,
success: boolean,
message?: string | null,
): void;
resolveEntryPosition(
entryId: number,
expectedRevision: number,
): Promise<number | null>;
}
export interface AstraQueueViewProps extends ViewProps {
active: boolean;
palette: NativeQueuePalette;
}
function nativeColor(value: string): number {
const processed = processColor(value);
return typeof processed === 'number' ? processed : 0;
}
export function toNativeQueuePalette(colors: Palette): NativeQueuePalette {
return {
background: nativeColor(colors.bgSecondary),
surface: nativeColor(colors.bgSecondary),
elevatedSurface: nativeColor(colors.bgTertiary),
selectedSurface: nativeColor(colors.glassHighlight),
nowPlayingSurface: nativeColor(colors.glassBg),
divider: nativeColor(colors.glassBorder),
ripple: nativeColor(colors.ripple),
text: nativeColor(colors.textPrimary),
textSecondary: nativeColor(colors.textSecondary),
textTertiary: nativeColor(colors.textTertiary),
accent: nativeColor(colors.accent),
accentText: nativeColor(colors.accentText),
accentTextStrong: nativeColor(colors.accentTextStrong),
// Destructive queue actions are semantically different from Astra's amber
// warning token. Keep remove affordances unmistakably red in every theme.
warning: nativeColor('#ef5350'),
};
}
export const AstraQueue = requireNativeModule<AstraQueueModuleType>('AstraQueue');
export const AstraQueueView =
requireNativeViewManager<AstraQueueViewProps>('AstraQueue');
+1 -1
View File
@@ -64,7 +64,7 @@
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/queue/virtualQueuePaging.test.mts src/components/swipeableRowState.test.mts",
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/virtualPlaybackWindow.test.mts src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/queue/virtualQueuePaging.test.mts src/components/swipeableRowState.test.mts",
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts src/services/desktopRemoteTransport.test.mts",
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
+103 -20
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
index b2409a0..4491bad 100644
index b2409a0..119caaf 100644
--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
+++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
@@ -18,9 +18,11 @@ import com.doublesymmetry.trackplayer.utils.RejectionException
@@ -45,7 +45,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
val options = Arguments.toBundle(data)
@@ -262,13 +267,16 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -262,19 +267,24 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -60,11 +60,20 @@ index b2409a0..4491bad 100644
- val tracks = readableArrayToTrackList(data);
+ // Track conversion is O(queue) work (Bundle parsing, Uri resolution) —
+ // keep it off the main thread so long queues don't freeze the UI/ANR.
+ val tracks = withContext(Dispatchers.Default) { readableArrayToTrackList(data) };
+ val tracks = withContext(Dispatchers.Default) {
+ readableArrayToTrackList(data).map { it.toAudioItem() }
+ };
if (insertBeforeIndex < -1 || insertBeforeIndex > musicService.tracks.size) {
callback.reject("index_out_of_bounds", "The track index is out of bounds")
return@launch
@@ -283,9 +291,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
}
val index = if (insertBeforeIndex == -1) musicService.tracks.size else insertBeforeIndex
- musicService.add(
+ musicService.addPrepared(
tracks,
index
)
@@ -283,9 +293,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
rejectWithException(callback, exception)
}
}
@@ -76,7 +85,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
if (data == null) {
callback.resolve(null)
@@ -299,16 +308,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -299,16 +310,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.reject("invalid_track_object", "Track was not a dictionary type")
}
}
@@ -97,7 +106,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
val inputIndexes = Arguments.toList(data)
if (inputIndexes != null) {
@@ -329,9 +340,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -329,9 +342,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
}
callback.resolve(null)
}
@@ -109,7 +118,7 @@ index b2409a0..4491bad 100644
scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
@@ -346,9 +358,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -346,9 +360,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
}
@@ -121,7 +130,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty())
@@ -362,9 +375,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -362,9 +377,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -133,7 +142,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty())
@@ -373,17 +387,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -373,17 +389,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
musicService.clearNotificationMetadata()
callback.resolve(null)
}
@@ -155,7 +164,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skip(index)
@@ -394,9 +410,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -394,9 +412,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -167,7 +176,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToNext()
@@ -407,9 +424,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -407,9 +426,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -179,7 +188,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToPrevious()
@@ -420,9 +438,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -420,9 +440,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -191,7 +200,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.stop()
@@ -431,188 +450,222 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -431,188 +452,224 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -374,10 +383,12 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
try {
+ val tracks = withContext(Dispatchers.Default) { readableArrayToTrackList(data) }
+ val tracks = withContext(Dispatchers.Default) {
+ readableArrayToTrackList(data).map { it.toAudioItem() }
+ }
musicService.clear()
- musicService.add(readableArrayToTrackList(data))
+ musicService.add(tracks)
+ musicService.addPrepared(tracks, 0)
callback.resolve(null)
} catch (exception: Exception) {
rejectWithException(callback, exception)
@@ -443,7 +454,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch
var bundle = Bundle()
bundle.putDouble("duration", musicService.getDurationInSeconds());
@@ -620,10 +664,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -620,10 +677,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds());
callback.resolve(Arguments.fromBundle(bundle))
}
@@ -458,15 +469,87 @@ index b2409a0..4491bad 100644
+ }
}
diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
index afa6b0f..c6f01d5 100644
index afa6b0f..87af67c 100644
--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
+++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
@@ -337,0 +338,4 @@ class MusicService : HeadlessJsTaskService() {
@@ -9,6 +9,7 @@ import android.os.Binder
import android.os.Build
import android.os.Bundle
import android.os.IBinder
+import android.os.Trace
import android.support.v4.media.RatingCompat
import androidx.annotation.MainThread
import androidx.core.app.NotificationCompat
@@ -300,6 +301,18 @@ class MusicService : HeadlessJsTaskService() {
player.add(items, atIndex)
}
+ /**
+ * Track -> AudioItem construction is safe off the player looper and can be
+ * noticeable even for a bounded window. MusicModule prepares these on
+ * Dispatchers.Default; only the actual Media3 playlist mutation stays here.
+ */
+ @MainThread
+ fun addPrepared(items: List<TrackAudioItem>, atIndex: Int) {
+ tracePlaylistMutation("AstraQueue.rntpAdd") {
+ player.add(items, atIndex)
+ }
+ }
+
@MainThread
fun load(track: Track) {
player.load(track.toAudioItem())
@@ -307,7 +320,9 @@ class MusicService : HeadlessJsTaskService() {
@MainThread
fun move(fromIndex: Int, toIndex: Int) {
- player.move(fromIndex, toIndex);
+ tracePlaylistMutation("AstraQueue.rntpMove") {
+ player.move(fromIndex, toIndex);
+ }
}
@MainThread
@@ -317,12 +332,25 @@ class MusicService : HeadlessJsTaskService() {
@MainThread
fun remove(indexes: List<Int>) {
- player.remove(indexes)
+ tracePlaylistMutation("AstraQueue.rntpRemove") {
+ player.remove(indexes)
+ }
}
@MainThread
fun clear() {
- player.clear()
+ tracePlaylistMutation("AstraQueue.rntpClear") {
+ player.clear()
+ }
+ }
+
+ private inline fun <T> tracePlaylistMutation(name: String, block: () -> T): T {
+ Trace.beginSection(name)
+ return try {
+ block()
+ } finally {
+ Trace.endSection()
+ }
}
@MainThread
@@ -335,6 +363,10 @@ class MusicService : HeadlessJsTaskService() {
player.pause()
}
+ fun setPauseAtEndOfItem(enabled: Boolean) {
+ player.setPauseAtEndOfItem(enabled)
+ }
+
@@ -741,7 +745,7 @@ class MusicService : HeadlessJsTaskService() {
@MainThread
fun stop() {
player.stop()
@@ -741,7 +773,7 @@ class MusicService : HeadlessJsTaskService() {
@MainThread
private fun emit(event: String, data: Bundle? = null) {
@@ -475,7 +558,7 @@ index afa6b0f..c6f01d5 100644
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(event, data?.let { Arguments.fromBundle(it) })
}
@@ -751,7 +755,7 @@ class MusicService : HeadlessJsTaskService() {
@@ -751,7 +783,7 @@ class MusicService : HeadlessJsTaskService() {
val payload = Arguments.createArray()
data.forEach { payload.pushMap(Arguments.fromBundle(it)) }
+16 -1
View File
@@ -2,14 +2,17 @@ import { useEffect } from 'react';
import { InteractionManager } from 'react-native';
import { useRouter } from 'expo-router';
import {
SettingsCard,
SettingsNavRow,
SettingsSectionLabel,
SettingsSectionScreen,
SettingsToggleRow,
} from '@/components/settings/SettingsSectionScaffold';
import { formatRelativeTime } from '@/lib/format';
import { useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { useSettingsStore } from '@/stores/settingsStore';
export default function ExperimentalSettingsScreen() {
const colors = useColors();
@@ -20,6 +23,8 @@ export default function ExperimentalSettingsScreen() {
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const setNativeQueueEnabled = useSettingsStore((s) => s.setNativeQueueEnabled);
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
@@ -44,7 +49,17 @@ export default function ExperimentalSettingsScreen() {
return (
<SettingsSectionScreen title="Experimental">
<SettingsSectionLabel>DESKTOP</SettingsSectionLabel>
<SettingsSectionLabel>PLAYBACK</SettingsSectionLabel>
<SettingsCard>
<SettingsToggleRow
title="Native queue"
description="Use the Kotlin queue sheet and RecyclerView. Disable to compare with the legacy React Native queue."
value={nativeQueueEnabled}
onValueChange={(enabled) => void setNativeQueueEnabled(enabled)}
/>
</SettingsCard>
<SettingsSectionLabel spaced>DESKTOP</SettingsSectionLabel>
<SettingsNavRow
icon="phone-portrait-outline"
title="Desktop Remote"
+233 -36
View File
@@ -42,6 +42,17 @@ import {
type NativePlaybackWindow,
} from '../../modules/astra-library-scanner';
import { dbTrackToTrack } from '@/library/trackAdapter';
import {
VIRTUAL_PLAYBACK_APPEND_BATCH as TRANSPORT_APPEND_BATCH,
VIRTUAL_PLAYBACK_HISTORY as TRANSPORT_HISTORY,
VIRTUAL_PLAYBACK_REFILL_THRESHOLD as TRANSPORT_REFILL_THRESHOLD,
VIRTUAL_PLAYBACK_UPCOMING as TRANSPORT_UPCOMING,
VIRTUAL_PLAYBACK_WINDOW_SIZE as TRANSPORT_WINDOW_SIZE,
shouldRefillVirtualPlayback,
virtualPlaybackRefillLimit,
virtualPlaybackTrimCount,
virtualPlaybackWindowStart,
} from './virtualPlaybackWindow';
// If a background queue fill dies partway, the mirror no longer matches the
// native queue — re-read the truth.
@@ -62,11 +73,21 @@ let originalOrder: string[] | null = null;
let restoredMaterializationPromise: Promise<void> | null = null;
let virtualContext: {
sessionId: string;
queueRevision: number;
windowStart: number;
loadedEnd: number;
totalCount: number;
} | null = null;
let virtualRefillPromise: Promise<void> | null = null;
let requestedQueueRevision = 0;
let requestedActivePosition: number | null = null;
let virtualRevisionSyncPromise: Promise<void> | null = null;
export {
TRANSPORT_HISTORY,
TRANSPORT_REFILL_THRESHOLD,
TRANSPORT_UPCOMING,
};
export interface VirtualQueuePageItem {
track: RntpTrack;
@@ -85,11 +106,12 @@ export interface PlaybackStartOptions {
}
function toVirtualRntpTrack(
item: DbTrack & { queuePosition: number },
item: DbTrack & { queuePosition: number; queueEntryId: number },
): RntpTrack {
return {
...toRntpTrack(dbTrackToTrack(item)),
astraQueuePosition: item.queuePosition,
astraQueueEntryId: item.queueEntryId,
};
}
@@ -110,6 +132,11 @@ function toRntpRepeat(mode: RepeatModeStr): RepeatMode {
}
}
function toEffectiveRntpRepeat(mode: RepeatModeStr): RepeatMode {
if (virtualContext && mode === 'all') return RepeatMode.Off;
return toRntpRepeat(mode);
}
function mapRntpState(state?: State): PlaybackState {
switch (state) {
case State.Playing:
@@ -143,6 +170,24 @@ function setOptimisticTrack(track: RntpTrack | undefined, playbackState?: Playba
if (playbackState) player.setPlaybackState(playbackState);
}
function setVirtualQueueSnapshot(
tracks: RntpTrack[],
activeLocalIndex: number,
source?: PlaybackSource | null,
): void {
const context = virtualContext;
useQueueStore.getState().setSnapshot(tracks, activeLocalIndex, {
...(source !== undefined ? { source } : {}),
transport: context
? {
sessionId: context.sessionId,
queueRevision: context.queueRevision,
windowStart: context.windowStart,
}
: null,
});
}
async function reconcilePlayerFromNative(): Promise<void> {
try {
const [activeTrack, playbackState, progress] = await Promise.all([
@@ -240,7 +285,13 @@ async function materializeRestoredSession(): Promise<void> {
// Play. Rebuild every RNTP row from its stable Astra identity at the lazy
// materialization boundary so URL resolution is fresh.
const materializedTracks = queue.tracks.map((track) => toRntpTrack(rntpToTrack(track)));
useQueueStore.getState().setSnapshot(materializedTracks, queue.activeIndex);
if (virtualContext) {
setVirtualQueueSnapshot(materializedTracks, queue.activeIndex);
} else {
useQueueStore.getState().setSnapshot(materializedTracks, queue.activeIndex, {
transport: null,
});
}
if (player.currentTime > 0) player.setPendingSeek(player.currentTime);
await materializePlaybackQueue(
{
@@ -252,7 +303,7 @@ async function materializeRestoredSession(): Promise<void> {
{
loadQueue: loadQueueChunked,
setRepeat: async (repeat) => {
await TrackPlayer.setRepeatMode(toRntpRepeat(repeat));
await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(repeat));
},
seek: (position) => TrackPlayer.seekTo(position),
}
@@ -272,7 +323,7 @@ async function ensurePlayerReady(
): Promise<void> {
await setupPlayer(options);
if (options.materializeRestored !== false) await materializeRestoredSession();
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(usePlayerStore.getState().repeat));
}
function discardPendingRestoredSession(): void {
@@ -316,7 +367,7 @@ export function restorePlaybackSession(
const player = usePlayerStore.getState();
if (!session || session.tracks.length === 0) {
originalOrder = null;
useQueueStore.getState().setSnapshot([], -1, { source: null });
useQueueStore.getState().setSnapshot([], -1, { source: null, transport: null });
player.reset();
player.setShuffle(false);
player.setRepeat('none');
@@ -331,6 +382,7 @@ export function restorePlaybackSession(
.filter((id): id is string => Boolean(id));
useQueueStore.getState().setSnapshot(queueTracks, session.activeIndex, {
source: session.source,
transport: null,
});
player.setCurrentTrack(activeTrack);
player.setProgress(session.position, activeTrack.duration);
@@ -361,14 +413,14 @@ export function restoreVirtualPlaybackContext(
);
virtualContext = {
sessionId: window.sessionId,
queueRevision: window.queueRevision,
windowStart: window.windowStart,
loadedEnd: window.items[window.items.length - 1].queuePosition + 1,
totalCount: window.totalCount,
};
requestedQueueRevision = window.queueRevision;
originalOrder = session.shuffle ? null : tracks.map((track) => track.id);
useQueueStore.getState().setSnapshot(queueTracks, activeIndex, {
source: session.source,
});
setVirtualQueueSnapshot(queueTracks, activeIndex, session.source);
const activeTrack = tracks[activeIndex];
const player = usePlayerStore.getState();
player.setCurrentTrack(activeTrack);
@@ -414,8 +466,8 @@ export interface LibraryPlaybackStartOptions extends PlaybackStartOptions {
}
/**
* Starts a native virtual library context. Only 25 previous + 200 upcoming
* tracks cross into JavaScript; the complete ordered path set remains in Room.
* Starts a native virtual library context. Only eight historical, the current,
* and 32 upcoming tracks cross into JavaScript; complete order remains in Room.
*/
export async function playLibraryQuery(
query: LibraryQuery,
@@ -450,17 +502,20 @@ async function startVirtualWindow(
);
virtualContext = {
sessionId: window.sessionId,
queueRevision: window.queueRevision,
windowStart: window.windowStart,
loadedEnd: window.items.length === 0
? window.windowStart
: window.items[window.items.length - 1].queuePosition + 1,
totalCount: window.totalCount,
};
requestedQueueRevision = window.queueRevision;
await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(usePlayerStore.getState().repeat));
originalOrder = shuffle ? null : tracks.map((track) => track.id);
usePlayerStore.getState().setShuffle(shuffle);
const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none');
const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null;
useQueueStore.getState().setSnapshot(queueTracks, startIndex, { source });
setVirtualQueueSnapshot(queueTracks, startIndex, source);
setOptimisticTrack(queueTracks[startIndex], 'loading');
try {
await prepareAudioProcessingForPlayback(playbackTarget, 'virtual-queue-play');
@@ -485,6 +540,47 @@ export function handleVirtualPlaybackAdvance(_nativeEventIndex?: number): Promis
return virtualRefillPromise;
}
/** Continues a starved bounded window, or implements repeat-all over Room. */
export async function handleVirtualQueueEnded(): Promise<boolean> {
const context = virtualContext;
if (!context || context.totalCount <= 0) return false;
await queueLoadSettled();
if (virtualContext !== context) return false;
const currentPosition =
context.windowStart + Math.max(0, useQueueStore.getState().activeIndex);
const target = context.loadedEnd < context.totalCount
? Math.min(context.totalCount - 1, currentPosition + 1)
: usePlayerStore.getState().repeat === 'all'
? 0
: null;
if (target == null) return false;
await AstraLibraryData.updatePlaybackPosition(context.sessionId, target);
const window = await AstraLibraryData.getPlaybackWindow<DbTrack>(
context.sessionId,
virtualPlaybackWindowStart(target),
TRANSPORT_WINDOW_SIZE,
);
if (virtualContext !== context || window.items.length === 0) return false;
await startVirtualWindow(
window,
useQueueStore.getState().source ?? { kind: 'library', label: 'Library' },
usePlayerStore.getState().shuffle,
);
return true;
}
async function appendTransportTracks(
tracks: RntpTrack[],
baseCount: number,
): Promise<void> {
for (let index = 0; index < tracks.length; index += TRANSPORT_APPEND_BATCH) {
await appendUpcomingChunked(
tracks.slice(index, index + TRANSPORT_APPEND_BATCH),
baseCount + index,
);
}
}
async function replenishVirtualContext(): Promise<void> {
const context = virtualContext;
if (!context) return;
@@ -496,8 +592,8 @@ async function replenishVirtualContext(): Promise<void> {
void AstraLibraryData.updatePlaybackPosition(context.sessionId, activePosition).catch(() => {});
let localIndex = nativeIndex;
if (localIndex > 25) {
const removeCount = localIndex - 25;
const removeCount = virtualPlaybackTrimCount(localIndex);
if (removeCount > 0) {
const indices = Array.from({ length: removeCount }, (_, index) => index);
await TrackPlayer.remove(indices);
useQueueStore.getState().removeIndices(indices);
@@ -507,11 +603,21 @@ async function replenishVirtualContext(): Promise<void> {
const currentLength = useQueueStore.getState().tracks.length;
const upcoming = currentLength - localIndex - 1;
if (upcoming >= 50 || context.loadedEnd >= context.totalCount) return;
if (!shouldRefillVirtualPlayback(
upcoming,
context.loadedEnd,
context.totalCount,
)) return;
const requested = virtualPlaybackRefillLimit(
upcoming,
context.loadedEnd,
context.totalCount,
);
if (requested <= 0) return;
const next = await AstraLibraryData.getPlaybackWindow<DbTrack>(
context.sessionId,
context.loadedEnd,
100,
requested,
);
if (virtualContext !== context || next.items.length === 0) return;
const additions = next.items
@@ -524,12 +630,10 @@ async function replenishVirtualContext(): Promise<void> {
const nextLoadedEnd = Number(additions[additions.length - 1].astraQueuePosition) + 1;
if (!Number.isFinite(nextLoadedEnd) || nextLoadedEnd <= context.loadedEnd) return;
const before = useQueueStore.getState();
await appendUpcomingChunked(additions, before.tracks.length);
useQueueStore.getState().setSnapshot(
[...before.tracks, ...additions],
localIndex,
);
await appendTransportTracks(additions, before.tracks.length);
context.loadedEnd = Math.min(context.totalCount, nextLoadedEnd);
context.queueRevision = Math.max(context.queueRevision, next.queueRevision);
setVirtualQueueSnapshot([...before.tracks, ...additions], localIndex);
}
/** Returns a bounded page from the native virtual queue, or null for ordinary queues. */
@@ -563,6 +667,7 @@ export async function getVirtualQueuePage(
export function getVirtualQueueState(): {
sessionId: string;
queueRevision: number;
activePosition: number;
totalCount: number;
} | null {
@@ -571,11 +676,73 @@ export function getVirtualQueueState(): {
const localActive = useQueueStore.getState().activeIndex;
return {
sessionId: context.sessionId,
queueRevision: context.queueRevision,
activePosition: context.windowStart + Math.max(0, localActive),
totalCount: context.totalCount,
};
}
/**
* Coalesces Room revisions emitted by the Kotlin queue and rebuilds only RNTP's
* bounded upcoming tail. The currently playing MediaSource is never replaced.
*/
export function synchronizeVirtualQueueRevision(
queueRevision: number,
activePosition?: number,
): Promise<void> {
const context = virtualContext;
if (!context || queueRevision <= context.queueRevision) return Promise.resolve();
requestedQueueRevision = Math.max(requestedQueueRevision, queueRevision);
if (activePosition != null) requestedActivePosition = activePosition;
if (virtualRevisionSyncPromise) return virtualRevisionSyncPromise;
virtualRevisionSyncPromise = (async () => {
while (virtualContext && requestedQueueRevision > virtualContext.queueRevision) {
const targetRevision = requestedQueueRevision;
const targetActive = requestedActivePosition;
requestedActivePosition = null;
await synchronizeVirtualTransportOnce(targetRevision, targetActive);
}
})().finally(() => {
virtualRevisionSyncPromise = null;
});
return virtualRevisionSyncPromise;
}
async function synchronizeVirtualTransportOnce(
targetRevision: number,
emittedActivePosition: number | null,
): Promise<void> {
const context = virtualContext;
if (!context || targetRevision <= context.queueRevision) return;
await queueLoadSettled();
if (virtualContext !== context) return;
const nativeIndex = await TrackPlayer.getActiveTrackIndex();
if (nativeIndex == null || nativeIndex < 0) return;
const activePosition = emittedActivePosition ??
context.windowStart + nativeIndex;
const window = await AstraLibraryData.getPlaybackWindow<DbTrack>(
context.sessionId,
virtualPlaybackWindowStart(activePosition),
TRANSPORT_WINDOW_SIZE,
);
if (virtualContext !== context || window.queueRevision < targetRevision) return;
const upcoming = window.items
.filter((item) => item.queuePosition > window.activePosition)
.slice(0, TRANSPORT_UPCOMING)
.map(toVirtualRntpTrack);
const before = useQueueStore.getState();
const prefix = before.tracks.slice(0, nativeIndex + 1);
await TrackPlayer.removeUpcomingTracks();
await appendTransportTracks(upcoming, prefix.length);
context.windowStart = window.activePosition - nativeIndex;
context.loadedEnd = window.activePosition + upcoming.length + 1;
context.totalCount = window.totalCount;
context.queueRevision = window.queueRevision;
setVirtualQueueSnapshot([...prefix, ...upcoming], nativeIndex);
}
async function adoptCurrentQueueAsVirtualContext(): Promise<boolean> {
if (virtualContext) return true;
const snapshot = await getQueueSnapshot();
@@ -590,10 +757,12 @@ async function adoptCurrentQueueAsVirtualContext(): Promise<boolean> {
);
virtualContext = {
sessionId: window.sessionId,
queueRevision: window.queueRevision,
windowStart: window.activePosition - activeIndex,
loadedEnd: Math.min(window.totalCount, snapshot.queue.length),
totalCount: window.totalCount,
};
requestedQueueRevision = window.queueRevision;
originalOrder = null;
return true;
}
@@ -632,19 +801,18 @@ async function mutateVirtualQueue(
const before = useQueueStore.getState();
const prefix = before.tracks.slice(0, boundedActive + 1);
context.queueRevision = window.queueRevision;
await TrackPlayer.removeUpcomingTracks();
if (upcoming.length > 0) {
await appendUpcomingChunked(upcoming, prefix.length);
}
useQueueStore.getState().setSnapshot(
[...prefix, ...upcoming],
boundedActive,
);
await appendTransportTracks(upcoming.slice(0, TRANSPORT_UPCOMING), prefix.length);
context.windowStart = window.activePosition - boundedActive;
context.loadedEnd = upcoming.length > 0
? window.activePosition + upcoming.length + 1
? window.activePosition + Math.min(upcoming.length, TRANSPORT_UPCOMING) + 1
: window.activePosition + 1;
context.totalCount = window.totalCount;
setVirtualQueueSnapshot(
[...prefix, ...upcoming.slice(0, TRANSPORT_UPCOMING)],
boundedActive,
);
return window;
}
@@ -684,6 +852,7 @@ async function playTracksInternal(
const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null;
useQueueStore.getState().setSnapshot(queueTracks, startIndex, {
source: startOptions.source,
transport: null,
});
setOptimisticTrack(queueTracks[startIndex], 'loading');
try {
@@ -713,7 +882,7 @@ export async function shuffleTracks(
const queueTracks = shuffleArray(tracks).map(toRntpTrack);
const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none');
const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null;
useQueueStore.getState().setSnapshot(queueTracks, 0, { source });
useQueueStore.getState().setSnapshot(queueTracks, 0, { source, transport: null });
setOptimisticTrack(queueTracks[0], 'loading');
try {
await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play');
@@ -743,13 +912,14 @@ export async function playSample(): Promise<void> {
originalOrder = SAMPLE_TRACKS.map((t) => t.id);
useQueueStore.getState().setSnapshot(sampleQueue, 0, {
source: { kind: 'sample', label: 'Astra Sample' },
transport: null,
});
setOptimisticTrack(sampleQueue[0], 'loading');
} else {
const activeIndex = await TrackPlayer.getActiveTrackIndex();
playbackTarget = dspTargetFromTrack(queue[activeIndex ?? 0], 'immediate');
await prepareAudioProcessingForPlayback(playbackTarget, 'sample-resume');
useQueueStore.getState().setSnapshot(queue, activeIndex);
useQueueStore.getState().setSnapshot(queue, activeIndex, { transport: null });
setOptimisticTrack(queue[activeIndex ?? 0], 'loading');
}
try {
@@ -825,6 +995,16 @@ export async function skipToNext(): Promise<void> {
]);
const playbackStateAtIntent = mapRntpState(nativePlaybackState.state);
const resumeAfterSkip = shouldResumeAfterExplicitNext(playbackStateAtIntent);
const virtualState = getVirtualQueueState();
if (
virtualState &&
nativeIndex != null &&
nativeIndex >= nativeQueue.length - 1 &&
virtualState.activePosition + 1 < virtualState.totalCount
) {
await jumpToQueueIndex(virtualState.activePosition + 1, { virtualPosition: true });
return;
}
const playbackTarget = dspTargetFromTrack(
nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1],
'none',
@@ -882,6 +1062,11 @@ export async function skipToPrevious(): Promise<void> {
TrackPlayer.getQueue(),
TrackPlayer.getActiveTrackIndex(),
]);
const virtualState = getVirtualQueueState();
if (virtualState && nativeIndex === 0 && virtualState.activePosition > 0) {
await jumpToQueueIndex(virtualState.activePosition - 1, { virtualPosition: true });
return;
}
await prepareAudioProcessingForPlayback(
dspTargetFromTrack(
nativeIndex == null ? undefined : nativeQueue[nativeIndex - 1],
@@ -914,7 +1099,7 @@ export async function cycleRepeat(): Promise<void> {
const next = NEXT_REPEAT[usePlayerStore.getState().repeat];
usePlayerStore.getState().setRepeat(next);
await ensurePlayerReady();
await TrackPlayer.setRepeatMode(toRntpRepeat(next));
await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(next));
}
/**
@@ -925,18 +1110,27 @@ export async function cycleRepeat(): Promise<void> {
export async function toggleShuffle(): Promise<void> {
const store = usePlayerStore.getState();
const next = !store.shuffle;
// The control is a direct-manipulation toggle: reflect it immediately while
// Room reorders the authoritative queue and the bounded RNTP tail catches up.
// A failed native mutation rolls the visual state back.
store.setShuffle(next);
await ensurePlayerReady();
await queueLoadSettled();
if (virtualContext) {
try {
await mutateVirtualQueue('shuffle', {
enabled: next,
seed: next ? Date.now() : null,
});
store.setShuffle(next);
} catch (error) {
store.setShuffle(!next);
throw error;
}
return;
}
try {
const snapshot = await getQueueSnapshot();
const queue = snapshot.queue;
const activeIndex = snapshot.activeIndex >= 0 ? snapshot.activeIndex : 0;
@@ -964,8 +1158,11 @@ export async function toggleShuffle(): Promise<void> {
mirroredQueue = [...queue.slice(0, activeIndex + 1), ...restored];
}
useQueueStore.getState().setSnapshot(mirroredQueue, activeIndex);
store.setShuffle(next);
useQueueStore.getState().setSnapshot(mirroredQueue, activeIndex, { transport: null });
} catch (error) {
store.setShuffle(!next);
throw error;
}
}
/** Insert a track right after the current one ("Play next"). */
@@ -1127,8 +1324,8 @@ export async function jumpToQueueIndex(
await AstraLibraryData.updatePlaybackPosition(context.sessionId, bounded);
const window = await AstraLibraryData.getPlaybackWindow<DbTrack>(
context.sessionId,
Math.max(0, bounded - 25),
226,
virtualPlaybackWindowStart(bounded),
TRANSPORT_WINDOW_SIZE,
);
await startVirtualWindow(
window,
+4
View File
@@ -4,6 +4,7 @@ import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
import { applyNormalizationForActiveTrack } from './applyNormalization';
import { startAudioProcessingWarmup } from './audioProcessingStartup';
import {
handleVirtualQueueEnded,
handleVirtualPlaybackAdvance,
playForCar,
skipToNext,
@@ -112,6 +113,9 @@ export async function PlaybackService(): Promise<void> {
});
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, ({ position }) => {
handleListeningQueueEnded(position);
void handleVirtualQueueEnded().catch((error) => {
console.warn('[playback] virtual queue end recovery failed', error);
});
if (useSleepTimerStore.getState().timer?.mode !== 'end-of-track') return;
void TrackPlayer.getProgress()
.then(({ position, duration }) => useSleepTimerStore.getState().reconcileEndOfTrack(position, duration, false))
+21 -44
View File
@@ -7,26 +7,17 @@ import {
/**
* Chunked feeder for RNTP's native queue. Loading a long context in one
* setQueue/add stalls the Android main thread for seconds (per-track Bundle
* Track MediaSource construction), so playback starts from a small first
* chunk and the rest streams in behind it: the upcoming tail first (it plays
* next), then the head prepended in reverse chunk order. The JS queue mirror
* holds the full context from the start; while the head is still missing,
* native indices trail absolute (mirror) indices by `headRemaining`.
* Track MediaSource construction), so playback starts from history + current
* + eight upcoming rows and the rest streams in behind it. We never prepend:
* native indices stay stable for the lifetime of a transport window.
*/
// The first chunk's setQueue lands on the Android main thread at the exact
// moment of the play tap, so it stays tiny. Each background add() also occupies
// the main thread (= the UI thread) for time proportional to its size, so the
// chunks stay small with generous yields — a longer total fill is invisible,
// per-chunk frame drops are not.
const FIRST_CHUNK = 12;
const CHUNK = 50;
const YIELD_MS = 64;
const INITIAL_UPCOMING = 8;
const CHUNK = 8;
const YIELD_MS = 16;
interface QueueLoad {
generation: number;
/** Head tracks not yet prepended: absolute = native + headRemaining. */
headRemaining: number;
/** Tracks currently in the native queue (per this loader's bookkeeping). */
loadedCount: number;
settled: Promise<void>;
@@ -54,19 +45,15 @@ export function queueLoadSettled(): Promise<void> {
return load ? load.settled : Promise.resolve();
}
/** Map a native RNTP queue index to an absolute (full-queue mirror) index. */
/** RNTP and the JS transport mirror share one stable local index space. */
export function nativeIndexToAbsolute(nativeIndex: number): number {
return load ? nativeIndex + load.headRemaining : nativeIndex;
return nativeIndex;
}
/**
* Map an absolute index to its native index, or null while that part of the
* queue has not been loaded yet.
*/
/** Map a local mirror index to RNTP, or null until its append has landed. */
export function absoluteIndexToNative(absoluteIndex: number): number | null {
if (!load) return absoluteIndex;
const nativeIndex = absoluteIndex - load.headRemaining;
return nativeIndex >= 0 && nativeIndex < load.loadedCount ? nativeIndex : null;
return absoluteIndex >= 0 && absoluteIndex < load.loadedCount ? absoluteIndex : null;
}
function sleep(ms: number): Promise<void> {
@@ -81,7 +68,7 @@ async function supersedePreviousLoad(): Promise<number> {
return gen;
}
function beginLoad(gen: number, headRemaining: number, loadedCount: number): QueueLoad {
function beginLoad(gen: number, loadedCount: number): QueueLoad {
let resolveSettled!: () => void;
let resolveLoopDone!: () => void;
const settled = new Promise<void>((resolve) => {
@@ -92,7 +79,6 @@ function beginLoad(gen: number, headRemaining: number, loadedCount: number): Que
});
const next: QueueLoad = {
generation: gen,
headRemaining,
loadedCount,
settled,
resolveSettled,
@@ -123,21 +109,26 @@ export async function loadQueueChunked(
const gen = await supersedePreviousLoad();
if (gen !== generation) return;
const current = beginLoad(gen, startIndex, 0);
const current = beginLoad(gen, 0);
const manualTransition = markManualRecentPlayTransition(
options.manualTransitionFromPath,
);
try {
const first = tracks.slice(startIndex, startIndex + FIRST_CHUNK);
const firstEnd = Math.min(
tracks.length,
Math.max(startIndex + 1, startIndex + 1 + INITIAL_UPCOMING),
);
const first = tracks.slice(0, firstEnd);
await TrackPlayer.setQueue(first);
current.loadedCount = first.length;
if (startIndex > 0) await TrackPlayer.skip(startIndex);
} catch (err) {
cancelManualRecentPlayTransition(manualTransition);
finishLoad(current, false);
throw err;
}
void fillRemainder(current, tracks, startIndex);
void fillRemainder(current, tracks);
}
/**
@@ -150,7 +141,7 @@ export async function appendUpcomingChunked(tracks: RntpTrack[], baseCount: numb
const gen = await supersedePreviousLoad();
if (gen !== generation || tracks.length === 0) return;
const current = beginLoad(gen, 0, baseCount);
const current = beginLoad(gen, baseCount);
try {
const first = tracks.slice(0, CHUNK);
await TrackPlayer.add(first);
@@ -180,24 +171,10 @@ async function fillTail(current: QueueLoad, tracks: RntpTrack[], fromIndex: numb
async function fillRemainder(
current: QueueLoad,
tracks: RntpTrack[],
startIndex: number,
): Promise<void> {
let failed = false;
try {
// Tail first — it's what plays next.
await fillTail(current, tracks, startIndex + current.loadedCount);
// Head second, prepended in reverse chunk order so [0..startIndex) ends up
// in original order and `absolute = native + headRemaining` holds throughout.
for (let end = startIndex; end > 0; end -= CHUNK) {
await sleep(YIELD_MS);
if (current.generation !== generation) return;
const begin = Math.max(0, end - CHUNK);
const chunk = tracks.slice(begin, end);
await TrackPlayer.add(chunk, 0);
current.headRemaining = begin;
current.loadedCount += chunk.length;
}
await fillTail(current, tracks, current.loadedCount);
} catch {
failed = true;
} finally {
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
VIRTUAL_PLAYBACK_APPEND_BATCH,
VIRTUAL_PLAYBACK_HISTORY,
VIRTUAL_PLAYBACK_REFILL_THRESHOLD,
VIRTUAL_PLAYBACK_UPCOMING,
VIRTUAL_PLAYBACK_WINDOW_SIZE,
shouldRefillVirtualPlayback,
virtualPlaybackRefillLimit,
virtualPlaybackTrimCount,
virtualPlaybackWindowStart,
} from './virtualPlaybackWindow.ts';
test('uses the 8 history + current + 32 upcoming transport window', () => {
assert.equal(VIRTUAL_PLAYBACK_HISTORY, 8);
assert.equal(VIRTUAL_PLAYBACK_UPCOMING, 32);
assert.equal(VIRTUAL_PLAYBACK_WINDOW_SIZE, 41);
assert.equal(VIRTUAL_PLAYBACK_APPEND_BATCH, 8);
assert.equal(virtualPlaybackWindowStart(8), 0);
assert.equal(virtualPlaybackWindowStart(40), 32);
});
test('trims history without changing the logical queue position', () => {
assert.equal(virtualPlaybackTrimCount(8), 0);
assert.equal(virtualPlaybackTrimCount(9), 1);
assert.equal(virtualPlaybackTrimCount(20), 12);
});
test('refills only below sixteen upcoming and caps coverage at thirty-two', () => {
assert.equal(VIRTUAL_PLAYBACK_REFILL_THRESHOLD, 16);
assert.equal(shouldRefillVirtualPlayback(16, 100, 200), false);
assert.equal(shouldRefillVirtualPlayback(15, 100, 200), true);
assert.equal(shouldRefillVirtualPlayback(0, 200, 200), false);
assert.equal(virtualPlaybackRefillLimit(15, 100, 200), 17);
assert.equal(virtualPlaybackRefillLimit(0, 190, 200), 10);
assert.equal(virtualPlaybackRefillLimit(31, 199, 200), 1);
assert.equal(virtualPlaybackRefillLimit(0, 200, 200), 0);
});
+40
View File
@@ -0,0 +1,40 @@
/**
* RNTP is a bounded transport, never the source of truth for a library queue.
* Room owns complete order while these helpers keep local indices stable.
*/
export const VIRTUAL_PLAYBACK_HISTORY = 8;
export const VIRTUAL_PLAYBACK_UPCOMING = 32;
export const VIRTUAL_PLAYBACK_WINDOW_SIZE =
VIRTUAL_PLAYBACK_HISTORY + 1 + VIRTUAL_PLAYBACK_UPCOMING;
export const VIRTUAL_PLAYBACK_REFILL_THRESHOLD = 16;
export const VIRTUAL_PLAYBACK_APPEND_BATCH = 8;
export function virtualPlaybackWindowStart(activePosition: number): number {
return Math.max(0, Math.floor(activePosition) - VIRTUAL_PLAYBACK_HISTORY);
}
export function virtualPlaybackTrimCount(activeLocalIndex: number): number {
return Math.max(0, Math.floor(activeLocalIndex) - VIRTUAL_PLAYBACK_HISTORY);
}
export function shouldRefillVirtualPlayback(
upcomingCount: number,
loadedEnd: number,
totalCount: number,
): boolean {
return upcomingCount < VIRTUAL_PLAYBACK_REFILL_THRESHOLD && loadedEnd < totalCount;
}
export function virtualPlaybackRefillLimit(
upcomingCount: number,
loadedEnd: number,
totalCount: number,
): number {
return Math.max(
0,
Math.min(
VIRTUAL_PLAYBACK_UPCOMING - Math.max(0, upcomingCount),
totalCount - loadedEnd,
),
);
}
@@ -5,7 +5,7 @@ import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { seekTo } from '@/audio/playbackController';
import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import { createThemedStyles, useColors } from '@/theme/themed';
import {
getNowPlayingTrackTransitionKey,
NowPlayingTrackFadeThrough,
@@ -14,6 +14,10 @@ import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { NowPlayingCompanion } from './nowPlayingPreferences';
import type { Track } from '@/types/audio';
import {
AstraQueueView,
toNativeQueuePalette,
} from '../../../modules/astra-library-scanner';
const COMPANION_SEGMENTS = [
{ key: 'queue', label: 'Queue' },
@@ -35,7 +39,9 @@ export function NowPlayingCompanionPane({
track,
}: NowPlayingCompanionPaneProps) {
const styles = useStyles();
const colors = useColors();
const companion = useSettingsStore((s) => s.nowPlayingCompanion);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion);
const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0));
const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration));
@@ -68,7 +74,15 @@ export function NowPlayingCompanionPane({
</View>
<View style={styles.content}>
{companion === 'queue' ? (
nativeQueueEnabled ? (
<AstraQueueView
active={active}
palette={toNativeQueuePalette(colors)}
style={styles.nativeQueue}
/>
) : (
<QueueTray embedded onClose={noop} />
)
) : track ? (
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
@@ -113,4 +127,8 @@ const useStyles = createThemedStyles((colors) => ({
minHeight: 0,
position: 'relative',
},
nativeQueue: {
flex: 1,
minHeight: 0,
},
}));
+55 -3
View File
@@ -108,12 +108,18 @@ import { useThemeStore } from '@/stores/themeStore';
import type { DbTrack } from '@/types/library';
import {
cycleRepeat,
jumpToQueueIndex,
seekTo,
skipToNext,
skipToPrevious,
synchronizeVirtualQueueRevision,
togglePlay,
toggleShuffle
} from '@/audio/playbackController';
import {
AstraQueue,
toNativeQueuePalette,
} from '../../../modules/astra-library-scanner';
import {
desktopConnectionLabel,
getDesktopPlaybackPresentation,
@@ -174,6 +180,7 @@ export function NowPlayingOverlay() {
const nowPlayingAccentSource = useThemeStore((s) => s.nowPlayingAccentSource);
const coverArtAccentMethod = useThemeStore((s) => s.coverArtAccentMethod);
const scopeMode = useSettingsStore((s) => s.scopeMode);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle);
@@ -378,6 +385,12 @@ export function NowPlayingOverlay() {
}
suspendPanForChildTransition();
setQueueOpen(true);
if (nativeQueueEnabled && !isDesktopTarget) {
void AstraQueue.present({ palette: toNativeQueuePalette(colors) }).catch((error) => {
console.warn('[queue] native presentation failed', error);
setQueueOpen(false);
});
}
};
const swapScopeMode = () =>
@@ -742,10 +755,49 @@ export function NowPlayingOverlay() {
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => {
suspendPanForChildTransition();
if (nativeQueueEnabled && !isDesktopTarget) AstraQueue.dismiss();
setQueueOpen(false);
// Shared values and the state setter remain stable for this overlay mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [isDesktopTarget, nativeQueueEnabled]);
useEffect(() => {
if (!nativeQueueEnabled) return undefined;
const dismissed = AstraQueue.addListener('onDismissed', () => {
setQueueOpen(false);
});
const playbackRequest = AstraQueue.addListener('onPlaybackRequest', (request) => {
void (async () => {
try {
const position = await AstraQueue.resolveEntryPosition(
request.entryId,
request.queueRevision,
);
if (position == null) {
throw new Error('The queue changed. Try that song again.');
}
await jumpToQueueIndex(position, { virtualPosition: true });
AstraQueue.resolvePlaybackRequest(request.requestId, true);
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not play that song';
AstraQueue.resolvePlaybackRequest(request.requestId, false, message);
}
})();
});
const revision = AstraQueue.addListener('onQueueRevision', (event) => {
void synchronizeVirtualQueueRevision(
event.queueRevision,
event.activePosition,
).catch((error) => {
console.warn('[queue] transport revision sync failed', error);
});
});
return () => {
dismissed.remove();
playbackRequest.remove();
revision.remove();
};
}, [nativeQueueEnabled]);
// Hardware back, innermost layer first: menu → queue tray → player. Registered
// only while open, so it sits above the focused screen's own handlers (LIFO)
@@ -1692,9 +1744,9 @@ export function NowPlayingOverlay() {
{queueOpen && !hasTabletCompanion && (
isDesktopTarget ? (
<RemoteQueueSheet onClose={closeQueue} />
) : (
) : !nativeQueueEnabled ? (
<QueueTray onClose={closeQueue} />
)
) : null
)}
<PlaybackTargetPicker
visible={targetPickerOpen}
+90 -14
View File
@@ -6,6 +6,20 @@ import type { PlaybackSource } from '@/types/audio';
interface QueueSnapshotOptions {
/** Omit to retain the current queue source; pass null when clearing playback. */
source?: PlaybackSource | null;
/** Omit to retain transport metadata; pass null for an ordinary RNTP queue. */
transport?: {
sessionId: string;
queueRevision: number;
windowStart: number;
} | null;
}
export interface QueueTransportState {
sessionId: string;
queueRevision: number;
windowStart: number;
activeLocalIndex: number;
tracks: RntpTrack[];
}
/**
@@ -18,6 +32,7 @@ interface QueueStore {
activeIndex: number;
hasSnapshot: boolean;
source: PlaybackSource | null;
transport: QueueTransportState | null;
refreshFromNative: () => Promise<void>;
refreshActiveIndex: () => Promise<void>;
setSnapshot: (
@@ -47,53 +62,100 @@ export const useQueueStore = create<QueueStore>((set) => ({
activeIndex: -1,
hasSnapshot: false,
source: null,
transport: null,
refreshFromNative: async () => {
// Mid chunked-load the native queue is partial and index-shifted — wait it out.
// Mid chunked-load the native queue is partial — wait until all appends land.
await queueLoadSettled();
const [tracks, activeIndex] = await Promise.all([
TrackPlayer.getQueue(),
TrackPlayer.getActiveTrackIndex(),
]);
set({
set((state) => {
const normalized = normalizeActiveIndex(activeIndex, tracks.length);
return {
tracks,
activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
activeIndex: normalized,
hasSnapshot: true,
transport: state.transport
? { ...state.transport, activeLocalIndex: normalized, tracks }
: null,
};
});
},
refreshActiveIndex: async () => {
const activeIndex = await TrackPlayer.getActiveTrackIndex();
set((s) => ({
activeIndex: normalizeActiveIndex(
set((s) => {
const normalized = normalizeActiveIndex(
activeIndex == null ? activeIndex : nativeIndexToAbsolute(activeIndex),
s.tracks.length,
),
}));
);
return {
activeIndex: normalized,
transport: s.transport
? { ...s.transport, activeLocalIndex: normalized, tracks: s.tracks }
: null,
};
});
},
setSnapshot: (tracks, activeIndex = 0, options) =>
set((state) => ({
set((state) => {
const normalized = normalizeActiveIndex(activeIndex, tracks.length);
const transport = options && Object.hasOwn(options, 'transport')
? options.transport
? {
...options.transport,
activeLocalIndex: normalized,
tracks,
activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
}
: null
: state.transport
? { ...state.transport, activeLocalIndex: normalized, tracks }
: null;
return {
tracks,
activeIndex: normalized,
hasSnapshot: true,
source: options && Object.hasOwn(options, 'source')
? options.source ?? null
: state.source,
})),
transport,
};
}),
setActiveIndex: (activeIndex) =>
set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) })),
set((s) => {
const normalized = normalizeActiveIndex(activeIndex, s.tracks.length);
return {
activeIndex: normalized,
transport: s.transport
? { ...s.transport, activeLocalIndex: normalized, tracks: s.tracks }
: null,
};
}),
insertTrack: (track, index) =>
set((s) => {
const insertAt = boundedInsertIndex(index, s.tracks.length);
const tracks = [...s.tracks];
tracks.splice(insertAt, 0, track);
const activeIndex = s.activeIndex >= insertAt ? s.activeIndex + 1 : s.activeIndex;
return { tracks, activeIndex, hasSnapshot: true };
return {
tracks,
activeIndex,
hasSnapshot: true,
transport: s.transport
? { ...s.transport, activeLocalIndex: activeIndex, tracks }
: null,
};
}),
replaceUpcoming: (upcoming) =>
set((s) => {
const prefixEnd = s.activeIndex >= 0 ? s.activeIndex + 1 : 0;
const tracks = [...s.tracks.slice(0, prefixEnd), ...upcoming];
return {
tracks: [...s.tracks.slice(0, prefixEnd), ...upcoming],
tracks,
hasSnapshot: true,
transport: s.transport
? { ...s.transport, tracks }
: null,
};
}),
moveItem: (fromIndex, toIndex) =>
@@ -114,7 +176,14 @@ export const useQueueStore = create<QueueStore>((set) => ({
activeIndex += 1;
}
return { tracks, activeIndex, hasSnapshot: true };
return {
tracks,
activeIndex,
hasSnapshot: true,
transport: s.transport
? { ...s.transport, activeLocalIndex: activeIndex, tracks }
: null,
};
}),
removeIndices: (indices) =>
set((s) => {
@@ -143,6 +212,13 @@ export const useQueueStore = create<QueueStore>((set) => ({
tracks,
activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
hasSnapshot: true,
transport: s.transport
? {
...s.transport,
activeLocalIndex: normalizeActiveIndex(activeIndex, tracks.length),
tracks,
}
: null,
};
}),
}));
+15
View File
@@ -29,6 +29,7 @@ const LYRICS_VISIBLE_KEY = 'lyrics_visible';
const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion';
const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode';
const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled';
const NATIVE_QUEUE_ENABLED_KEY = 'native_queue_enabled';
/** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope';
@@ -68,6 +69,7 @@ interface SettingsStore {
nowPlayingCompanion: NowPlayingCompanion;
homeGreetingTextMode: HomeGreetingTextMode;
listeningHistoryEnabled: boolean;
nativeQueueEnabled: boolean;
loaded: boolean;
load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
@@ -79,6 +81,7 @@ interface SettingsStore {
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
setNativeQueueEnabled: (enabled: boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -91,6 +94,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingCompanion: 'queue',
homeGreetingTextMode: 'messages',
listeningHistoryEnabled: true,
nativeQueueEnabled: false,
loaded: false,
load: async () => {
@@ -106,6 +110,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
NOW_PLAYING_COMPANION_KEY,
HOME_GREETING_TEXT_MODE_KEY,
LISTENING_HISTORY_ENABLED_KEY,
NATIVE_QUEUE_ENABLED_KEY,
]);
const grouping = values[ARTIST_GROUPING_KEY] ?? null;
const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null;
@@ -116,6 +121,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null;
const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null;
const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0';
const nativeQueueEnabled = parseBoolean(values[NATIVE_QUEUE_ENABLED_KEY] ?? null);
set({
artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles),
@@ -126,6 +132,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
listeningHistoryEnabled,
nativeQueueEnabled,
loaded: true,
});
},
@@ -194,4 +201,12 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
throw error;
}
},
setNativeQueueEnabled: async (enabled) => {
if (get().nativeQueueEnabled === enabled) return;
set({ nativeQueueEnabled: enabled });
await AstraLibraryData.setSettings({
[NATIVE_QUEUE_ENABLED_KEY]: enabled ? 'true' : 'false',
});
},
}));