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' group = 'expo.modules.astralibraryscanner'
version = '0.1.0' 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 { android {
namespace "expo.modules.astralibraryscanner" namespace "expo.modules.astralibraryscanner"
defaultConfig { defaultConfig {
@@ -23,12 +33,17 @@ android {
} }
sourceSets { sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas") androidTest.assets.srcDirs += files("$projectDir/schemas")
main.assets.srcDir(queueFontAssetsDir)
} }
lintOptions { lintOptions {
abortOnError false abortOnError false
} }
} }
tasks.named("preBuild").configure {
dependsOn(prepareQueueFontAssets)
}
dependencies { dependencies {
// ExoPlayer 2.19.0 (same version the vendored kotlin-audio fork pulls in) for // ExoPlayer 2.19.0 (same version the vendored kotlin-audio fork pulls in) for
// MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without // 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 'com.google.android.exoplayer:exoplayer-core:2.19.0'
implementation 'androidx.room:room-runtime:2.8.4' implementation 'androidx.room:room-runtime:2.8.4'
implementation 'androidx.room:room-ktx: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' ksp 'androidx.room:room-compiler:2.8.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
@@ -208,6 +208,69 @@ class RoomLibraryRepositoryTest {
assertEquals(2L, dao.countQueueEntries(session.id)) 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 @Test
fun playbackWindowNeverClampsPastTheEndBackToTheLastTrack() { fun playbackWindowNeverClampsPastTheEndBackToTheLastTrack() {
assertEquals(0L, boundedPlaybackWindowStart(-10, 3)) assertEquals(0L, boundedPlaybackWindowStart(-10, 3))
@@ -104,6 +104,83 @@ class UserMigrationTest {
assertTrue("listening_segments" in tables) 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 = private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
this.query(query).use { cursor -> this.query(query).use { cursor ->
assertTrue(cursor.moveToFirst()) 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.content.Context
import android.database.sqlite.SQLiteDatabaseCorruptException import android.database.sqlite.SQLiteDatabaseCorruptException
import android.database.sqlite.SQLiteException import android.database.sqlite.SQLiteException
import android.os.Build
import android.os.Trace
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import java.io.File import java.io.File
@@ -12,6 +14,7 @@ import java.text.Normalizer
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicInteger
import kotlin.random.Random import kotlin.random.Random
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll 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 CUTOVER_COMPLETE = "room-cutover-v1-complete"
private const val SNAPSHOT_DEBOUNCE_MS = 2_000L private const val SNAPSHOT_DEBOUNCE_MS = 2_000L
private const val MOBILE_SESSION_ID = "mobile" 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") class StaleRevisionException : IllegalStateException("STALE_REVISION")
internal class ScanCancelledException : IllegalStateException("SCAN_CANCELLED") internal class ScanCancelledException : IllegalStateException("SCAN_CANCELLED")
@@ -59,6 +81,11 @@ private data class RemoteSyncHandle(
val seenPaths: MutableSet<String> = ConcurrentHashMap.newKeySet(), 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— * Single owner for both Room files. Every app surface—including Android Auto—
* reaches SQLite through this repository so connection and recovery policy * reaches SQLite through this repository so connection and recovery policy
@@ -915,11 +942,25 @@ class AstraLibraryRepository private constructor(
initialize() initialize()
val catalogDao = requireCatalog().catalogDao() val catalogDao = requireCatalog().catalogDao()
val userDao = requireUser().userDao() val userDao = requireUser().userDao()
val paths = resolvePlaybackPaths(context, catalogDao, userDao) val contextKind = context["kind"] as? String ?: "library"
val availablePaths = filterAvailablePaths(paths, catalogDao) 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 seed = requestedSeed ?: System.currentTimeMillis()
val ordered = availablePaths.toMutableList() val original = availablePaths.mapIndexed { index, path ->
var activePosition = anchorPath?.let(ordered::indexOf)?.takeIf { it >= 0 } ?: 0 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) { if (shuffle && ordered.size > 1) {
val anchor = ordered.getOrNull(activePosition) val anchor = ordered.getOrNull(activePosition)
if (anchor != null) ordered.removeAt(activePosition) if (anchor != null) ordered.removeAt(activePosition)
@@ -928,36 +969,80 @@ class AstraLibraryRepository private constructor(
activePosition = 0 activePosition = 0
} }
val now = System.currentTimeMillis() 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( userDao.replacePlaybackQueue(
PlaybackSessionEntity( PlaybackSessionEntity(
id = ACTIVE_PLAYBACK_CONTEXT_ID, id = ACTIVE_PLAYBACK_CONTEXT_ID,
contextJson = org.json.JSONObject(context).toString(), contextJson = persistedContextJson,
anchorPath = ordered.getOrNull(activePosition), anchorPath = ordered.getOrNull(activePosition)?.trackPath,
shuffleSeed = if (shuffle) seed else null, shuffleSeed = if (shuffle) seed else null,
activePosition = activePosition.toLong(), activePosition = activePosition.toLong(),
createdAt = now, createdAt = now,
updatedAt = 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( PlaybackQueueEntryEntity(
sessionId = ACTIVE_PLAYBACK_CONTEXT_ID, sessionId = ACTIVE_PLAYBACK_CONTEXT_ID,
position = index.toLong(), position = index.toLong(),
trackPath = path, trackPath = item.trackPath,
entryId = item.entryId,
) )
}, },
availablePaths.mapIndexed { index, path -> if (shuffle) {
original.mapIndexed { index, item ->
PlaybackOriginalQueueEntryEntity( PlaybackOriginalQueueEntryEntity(
sessionId = ACTIVE_PLAYBACK_CONTEXT_ID, sessionId = ACTIVE_PLAYBACK_CONTEXT_ID,
position = index.toLong(), position = index.toLong(),
trackPath = path, trackPath = item.trackPath,
entryId = item.entryId,
) )
}
} else {
emptyList()
}, },
) )
}
scheduleSnapshot() scheduleSnapshot()
return playbackWindow( return playbackWindow(
ACTIVE_PLAYBACK_CONTEXT_ID, ACTIVE_PLAYBACK_CONTEXT_ID,
(activePosition - 25).coerceAtLeast(0).toLong(), (activePosition - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong(),
226, PLAYBACK_WINDOW_SIZE,
) )
} }
@@ -978,7 +1063,6 @@ class AstraLibraryRepository private constructor(
val bounded = activePosition.coerceIn(0, total - 1) val bounded = activePosition.coerceIn(0, total - 1)
val anchor = dao.getQueueWindow(sessionId, bounded, 1).firstOrNull()?.trackPath val anchor = dao.getQueueWindow(sessionId, bounded, 1).firstOrNull()?.trackPath
dao.updatePlaybackPosition(sessionId, bounded, anchor, System.currentTimeMillis()) dao.updatePlaybackPosition(sessionId, bounded, anchor, System.currentTimeMillis())
scheduleSnapshot()
} }
suspend fun restorePlaybackContext(): Map<String, Any?>? { suspend fun restorePlaybackContext(): Map<String, Any?>? {
@@ -1006,20 +1090,23 @@ class AstraLibraryRepository private constructor(
val normalized = retained.mapIndexed { index, row -> val normalized = retained.mapIndexed { index, row ->
row.copy(position = index.toLong()) row.copy(position = index.toLong())
} }
val retainedIds = normalized.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
val original = dao.getOriginalQueueEntries(session.id) 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()) } .mapIndexed { index, row -> row.copy(position = index.toLong()) }
database.userDao().replacePlaybackQueue( database.userDao().replacePlaybackQueue(
session.copy( session.copy(
anchorPath = normalized[activeIndex].trackPath, anchorPath = normalized[activeIndex].trackPath,
activePosition = activeIndex.toLong(), activePosition = activeIndex.toLong(),
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
queueRevision = session.queueRevision + 1,
isDirty = true,
), ),
normalized, normalized,
original, original,
) )
val start = (activeIndex - 25).coerceAtLeast(0).toLong() val start = (activeIndex - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong()
return playbackWindow(session.id, start, 226) return playbackWindow(session.id, start, PLAYBACK_WINDOW_SIZE)
} }
suspend fun mutatePlaybackContext( suspend fun mutatePlaybackContext(
@@ -1030,18 +1117,27 @@ class AstraLibraryRepository private constructor(
val database = requireUser() val database = requireUser()
val dao = database.userDao() val dao = database.userDao()
val session = dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID) ?: return null 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 if (current.isEmpty()) return null
val originalRows = dao.getOriginalQueueEntries(session.id) 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() .toMutableList()
var active = session.activePosition.toInt().coerceIn(current.indices) 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) { fun move(items: MutableList<OrderedQueueItem>, from: Int, to: Int) {
if (from !in paths.indices || to !in paths.indices || from == to) return if (from !in items.indices || to !in items.indices || from == to) return
val item = paths.removeAt(from) val item = items.removeAt(from)
paths.add(to.coerceIn(0, paths.size), item) items.add(to.coerceIn(0, items.size), item)
} }
when (operation) { when (operation) {
@@ -1055,16 +1151,19 @@ class AstraLibraryRepository private constructor(
} }
val paths = filterAvailablePaths(requested, requireCatalog().catalogDao()) val paths = filterAvailablePaths(requested, requireCatalog().catalogDao())
if (paths.isNotEmpty()) { if (paths.isNotEmpty()) {
val additions = paths.map { path ->
OrderedQueueItem(nextEntryId++, path)
}
val append = operation == "append" || operation == "appendQuery" val append = operation == "append" || operation == "appendQuery"
val insertAt = if (append) current.size else active + 1 val insertAt = if (append) current.size else active + 1
current.addAll(insertAt, paths) current.addAll(insertAt, additions)
val originalAnchor = original.indexOf(activePath) val originalAnchor = original.indexOfFirst { it.entryId == activeEntryId }
val originalInsert = if (append || originalAnchor < 0) { val originalInsert = if (append || originalAnchor < 0) {
original.size original.size
} else { } else {
originalAnchor + 1 originalAnchor + 1
} }
original.addAll(originalInsert, paths) original.addAll(originalInsert, additions)
} }
} }
"remove" -> { "remove" -> {
@@ -1077,7 +1176,9 @@ class AstraLibraryRepository private constructor(
for (position in positions) { for (position in positions) {
if (position !in current.indices || position == active) continue if (position !in current.indices || position == active) continue
val removed = current.removeAt(position) 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 if (position < active) active -= 1
} }
} }
@@ -1085,13 +1186,13 @@ class AstraLibraryRepository private constructor(
val from = (values["from"] as? Number)?.toInt() ?: -1 val from = (values["from"] as? Number)?.toInt() ?: -1
val to = (values["to"] 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) { if (from in current.indices && to in current.indices && from != active && to != active) {
val movedPath = current[from] val movedEntryId = current[from].entryId
val targetPath = current[to] val targetEntryId = current[to].entryId
move(current, from, to) move(current, from, to)
val originalFrom = original.indexOf(movedPath) val originalFrom = original.indexOfFirst { it.entryId == movedEntryId }
val originalTo = original.indexOf(targetPath) val originalTo = original.indexOfFirst { it.entryId == targetEntryId }
if (originalFrom >= 0 && originalTo >= 0) move(original, originalFrom, originalTo) if (originalFrom >= 0 && originalTo >= 0) move(original, originalFrom, originalTo)
active = current.indexOf(activePath).coerceAtLeast(0) active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
} }
} }
"moveManyAfterActive" -> { "moveManyAfterActive" -> {
@@ -1105,18 +1206,14 @@ class AstraLibraryRepository private constructor(
if (positions.isNotEmpty()) { if (positions.isNotEmpty()) {
val selected = positions.map(current::get) val selected = positions.map(current::get)
positions.asReversed().forEach { current.removeAt(it) } 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) current.addAll(active + 1, selected)
val selectedCounts = selected.groupingBy { it }.eachCount().toMutableMap() val selectedIds = selected.mapTo(hashSetOf(), OrderedQueueItem::entryId)
val remainingOriginal = original.filter { path -> val remainingOriginal = original
val count = selectedCounts[path] ?: 0 .filterNot { it.entryId in selectedIds }
if (count <= 0) true else { .toMutableList()
if (count == 1) selectedCounts.remove(path) else selectedCounts[path] = count - 1 val originalActive = remainingOriginal.indexOfFirst { it.entryId == activeEntryId }
false
}
}.toMutableList()
val originalActive = remainingOriginal.indexOf(activePath)
remainingOriginal.addAll( remainingOriginal.addAll(
if (originalActive >= 0) originalActive + 1 else 0, if (originalActive >= 0) originalActive + 1 else 0,
selected, selected,
@@ -1137,7 +1234,7 @@ class AstraLibraryRepository private constructor(
} else { } else {
current.clear() current.clear()
current.addAll(original) current.addAll(original)
active = current.indexOf(activePath).coerceAtLeast(0) active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
} }
} }
else -> error("Unknown playback context mutation.") else -> error("Unknown playback context mutation.")
@@ -1150,22 +1247,45 @@ class AstraLibraryRepository private constructor(
shuffleEnabled -> (values["seed"] as? Number)?.toLong() ?: now shuffleEnabled -> (values["seed"] as? Number)?.toLong() ?: now
else -> null else -> null
} }
dao.replacePlaybackQueue( traceAsyncSection("AstraQueue.roomMutation") {
dao.applyPlaybackQueueMutation(
session.copy( session.copy(
anchorPath = current.getOrNull(active), anchorPath = current.getOrNull(active)?.trackPath,
activePosition = active.toLong(), activePosition = active.toLong(),
shuffleSeed = nextSeed, shuffleSeed = nextSeed,
updatedAt = now, updatedAt = now,
queueRevision = session.queueRevision + 1,
isDirty = true,
nextEntryId = nextEntryId,
), ),
current.mapIndexed { index, path -> current.mapIndexed { index, item ->
PlaybackQueueEntryEntity(session.id, index.toLong(), path) PlaybackQueueEntryEntity(
sessionId = session.id,
position = index.toLong(),
trackPath = item.trackPath,
entryId = item.entryId,
)
}, },
original.mapIndexed { index, path -> if (nextSeed != null) {
PlaybackOriginalQueueEntryEntity(session.id, index.toLong(), path) original.mapIndexed { index, item ->
PlaybackOriginalQueueEntryEntity(
sessionId = session.id,
position = index.toLong(),
trackPath = item.trackPath,
entryId = item.entryId,
)
}
} else {
emptyList()
}, },
) )
}
scheduleSnapshot() 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 { suspend fun recordTrackPlayed(path: String): Boolean {
@@ -2450,6 +2570,7 @@ class AstraLibraryRepository private constructor(
val items = entries.mapNotNull { entry -> val items = entries.mapNotNull { entry ->
tracks[entry.trackPath]?.toBridgeMap()?.toMutableMap()?.apply { tracks[entry.trackPath]?.toBridgeMap()?.toMutableMap()?.apply {
this["queuePosition"] = entry.position.toDouble() this["queuePosition"] = entry.position.toDouble()
this["queueEntryId"] = entry.entryId.toDouble()
} }
} }
return mapOf( return mapOf(
@@ -2460,6 +2581,7 @@ class AstraLibraryRepository private constructor(
"totalCount" to total.toDouble(), "totalCount" to total.toDouble(),
"contextJson" to session.contextJson, "contextJson" to session.contextJson,
"shuffleSeed" to session.shuffleSeed?.toDouble(), "shuffleSeed" to session.shuffleSeed?.toDouble(),
"queueRevision" to session.queueRevision.toDouble(),
"catalogRevision" to requireCatalog().catalogDao().getRevision().toString(), "catalogRevision" to requireCatalog().catalogDao().getRevision().toString(),
) )
} }
@@ -2482,6 +2604,12 @@ class AstraLibraryRepository private constructor(
return requireUser() 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 { suspend fun catalogDb(): AstraCatalogDatabase {
initialize() initialize()
return requireCatalog() return requireCatalog()
@@ -2792,7 +2920,7 @@ class AstraLibraryRepository private constructor(
private fun buildUserDatabase(): AstraUserDatabase = private fun buildUserDatabase(): AstraUserDatabase =
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME) Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(USER_MIGRATION_1_2) .addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3)
.build() .build()
private fun buildCatalogDatabase(): AstraCatalogDatabase = private fun buildCatalogDatabase(): AstraCatalogDatabase =
@@ -11,6 +11,7 @@ import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
data class RemotePlaylistSyncPlan( data class RemotePlaylistSyncPlan(
val playlist: PlaylistEntity, val playlist: PlaylistEntity,
@@ -326,6 +327,9 @@ interface UserDao {
@Query("SELECT * FROM playback_sessions WHERE id = :id") @Query("SELECT * FROM playback_sessions WHERE id = :id")
suspend fun getPlaybackSession(id: String): PlaybackSessionEntity? 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") @Query("SELECT * FROM playback_sessions ORDER BY updated_at DESC LIMIT 1")
suspend fun getLatestPlaybackSession(): PlaybackSessionEntity? suspend fun getLatestPlaybackSession(): PlaybackSessionEntity?
@@ -372,12 +376,36 @@ interface UserDao {
@Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position") @Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position")
suspend fun getAllQueueEntries(sessionId: String): List<PlaybackQueueEntryEntity> 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 @Upsert
suspend fun putQueueEntries(entries: List<PlaybackQueueEntryEntity>) suspend fun putQueueEntries(entries: List<PlaybackQueueEntryEntity>)
@Query("DELETE FROM playback_queue_entries WHERE session_id = :sessionId") @Query("DELETE FROM playback_queue_entries WHERE session_id = :sessionId")
suspend fun clearQueueEntries(sessionId: String) 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") @Query("SELECT COUNT(*) FROM playback_queue_entries WHERE session_id = :sessionId")
suspend fun countQueueEntries(sessionId: String): Long suspend fun countQueueEntries(sessionId: String): Long
@@ -390,6 +418,27 @@ interface UserDao {
@Query("DELETE FROM playback_original_queue_entries WHERE session_id = :sessionId") @Query("DELETE FROM playback_original_queue_entries WHERE session_id = :sessionId")
suspend fun clearOriginalQueueEntries(sessionId: String) 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") @Query("SELECT * FROM snapshot_metadata WHERE id = 1")
suspend fun getSnapshotMetadata(): SnapshotMetadataEntity? suspend fun getSnapshotMetadata(): SnapshotMetadataEntity?
@@ -409,6 +458,88 @@ interface UserDao {
if (originalEntries.isNotEmpty()) putOriginalQueueEntries(originalEntries) 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 @Transaction
suspend fun replaceRemoteUserState( suspend fun replaceRemoteUserState(
sourceId: Long, sourceId: Long,
@@ -525,7 +656,7 @@ interface UserDao {
PlaybackOriginalQueueEntryEntity::class, PlaybackOriginalQueueEntryEntity::class,
SnapshotMetadataEntity::class, SnapshotMetadataEntity::class,
], ],
version = 2, version = 3,
exportSchema = true, exportSchema = true,
) )
abstract class AstraUserDatabase : RoomDatabase() { 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 = "active_position") val activePosition: Long,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: 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( @Entity(
tableName = "playback_queue_entries", tableName = "playback_queue_entries",
primaryKeys = ["session_id", "position"], primaryKeys = ["session_id", "entry_id"],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = PlaybackSessionEntity::class, entity = PlaybackSessionEntity::class,
@@ -240,6 +244,7 @@ data class PlaybackSessionEntity(
), ),
], ],
indices = [ indices = [
Index(value = ["session_id", "position"], unique = true),
Index(value = ["session_id", "track_path"]), Index(value = ["session_id", "track_path"]),
], ],
) )
@@ -247,11 +252,12 @@ data class PlaybackQueueEntryEntity(
@ColumnInfo(name = "session_id") val sessionId: String, @ColumnInfo(name = "session_id") val sessionId: String,
val position: Long, val position: Long,
@ColumnInfo(name = "track_path") val trackPath: String, @ColumnInfo(name = "track_path") val trackPath: String,
@ColumnInfo(name = "entry_id") val entryId: Long = position,
) )
@Entity( @Entity(
tableName = "playback_original_queue_entries", tableName = "playback_original_queue_entries",
primaryKeys = ["session_id", "position"], primaryKeys = ["session_id", "entry_id"],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = PlaybackSessionEntity::class, entity = PlaybackSessionEntity::class,
@@ -260,12 +266,16 @@ data class PlaybackQueueEntryEntity(
onDelete = ForeignKey.CASCADE, 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( data class PlaybackOriginalQueueEntryEntity(
@ColumnInfo(name = "session_id") val sessionId: String, @ColumnInfo(name = "session_id") val sessionId: String,
val position: Long, val position: Long,
@ColumnInfo(name = "track_path") val trackPath: String, @ColumnInfo(name = "track_path") val trackPath: String,
@ColumnInfo(name = "entry_id") val entryId: Long = position,
) )
@Entity(tableName = "snapshot_metadata") @Entity(tableName = "snapshot_metadata")
@@ -336,6 +336,10 @@ private fun PlaybackSessionEntity.toJson() = JSONObject()
.put("activePosition", activePosition) .put("activePosition", activePosition)
.put("createdAt", createdAt) .put("createdAt", createdAt)
.put("updatedAt", updatedAt) .put("updatedAt", updatedAt)
.put("queueRevision", queueRevision)
.putNullable("catalogRevision", catalogRevision)
.put("isDirty", isDirty)
.put("nextEntryId", nextEntryId)
private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity( private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity(
id = json.getString("id"), id = json.getString("id"),
@@ -345,26 +349,34 @@ private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity(
activePosition = json.getLong("activePosition"), activePosition = json.getLong("activePosition"),
createdAt = json.getLong("createdAt"), createdAt = json.getLong("createdAt"),
updatedAt = json.getLong("updatedAt"), 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() private fun PlaybackQueueEntryEntity.toJson() = JSONObject()
.put("sessionId", sessionId) .put("sessionId", sessionId)
.put("entryId", entryId)
.put("position", position) .put("position", position)
.put("trackPath", trackPath) .put("trackPath", trackPath)
private fun playbackQueueFromJson(json: JSONObject) = PlaybackQueueEntryEntity( private fun playbackQueueFromJson(json: JSONObject) = PlaybackQueueEntryEntity(
sessionId = json.getString("sessionId"), sessionId = json.getString("sessionId"),
entryId = json.optLong("entryId", json.getLong("position")),
position = json.getLong("position"), position = json.getLong("position"),
trackPath = json.getString("trackPath"), trackPath = json.getString("trackPath"),
) )
private fun PlaybackOriginalQueueEntryEntity.toJson() = JSONObject() private fun PlaybackOriginalQueueEntryEntity.toJson() = JSONObject()
.put("sessionId", sessionId) .put("sessionId", sessionId)
.put("entryId", entryId)
.put("position", position) .put("position", position)
.put("trackPath", trackPath) .put("trackPath", trackPath)
private fun playbackOriginalQueueFromJson(json: JSONObject) = PlaybackOriginalQueueEntryEntity( private fun playbackOriginalQueueFromJson(json: JSONObject) = PlaybackOriginalQueueEntryEntity(
sessionId = json.getString("sessionId"), sessionId = json.getString("sessionId"),
entryId = json.optLong("entryId", json.getLong("position")),
position = json.getLong("position"), position = json.getLong("position"),
trackPath = json.getString("trackPath"), 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": { "android": {
"modules": [ "modules": [
"expo.modules.astralibraryscanner.AstraLibraryScannerModule", "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> { export interface NativePlaybackWindow<T> {
sessionId: string; sessionId: string;
items: (T & { queuePosition: number })[]; items: (T & { queuePosition: number; queueEntryId: number })[];
windowStart: number; windowStart: number;
activePosition: number; activePosition: number;
totalCount: number; totalCount: number;
contextJson: string; contextJson: string;
shuffleSeed: number | null; shuffleSeed: number | null;
queueRevision: number;
catalogRevision: string; catalogRevision: string;
} }
@@ -532,3 +533,16 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
export const AstraLibraryData = export const AstraLibraryData =
requireNativeModule<AstraLibraryDataModuleType>('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", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "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: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: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", "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 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 --- 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 +++ 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 @@ -18,9 +18,11 @@ import com.doublesymmetry.trackplayer.utils.RejectionException
@@ -45,7 +45,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
val options = Arguments.toBundle(data) 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) callback.resolve(null)
} }
@@ -60,11 +60,20 @@ index b2409a0..4491bad 100644
- val tracks = readableArrayToTrackList(data); - val tracks = readableArrayToTrackList(data);
+ // Track conversion is O(queue) work (Bundle parsing, Uri resolution) — + // 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. + // 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) { if (insertBeforeIndex < -1 || insertBeforeIndex > musicService.tracks.size) {
callback.reject("index_out_of_bounds", "The track index is out of bounds") callback.reject("index_out_of_bounds", "The track index is out of bounds")
return@launch 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) rejectWithException(callback, exception)
} }
} }
@@ -76,7 +85,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
if (data == null) { if (data == null) {
callback.resolve(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") callback.reject("invalid_track_object", "Track was not a dictionary type")
} }
} }
@@ -97,7 +106,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
val inputIndexes = Arguments.toList(data) val inputIndexes = Arguments.toList(data)
if (inputIndexes != null) { if (inputIndexes != null) {
@@ -329,9 +340,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM @@ -329,9 +342,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
} }
callback.resolve(null) callback.resolve(null)
} }
@@ -109,7 +118,7 @@ index b2409a0..4491bad 100644
scope.launch { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@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) callback.resolve(null)
} }
} }
@@ -121,7 +130,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty()) 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) callback.resolve(null)
} }
@@ -133,7 +142,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty()) if (musicService.tracks.isEmpty())
@@ -373,17 +387,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM @@ -373,17 +389,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
musicService.clearNotificationMetadata() musicService.clearNotificationMetadata()
callback.resolve(null) callback.resolve(null)
} }
@@ -155,7 +164,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skip(index) musicService.skip(index)
@@ -394,9 +410,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM @@ -394,9 +412,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null) callback.resolve(null)
} }
@@ -167,7 +176,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToNext() musicService.skipToNext()
@@ -407,9 +424,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM @@ -407,9 +426,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null) callback.resolve(null)
} }
@@ -179,7 +188,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToPrevious() musicService.skipToPrevious()
@@ -420,9 +438,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM @@ -420,9 +440,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null) callback.resolve(null)
} }
@@ -191,7 +200,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
musicService.stop() musicService.stop()
@@ -431,188 +450,222 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM @@ -431,188 +452,224 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null) callback.resolve(null)
} }
@@ -374,10 +383,12 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
try { try {
+ val tracks = withContext(Dispatchers.Default) { readableArrayToTrackList(data) } + val tracks = withContext(Dispatchers.Default) {
+ readableArrayToTrackList(data).map { it.toAudioItem() }
+ }
musicService.clear() musicService.clear()
- musicService.add(readableArrayToTrackList(data)) - musicService.add(readableArrayToTrackList(data))
+ musicService.add(tracks) + musicService.addPrepared(tracks, 0)
callback.resolve(null) callback.resolve(null)
} catch (exception: Exception) { } catch (exception: Exception) {
rejectWithException(callback, exception) rejectWithException(callback, exception)
@@ -443,7 +454,7 @@ index b2409a0..4491bad 100644
if (verifyServiceBoundOrReject(callback)) return@launch if (verifyServiceBoundOrReject(callback)) return@launch
var bundle = Bundle() var bundle = Bundle()
bundle.putDouble("duration", musicService.getDurationInSeconds()); 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()); bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds());
callback.resolve(Arguments.fromBundle(bundle)) 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 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 --- 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 +++ 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) { + fun setPauseAtEndOfItem(enabled: Boolean) {
+ player.setPauseAtEndOfItem(enabled) + player.setPauseAtEndOfItem(enabled)
+ } + }
+ +
@@ -741,7 +745,7 @@ class MusicService : HeadlessJsTaskService() { @MainThread
fun stop() {
player.stop()
@@ -741,7 +773,7 @@ class MusicService : HeadlessJsTaskService() {
@MainThread @MainThread
private fun emit(event: String, data: Bundle? = null) { private fun emit(event: String, data: Bundle? = null) {
@@ -475,7 +558,7 @@ index afa6b0f..c6f01d5 100644
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(event, data?.let { Arguments.fromBundle(it) }) ?.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() val payload = Arguments.createArray()
data.forEach { payload.pushMap(Arguments.fromBundle(it)) } 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 { InteractionManager } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
SettingsCard,
SettingsNavRow, SettingsNavRow,
SettingsSectionLabel, SettingsSectionLabel,
SettingsSectionScreen, SettingsSectionScreen,
SettingsToggleRow,
} from '@/components/settings/SettingsSectionScaffold'; } from '@/components/settings/SettingsSectionScaffold';
import { formatRelativeTime } from '@/lib/format'; import { formatRelativeTime } from '@/lib/format';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { useSettingsStore } from '@/stores/settingsStore';
export default function ExperimentalSettingsScreen() { export default function ExperimentalSettingsScreen() {
const colors = useColors(); const colors = useColors();
@@ -20,6 +23,8 @@ export default function ExperimentalSettingsScreen() {
const desktopSyncStatus = useDesktopSyncStore((s) => s.status); const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt); const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length); const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const setNativeQueueEnabled = useSettingsStore((s) => s.setNativeQueueEnabled);
useEffect(() => { useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => { const task = InteractionManager.runAfterInteractions(() => {
@@ -44,7 +49,17 @@ export default function ExperimentalSettingsScreen() {
return ( return (
<SettingsSectionScreen title="Experimental"> <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 <SettingsNavRow
icon="phone-portrait-outline" icon="phone-portrait-outline"
title="Desktop Remote" title="Desktop Remote"
+233 -36
View File
@@ -42,6 +42,17 @@ import {
type NativePlaybackWindow, type NativePlaybackWindow,
} from '../../modules/astra-library-scanner'; } from '../../modules/astra-library-scanner';
import { dbTrackToTrack } from '@/library/trackAdapter'; 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 // If a background queue fill dies partway, the mirror no longer matches the
// native queue — re-read the truth. // native queue — re-read the truth.
@@ -62,11 +73,21 @@ let originalOrder: string[] | null = null;
let restoredMaterializationPromise: Promise<void> | null = null; let restoredMaterializationPromise: Promise<void> | null = null;
let virtualContext: { let virtualContext: {
sessionId: string; sessionId: string;
queueRevision: number;
windowStart: number; windowStart: number;
loadedEnd: number; loadedEnd: number;
totalCount: number; totalCount: number;
} | null = null; } | null = null;
let virtualRefillPromise: Promise<void> | 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 { export interface VirtualQueuePageItem {
track: RntpTrack; track: RntpTrack;
@@ -85,11 +106,12 @@ export interface PlaybackStartOptions {
} }
function toVirtualRntpTrack( function toVirtualRntpTrack(
item: DbTrack & { queuePosition: number }, item: DbTrack & { queuePosition: number; queueEntryId: number },
): RntpTrack { ): RntpTrack {
return { return {
...toRntpTrack(dbTrackToTrack(item)), ...toRntpTrack(dbTrackToTrack(item)),
astraQueuePosition: item.queuePosition, 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 { function mapRntpState(state?: State): PlaybackState {
switch (state) { switch (state) {
case State.Playing: case State.Playing:
@@ -143,6 +170,24 @@ function setOptimisticTrack(track: RntpTrack | undefined, playbackState?: Playba
if (playbackState) player.setPlaybackState(playbackState); 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> { async function reconcilePlayerFromNative(): Promise<void> {
try { try {
const [activeTrack, playbackState, progress] = await Promise.all([ 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 // Play. Rebuild every RNTP row from its stable Astra identity at the lazy
// materialization boundary so URL resolution is fresh. // materialization boundary so URL resolution is fresh.
const materializedTracks = queue.tracks.map((track) => toRntpTrack(rntpToTrack(track))); 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); if (player.currentTime > 0) player.setPendingSeek(player.currentTime);
await materializePlaybackQueue( await materializePlaybackQueue(
{ {
@@ -252,7 +303,7 @@ async function materializeRestoredSession(): Promise<void> {
{ {
loadQueue: loadQueueChunked, loadQueue: loadQueueChunked,
setRepeat: async (repeat) => { setRepeat: async (repeat) => {
await TrackPlayer.setRepeatMode(toRntpRepeat(repeat)); await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(repeat));
}, },
seek: (position) => TrackPlayer.seekTo(position), seek: (position) => TrackPlayer.seekTo(position),
} }
@@ -272,7 +323,7 @@ async function ensurePlayerReady(
): Promise<void> { ): Promise<void> {
await setupPlayer(options); await setupPlayer(options);
if (options.materializeRestored !== false) await materializeRestoredSession(); if (options.materializeRestored !== false) await materializeRestoredSession();
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat)); await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(usePlayerStore.getState().repeat));
} }
function discardPendingRestoredSession(): void { function discardPendingRestoredSession(): void {
@@ -316,7 +367,7 @@ export function restorePlaybackSession(
const player = usePlayerStore.getState(); const player = usePlayerStore.getState();
if (!session || session.tracks.length === 0) { if (!session || session.tracks.length === 0) {
originalOrder = null; originalOrder = null;
useQueueStore.getState().setSnapshot([], -1, { source: null }); useQueueStore.getState().setSnapshot([], -1, { source: null, transport: null });
player.reset(); player.reset();
player.setShuffle(false); player.setShuffle(false);
player.setRepeat('none'); player.setRepeat('none');
@@ -331,6 +382,7 @@ export function restorePlaybackSession(
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
useQueueStore.getState().setSnapshot(queueTracks, session.activeIndex, { useQueueStore.getState().setSnapshot(queueTracks, session.activeIndex, {
source: session.source, source: session.source,
transport: null,
}); });
player.setCurrentTrack(activeTrack); player.setCurrentTrack(activeTrack);
player.setProgress(session.position, activeTrack.duration); player.setProgress(session.position, activeTrack.duration);
@@ -361,14 +413,14 @@ export function restoreVirtualPlaybackContext(
); );
virtualContext = { virtualContext = {
sessionId: window.sessionId, sessionId: window.sessionId,
queueRevision: window.queueRevision,
windowStart: window.windowStart, windowStart: window.windowStart,
loadedEnd: window.items[window.items.length - 1].queuePosition + 1, loadedEnd: window.items[window.items.length - 1].queuePosition + 1,
totalCount: window.totalCount, totalCount: window.totalCount,
}; };
requestedQueueRevision = window.queueRevision;
originalOrder = session.shuffle ? null : tracks.map((track) => track.id); originalOrder = session.shuffle ? null : tracks.map((track) => track.id);
useQueueStore.getState().setSnapshot(queueTracks, activeIndex, { setVirtualQueueSnapshot(queueTracks, activeIndex, session.source);
source: session.source,
});
const activeTrack = tracks[activeIndex]; const activeTrack = tracks[activeIndex];
const player = usePlayerStore.getState(); const player = usePlayerStore.getState();
player.setCurrentTrack(activeTrack); player.setCurrentTrack(activeTrack);
@@ -414,8 +466,8 @@ export interface LibraryPlaybackStartOptions extends PlaybackStartOptions {
} }
/** /**
* Starts a native virtual library context. Only 25 previous + 200 upcoming * Starts a native virtual library context. Only eight historical, the current,
* tracks cross into JavaScript; the complete ordered path set remains in Room. * and 32 upcoming tracks cross into JavaScript; complete order remains in Room.
*/ */
export async function playLibraryQuery( export async function playLibraryQuery(
query: LibraryQuery, query: LibraryQuery,
@@ -450,17 +502,20 @@ async function startVirtualWindow(
); );
virtualContext = { virtualContext = {
sessionId: window.sessionId, sessionId: window.sessionId,
queueRevision: window.queueRevision,
windowStart: window.windowStart, windowStart: window.windowStart,
loadedEnd: window.items.length === 0 loadedEnd: window.items.length === 0
? window.windowStart ? window.windowStart
: window.items[window.items.length - 1].queuePosition + 1, : window.items[window.items.length - 1].queuePosition + 1,
totalCount: window.totalCount, totalCount: window.totalCount,
}; };
requestedQueueRevision = window.queueRevision;
await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(usePlayerStore.getState().repeat));
originalOrder = shuffle ? null : tracks.map((track) => track.id); originalOrder = shuffle ? null : tracks.map((track) => track.id);
usePlayerStore.getState().setShuffle(shuffle); usePlayerStore.getState().setShuffle(shuffle);
const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none'); const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none');
const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null;
useQueueStore.getState().setSnapshot(queueTracks, startIndex, { source }); setVirtualQueueSnapshot(queueTracks, startIndex, source);
setOptimisticTrack(queueTracks[startIndex], 'loading'); setOptimisticTrack(queueTracks[startIndex], 'loading');
try { try {
await prepareAudioProcessingForPlayback(playbackTarget, 'virtual-queue-play'); await prepareAudioProcessingForPlayback(playbackTarget, 'virtual-queue-play');
@@ -485,6 +540,47 @@ export function handleVirtualPlaybackAdvance(_nativeEventIndex?: number): Promis
return virtualRefillPromise; 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> { async function replenishVirtualContext(): Promise<void> {
const context = virtualContext; const context = virtualContext;
if (!context) return; if (!context) return;
@@ -496,8 +592,8 @@ async function replenishVirtualContext(): Promise<void> {
void AstraLibraryData.updatePlaybackPosition(context.sessionId, activePosition).catch(() => {}); void AstraLibraryData.updatePlaybackPosition(context.sessionId, activePosition).catch(() => {});
let localIndex = nativeIndex; let localIndex = nativeIndex;
if (localIndex > 25) { const removeCount = virtualPlaybackTrimCount(localIndex);
const removeCount = localIndex - 25; if (removeCount > 0) {
const indices = Array.from({ length: removeCount }, (_, index) => index); const indices = Array.from({ length: removeCount }, (_, index) => index);
await TrackPlayer.remove(indices); await TrackPlayer.remove(indices);
useQueueStore.getState().removeIndices(indices); useQueueStore.getState().removeIndices(indices);
@@ -507,11 +603,21 @@ async function replenishVirtualContext(): Promise<void> {
const currentLength = useQueueStore.getState().tracks.length; const currentLength = useQueueStore.getState().tracks.length;
const upcoming = currentLength - localIndex - 1; 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>( const next = await AstraLibraryData.getPlaybackWindow<DbTrack>(
context.sessionId, context.sessionId,
context.loadedEnd, context.loadedEnd,
100, requested,
); );
if (virtualContext !== context || next.items.length === 0) return; if (virtualContext !== context || next.items.length === 0) return;
const additions = next.items const additions = next.items
@@ -524,12 +630,10 @@ async function replenishVirtualContext(): Promise<void> {
const nextLoadedEnd = Number(additions[additions.length - 1].astraQueuePosition) + 1; const nextLoadedEnd = Number(additions[additions.length - 1].astraQueuePosition) + 1;
if (!Number.isFinite(nextLoadedEnd) || nextLoadedEnd <= context.loadedEnd) return; if (!Number.isFinite(nextLoadedEnd) || nextLoadedEnd <= context.loadedEnd) return;
const before = useQueueStore.getState(); const before = useQueueStore.getState();
await appendUpcomingChunked(additions, before.tracks.length); await appendTransportTracks(additions, before.tracks.length);
useQueueStore.getState().setSnapshot(
[...before.tracks, ...additions],
localIndex,
);
context.loadedEnd = Math.min(context.totalCount, nextLoadedEnd); 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. */ /** 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(): { export function getVirtualQueueState(): {
sessionId: string; sessionId: string;
queueRevision: number;
activePosition: number; activePosition: number;
totalCount: number; totalCount: number;
} | null { } | null {
@@ -571,11 +676,73 @@ export function getVirtualQueueState(): {
const localActive = useQueueStore.getState().activeIndex; const localActive = useQueueStore.getState().activeIndex;
return { return {
sessionId: context.sessionId, sessionId: context.sessionId,
queueRevision: context.queueRevision,
activePosition: context.windowStart + Math.max(0, localActive), activePosition: context.windowStart + Math.max(0, localActive),
totalCount: context.totalCount, 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> { async function adoptCurrentQueueAsVirtualContext(): Promise<boolean> {
if (virtualContext) return true; if (virtualContext) return true;
const snapshot = await getQueueSnapshot(); const snapshot = await getQueueSnapshot();
@@ -590,10 +757,12 @@ async function adoptCurrentQueueAsVirtualContext(): Promise<boolean> {
); );
virtualContext = { virtualContext = {
sessionId: window.sessionId, sessionId: window.sessionId,
queueRevision: window.queueRevision,
windowStart: window.activePosition - activeIndex, windowStart: window.activePosition - activeIndex,
loadedEnd: Math.min(window.totalCount, snapshot.queue.length), loadedEnd: Math.min(window.totalCount, snapshot.queue.length),
totalCount: window.totalCount, totalCount: window.totalCount,
}; };
requestedQueueRevision = window.queueRevision;
originalOrder = null; originalOrder = null;
return true; return true;
} }
@@ -632,19 +801,18 @@ async function mutateVirtualQueue(
const before = useQueueStore.getState(); const before = useQueueStore.getState();
const prefix = before.tracks.slice(0, boundedActive + 1); const prefix = before.tracks.slice(0, boundedActive + 1);
context.queueRevision = window.queueRevision;
await TrackPlayer.removeUpcomingTracks(); await TrackPlayer.removeUpcomingTracks();
if (upcoming.length > 0) { await appendTransportTracks(upcoming.slice(0, TRANSPORT_UPCOMING), prefix.length);
await appendUpcomingChunked(upcoming, prefix.length);
}
useQueueStore.getState().setSnapshot(
[...prefix, ...upcoming],
boundedActive,
);
context.windowStart = window.activePosition - boundedActive; context.windowStart = window.activePosition - boundedActive;
context.loadedEnd = upcoming.length > 0 context.loadedEnd = upcoming.length > 0
? window.activePosition + upcoming.length + 1 ? window.activePosition + Math.min(upcoming.length, TRANSPORT_UPCOMING) + 1
: window.activePosition + 1; : window.activePosition + 1;
context.totalCount = window.totalCount; context.totalCount = window.totalCount;
setVirtualQueueSnapshot(
[...prefix, ...upcoming.slice(0, TRANSPORT_UPCOMING)],
boundedActive,
);
return window; return window;
} }
@@ -684,6 +852,7 @@ async function playTracksInternal(
const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null;
useQueueStore.getState().setSnapshot(queueTracks, startIndex, { useQueueStore.getState().setSnapshot(queueTracks, startIndex, {
source: startOptions.source, source: startOptions.source,
transport: null,
}); });
setOptimisticTrack(queueTracks[startIndex], 'loading'); setOptimisticTrack(queueTracks[startIndex], 'loading');
try { try {
@@ -713,7 +882,7 @@ export async function shuffleTracks(
const queueTracks = shuffleArray(tracks).map(toRntpTrack); const queueTracks = shuffleArray(tracks).map(toRntpTrack);
const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none'); const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none');
const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; 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'); setOptimisticTrack(queueTracks[0], 'loading');
try { try {
await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play'); await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play');
@@ -743,13 +912,14 @@ export async function playSample(): Promise<void> {
originalOrder = SAMPLE_TRACKS.map((t) => t.id); originalOrder = SAMPLE_TRACKS.map((t) => t.id);
useQueueStore.getState().setSnapshot(sampleQueue, 0, { useQueueStore.getState().setSnapshot(sampleQueue, 0, {
source: { kind: 'sample', label: 'Astra Sample' }, source: { kind: 'sample', label: 'Astra Sample' },
transport: null,
}); });
setOptimisticTrack(sampleQueue[0], 'loading'); setOptimisticTrack(sampleQueue[0], 'loading');
} else { } else {
const activeIndex = await TrackPlayer.getActiveTrackIndex(); const activeIndex = await TrackPlayer.getActiveTrackIndex();
playbackTarget = dspTargetFromTrack(queue[activeIndex ?? 0], 'immediate'); playbackTarget = dspTargetFromTrack(queue[activeIndex ?? 0], 'immediate');
await prepareAudioProcessingForPlayback(playbackTarget, 'sample-resume'); await prepareAudioProcessingForPlayback(playbackTarget, 'sample-resume');
useQueueStore.getState().setSnapshot(queue, activeIndex); useQueueStore.getState().setSnapshot(queue, activeIndex, { transport: null });
setOptimisticTrack(queue[activeIndex ?? 0], 'loading'); setOptimisticTrack(queue[activeIndex ?? 0], 'loading');
} }
try { try {
@@ -825,6 +995,16 @@ export async function skipToNext(): Promise<void> {
]); ]);
const playbackStateAtIntent = mapRntpState(nativePlaybackState.state); const playbackStateAtIntent = mapRntpState(nativePlaybackState.state);
const resumeAfterSkip = shouldResumeAfterExplicitNext(playbackStateAtIntent); 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( const playbackTarget = dspTargetFromTrack(
nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1], nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1],
'none', 'none',
@@ -882,6 +1062,11 @@ export async function skipToPrevious(): Promise<void> {
TrackPlayer.getQueue(), TrackPlayer.getQueue(),
TrackPlayer.getActiveTrackIndex(), TrackPlayer.getActiveTrackIndex(),
]); ]);
const virtualState = getVirtualQueueState();
if (virtualState && nativeIndex === 0 && virtualState.activePosition > 0) {
await jumpToQueueIndex(virtualState.activePosition - 1, { virtualPosition: true });
return;
}
await prepareAudioProcessingForPlayback( await prepareAudioProcessingForPlayback(
dspTargetFromTrack( dspTargetFromTrack(
nativeIndex == null ? undefined : nativeQueue[nativeIndex - 1], nativeIndex == null ? undefined : nativeQueue[nativeIndex - 1],
@@ -914,7 +1099,7 @@ export async function cycleRepeat(): Promise<void> {
const next = NEXT_REPEAT[usePlayerStore.getState().repeat]; const next = NEXT_REPEAT[usePlayerStore.getState().repeat];
usePlayerStore.getState().setRepeat(next); usePlayerStore.getState().setRepeat(next);
await ensurePlayerReady(); 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> { export async function toggleShuffle(): Promise<void> {
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const next = !store.shuffle; 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 ensurePlayerReady();
await queueLoadSettled(); await queueLoadSettled();
if (virtualContext) { if (virtualContext) {
try {
await mutateVirtualQueue('shuffle', { await mutateVirtualQueue('shuffle', {
enabled: next, enabled: next,
seed: next ? Date.now() : null, seed: next ? Date.now() : null,
}); });
store.setShuffle(next); } catch (error) {
store.setShuffle(!next);
throw error;
}
return; return;
} }
try {
const snapshot = await getQueueSnapshot(); const snapshot = await getQueueSnapshot();
const queue = snapshot.queue; const queue = snapshot.queue;
const activeIndex = snapshot.activeIndex >= 0 ? snapshot.activeIndex : 0; 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]; mirroredQueue = [...queue.slice(0, activeIndex + 1), ...restored];
} }
useQueueStore.getState().setSnapshot(mirroredQueue, activeIndex); useQueueStore.getState().setSnapshot(mirroredQueue, activeIndex, { transport: null });
store.setShuffle(next); } catch (error) {
store.setShuffle(!next);
throw error;
}
} }
/** Insert a track right after the current one ("Play next"). */ /** Insert a track right after the current one ("Play next"). */
@@ -1127,8 +1324,8 @@ export async function jumpToQueueIndex(
await AstraLibraryData.updatePlaybackPosition(context.sessionId, bounded); await AstraLibraryData.updatePlaybackPosition(context.sessionId, bounded);
const window = await AstraLibraryData.getPlaybackWindow<DbTrack>( const window = await AstraLibraryData.getPlaybackWindow<DbTrack>(
context.sessionId, context.sessionId,
Math.max(0, bounded - 25), virtualPlaybackWindowStart(bounded),
226, TRANSPORT_WINDOW_SIZE,
); );
await startVirtualWindow( await startVirtualWindow(
window, window,
+4
View File
@@ -4,6 +4,7 @@ import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
import { applyNormalizationForActiveTrack } from './applyNormalization'; import { applyNormalizationForActiveTrack } from './applyNormalization';
import { startAudioProcessingWarmup } from './audioProcessingStartup'; import { startAudioProcessingWarmup } from './audioProcessingStartup';
import { import {
handleVirtualQueueEnded,
handleVirtualPlaybackAdvance, handleVirtualPlaybackAdvance,
playForCar, playForCar,
skipToNext, skipToNext,
@@ -112,6 +113,9 @@ export async function PlaybackService(): Promise<void> {
}); });
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, ({ position }) => { TrackPlayer.addEventListener(Event.PlaybackQueueEnded, ({ position }) => {
handleListeningQueueEnded(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; if (useSleepTimerStore.getState().timer?.mode !== 'end-of-track') return;
void TrackPlayer.getProgress() void TrackPlayer.getProgress()
.then(({ position, duration }) => useSleepTimerStore.getState().reconcileEndOfTrack(position, duration, false)) .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 * 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 * setQueue/add stalls the Android main thread for seconds (per-track Bundle
* Track MediaSource construction), so playback starts from a small first * Track MediaSource construction), so playback starts from history + current
* chunk and the rest streams in behind it: the upcoming tail first (it plays * + eight upcoming rows and the rest streams in behind it. We never prepend:
* next), then the head prepended in reverse chunk order. The JS queue mirror * native indices stay stable for the lifetime of a transport window.
* holds the full context from the start; while the head is still missing,
* native indices trail absolute (mirror) indices by `headRemaining`.
*/ */
// The first chunk's setQueue lands on the Android main thread at the exact const INITIAL_UPCOMING = 8;
// moment of the play tap, so it stays tiny. Each background add() also occupies const CHUNK = 8;
// the main thread (= the UI thread) for time proportional to its size, so the const YIELD_MS = 16;
// 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;
interface QueueLoad { interface QueueLoad {
generation: number; generation: number;
/** Head tracks not yet prepended: absolute = native + headRemaining. */
headRemaining: number;
/** Tracks currently in the native queue (per this loader's bookkeeping). */ /** Tracks currently in the native queue (per this loader's bookkeeping). */
loadedCount: number; loadedCount: number;
settled: Promise<void>; settled: Promise<void>;
@@ -54,19 +45,15 @@ export function queueLoadSettled(): Promise<void> {
return load ? load.settled : Promise.resolve(); 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 { export function nativeIndexToAbsolute(nativeIndex: number): number {
return load ? nativeIndex + load.headRemaining : nativeIndex; return nativeIndex;
} }
/** /** Map a local mirror index to RNTP, or null until its append has landed. */
* Map an absolute index to its native index, or null while that part of the
* queue has not been loaded yet.
*/
export function absoluteIndexToNative(absoluteIndex: number): number | null { export function absoluteIndexToNative(absoluteIndex: number): number | null {
if (!load) return absoluteIndex; if (!load) return absoluteIndex;
const nativeIndex = absoluteIndex - load.headRemaining; return absoluteIndex >= 0 && absoluteIndex < load.loadedCount ? absoluteIndex : null;
return nativeIndex >= 0 && nativeIndex < load.loadedCount ? nativeIndex : null;
} }
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
@@ -81,7 +68,7 @@ async function supersedePreviousLoad(): Promise<number> {
return gen; return gen;
} }
function beginLoad(gen: number, headRemaining: number, loadedCount: number): QueueLoad { function beginLoad(gen: number, loadedCount: number): QueueLoad {
let resolveSettled!: () => void; let resolveSettled!: () => void;
let resolveLoopDone!: () => void; let resolveLoopDone!: () => void;
const settled = new Promise<void>((resolve) => { const settled = new Promise<void>((resolve) => {
@@ -92,7 +79,6 @@ function beginLoad(gen: number, headRemaining: number, loadedCount: number): Que
}); });
const next: QueueLoad = { const next: QueueLoad = {
generation: gen, generation: gen,
headRemaining,
loadedCount, loadedCount,
settled, settled,
resolveSettled, resolveSettled,
@@ -123,21 +109,26 @@ export async function loadQueueChunked(
const gen = await supersedePreviousLoad(); const gen = await supersedePreviousLoad();
if (gen !== generation) return; if (gen !== generation) return;
const current = beginLoad(gen, startIndex, 0); const current = beginLoad(gen, 0);
const manualTransition = markManualRecentPlayTransition( const manualTransition = markManualRecentPlayTransition(
options.manualTransitionFromPath, options.manualTransitionFromPath,
); );
try { 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); await TrackPlayer.setQueue(first);
current.loadedCount = first.length; current.loadedCount = first.length;
if (startIndex > 0) await TrackPlayer.skip(startIndex);
} catch (err) { } catch (err) {
cancelManualRecentPlayTransition(manualTransition); cancelManualRecentPlayTransition(manualTransition);
finishLoad(current, false); finishLoad(current, false);
throw err; 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(); const gen = await supersedePreviousLoad();
if (gen !== generation || tracks.length === 0) return; if (gen !== generation || tracks.length === 0) return;
const current = beginLoad(gen, 0, baseCount); const current = beginLoad(gen, baseCount);
try { try {
const first = tracks.slice(0, CHUNK); const first = tracks.slice(0, CHUNK);
await TrackPlayer.add(first); await TrackPlayer.add(first);
@@ -180,24 +171,10 @@ async function fillTail(current: QueueLoad, tracks: RntpTrack[], fromIndex: numb
async function fillRemainder( async function fillRemainder(
current: QueueLoad, current: QueueLoad,
tracks: RntpTrack[], tracks: RntpTrack[],
startIndex: number,
): Promise<void> { ): Promise<void> {
let failed = false; let failed = false;
try { try {
// Tail first — it's what plays next. await fillTail(current, tracks, current.loadedCount);
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;
}
} catch { } catch {
failed = true; failed = true;
} finally { } 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 { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { seekTo } from '@/audio/playbackController'; import { seekTo } from '@/audio/playbackController';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { import {
getNowPlayingTrackTransitionKey, getNowPlayingTrackTransitionKey,
NowPlayingTrackFadeThrough, NowPlayingTrackFadeThrough,
@@ -14,6 +14,10 @@ import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import type { NowPlayingCompanion } from './nowPlayingPreferences'; import type { NowPlayingCompanion } from './nowPlayingPreferences';
import type { Track } from '@/types/audio'; import type { Track } from '@/types/audio';
import {
AstraQueueView,
toNativeQueuePalette,
} from '../../../modules/astra-library-scanner';
const COMPANION_SEGMENTS = [ const COMPANION_SEGMENTS = [
{ key: 'queue', label: 'Queue' }, { key: 'queue', label: 'Queue' },
@@ -35,7 +39,9 @@ export function NowPlayingCompanionPane({
track, track,
}: NowPlayingCompanionPaneProps) { }: NowPlayingCompanionPaneProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors();
const companion = useSettingsStore((s) => s.nowPlayingCompanion); const companion = useSettingsStore((s) => s.nowPlayingCompanion);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion); const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion);
const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0)); const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0));
const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration)); const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration));
@@ -68,7 +74,15 @@ export function NowPlayingCompanionPane({
</View> </View>
<View style={styles.content}> <View style={styles.content}>
{companion === 'queue' ? ( {companion === 'queue' ? (
nativeQueueEnabled ? (
<AstraQueueView
active={active}
palette={toNativeQueuePalette(colors)}
style={styles.nativeQueue}
/>
) : (
<QueueTray embedded onClose={noop} /> <QueueTray embedded onClose={noop} />
)
) : track ? ( ) : track ? (
<NowPlayingTrackFadeThrough <NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey} transitionKey={transitionTrackKey}
@@ -113,4 +127,8 @@ const useStyles = createThemedStyles((colors) => ({
minHeight: 0, minHeight: 0,
position: 'relative', 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 type { DbTrack } from '@/types/library';
import { import {
cycleRepeat, cycleRepeat,
jumpToQueueIndex,
seekTo, seekTo,
skipToNext, skipToNext,
skipToPrevious, skipToPrevious,
synchronizeVirtualQueueRevision,
togglePlay, togglePlay,
toggleShuffle toggleShuffle
} from '@/audio/playbackController'; } from '@/audio/playbackController';
import {
AstraQueue,
toNativeQueuePalette,
} from '../../../modules/astra-library-scanner';
import { import {
desktopConnectionLabel, desktopConnectionLabel,
getDesktopPlaybackPresentation, getDesktopPlaybackPresentation,
@@ -174,6 +180,7 @@ export function NowPlayingOverlay() {
const nowPlayingAccentSource = useThemeStore((s) => s.nowPlayingAccentSource); const nowPlayingAccentSource = useThemeStore((s) => s.nowPlayingAccentSource);
const coverArtAccentMethod = useThemeStore((s) => s.coverArtAccentMethod); const coverArtAccentMethod = useThemeStore((s) => s.coverArtAccentMethod);
const scopeMode = useSettingsStore((s) => s.scopeMode); const scopeMode = useSettingsStore((s) => s.scopeMode);
const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled);
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle); const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle);
@@ -378,6 +385,12 @@ export function NowPlayingOverlay() {
} }
suspendPanForChildTransition(); suspendPanForChildTransition();
setQueueOpen(true); setQueueOpen(true);
if (nativeQueueEnabled && !isDesktopTarget) {
void AstraQueue.present({ palette: toNativeQueuePalette(colors) }).catch((error) => {
console.warn('[queue] native presentation failed', error);
setQueueOpen(false);
});
}
}; };
const swapScopeMode = () => const swapScopeMode = () =>
@@ -742,10 +755,49 @@ export function NowPlayingOverlay() {
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it. // Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => { const closeQueue = useCallback(() => {
suspendPanForChildTransition(); suspendPanForChildTransition();
if (nativeQueueEnabled && !isDesktopTarget) AstraQueue.dismiss();
setQueueOpen(false); setQueueOpen(false);
// Shared values and the state setter remain stable for this overlay mount. // Shared values and the state setter remain stable for this overlay mount.
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // Hardware back, innermost layer first: menu → queue tray → player. Registered
// only while open, so it sits above the focused screen's own handlers (LIFO) // only while open, so it sits above the focused screen's own handlers (LIFO)
@@ -1692,9 +1744,9 @@ export function NowPlayingOverlay() {
{queueOpen && !hasTabletCompanion && ( {queueOpen && !hasTabletCompanion && (
isDesktopTarget ? ( isDesktopTarget ? (
<RemoteQueueSheet onClose={closeQueue} /> <RemoteQueueSheet onClose={closeQueue} />
) : ( ) : !nativeQueueEnabled ? (
<QueueTray onClose={closeQueue} /> <QueueTray onClose={closeQueue} />
) ) : null
)} )}
<PlaybackTargetPicker <PlaybackTargetPicker
visible={targetPickerOpen} visible={targetPickerOpen}
+90 -14
View File
@@ -6,6 +6,20 @@ import type { PlaybackSource } from '@/types/audio';
interface QueueSnapshotOptions { interface QueueSnapshotOptions {
/** Omit to retain the current queue source; pass null when clearing playback. */ /** Omit to retain the current queue source; pass null when clearing playback. */
source?: PlaybackSource | null; 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; activeIndex: number;
hasSnapshot: boolean; hasSnapshot: boolean;
source: PlaybackSource | null; source: PlaybackSource | null;
transport: QueueTransportState | null;
refreshFromNative: () => Promise<void>; refreshFromNative: () => Promise<void>;
refreshActiveIndex: () => Promise<void>; refreshActiveIndex: () => Promise<void>;
setSnapshot: ( setSnapshot: (
@@ -47,53 +62,100 @@ export const useQueueStore = create<QueueStore>((set) => ({
activeIndex: -1, activeIndex: -1,
hasSnapshot: false, hasSnapshot: false,
source: null, source: null,
transport: null,
refreshFromNative: async () => { 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(); await queueLoadSettled();
const [tracks, activeIndex] = await Promise.all([ const [tracks, activeIndex] = await Promise.all([
TrackPlayer.getQueue(), TrackPlayer.getQueue(),
TrackPlayer.getActiveTrackIndex(), TrackPlayer.getActiveTrackIndex(),
]); ]);
set({ set((state) => {
const normalized = normalizeActiveIndex(activeIndex, tracks.length);
return {
tracks, tracks,
activeIndex: normalizeActiveIndex(activeIndex, tracks.length), activeIndex: normalized,
hasSnapshot: true, hasSnapshot: true,
transport: state.transport
? { ...state.transport, activeLocalIndex: normalized, tracks }
: null,
};
}); });
}, },
refreshActiveIndex: async () => { refreshActiveIndex: async () => {
const activeIndex = await TrackPlayer.getActiveTrackIndex(); const activeIndex = await TrackPlayer.getActiveTrackIndex();
set((s) => ({ set((s) => {
activeIndex: normalizeActiveIndex( const normalized = normalizeActiveIndex(
activeIndex == null ? activeIndex : nativeIndexToAbsolute(activeIndex), activeIndex == null ? activeIndex : nativeIndexToAbsolute(activeIndex),
s.tracks.length, s.tracks.length,
), );
})); return {
activeIndex: normalized,
transport: s.transport
? { ...s.transport, activeLocalIndex: normalized, tracks: s.tracks }
: null,
};
});
}, },
setSnapshot: (tracks, activeIndex = 0, options) => 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, tracks,
activeIndex: normalizeActiveIndex(activeIndex, tracks.length), }
: null
: state.transport
? { ...state.transport, activeLocalIndex: normalized, tracks }
: null;
return {
tracks,
activeIndex: normalized,
hasSnapshot: true, hasSnapshot: true,
source: options && Object.hasOwn(options, 'source') source: options && Object.hasOwn(options, 'source')
? options.source ?? null ? options.source ?? null
: state.source, : state.source,
})), transport,
};
}),
setActiveIndex: (activeIndex) => 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) => insertTrack: (track, index) =>
set((s) => { set((s) => {
const insertAt = boundedInsertIndex(index, s.tracks.length); const insertAt = boundedInsertIndex(index, s.tracks.length);
const tracks = [...s.tracks]; const tracks = [...s.tracks];
tracks.splice(insertAt, 0, track); tracks.splice(insertAt, 0, track);
const activeIndex = s.activeIndex >= insertAt ? s.activeIndex + 1 : s.activeIndex; 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) => replaceUpcoming: (upcoming) =>
set((s) => { set((s) => {
const prefixEnd = s.activeIndex >= 0 ? s.activeIndex + 1 : 0; const prefixEnd = s.activeIndex >= 0 ? s.activeIndex + 1 : 0;
const tracks = [...s.tracks.slice(0, prefixEnd), ...upcoming];
return { return {
tracks: [...s.tracks.slice(0, prefixEnd), ...upcoming], tracks,
hasSnapshot: true, hasSnapshot: true,
transport: s.transport
? { ...s.transport, tracks }
: null,
}; };
}), }),
moveItem: (fromIndex, toIndex) => moveItem: (fromIndex, toIndex) =>
@@ -114,7 +176,14 @@ export const useQueueStore = create<QueueStore>((set) => ({
activeIndex += 1; activeIndex += 1;
} }
return { tracks, activeIndex, hasSnapshot: true }; return {
tracks,
activeIndex,
hasSnapshot: true,
transport: s.transport
? { ...s.transport, activeLocalIndex: activeIndex, tracks }
: null,
};
}), }),
removeIndices: (indices) => removeIndices: (indices) =>
set((s) => { set((s) => {
@@ -143,6 +212,13 @@ export const useQueueStore = create<QueueStore>((set) => ({
tracks, tracks,
activeIndex: normalizeActiveIndex(activeIndex, tracks.length), activeIndex: normalizeActiveIndex(activeIndex, tracks.length),
hasSnapshot: true, 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 NOW_PLAYING_COMPANION_KEY = 'now_playing_companion';
const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode'; const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode';
const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled'; const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled';
const NATIVE_QUEUE_ENABLED_KEY = 'native_queue_enabled';
/** Which visualizer the now-playing scope stage shows. */ /** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope'; export type ScopeMode = 'spectrum' | 'scope';
@@ -68,6 +69,7 @@ interface SettingsStore {
nowPlayingCompanion: NowPlayingCompanion; nowPlayingCompanion: NowPlayingCompanion;
homeGreetingTextMode: HomeGreetingTextMode; homeGreetingTextMode: HomeGreetingTextMode;
listeningHistoryEnabled: boolean; listeningHistoryEnabled: boolean;
nativeQueueEnabled: boolean;
loaded: boolean; loaded: boolean;
load: () => Promise<void>; load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
@@ -79,6 +81,7 @@ interface SettingsStore {
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>; setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>; setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>; setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
setNativeQueueEnabled: (enabled: boolean) => Promise<void>;
} }
export const useSettingsStore = create<SettingsStore>((set, get) => ({ export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -91,6 +94,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingCompanion: 'queue', nowPlayingCompanion: 'queue',
homeGreetingTextMode: 'messages', homeGreetingTextMode: 'messages',
listeningHistoryEnabled: true, listeningHistoryEnabled: true,
nativeQueueEnabled: false,
loaded: false, loaded: false,
load: async () => { load: async () => {
@@ -106,6 +110,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
NOW_PLAYING_COMPANION_KEY, NOW_PLAYING_COMPANION_KEY,
HOME_GREETING_TEXT_MODE_KEY, HOME_GREETING_TEXT_MODE_KEY,
LISTENING_HISTORY_ENABLED_KEY, LISTENING_HISTORY_ENABLED_KEY,
NATIVE_QUEUE_ENABLED_KEY,
]); ]);
const grouping = values[ARTIST_GROUPING_KEY] ?? null; const grouping = values[ARTIST_GROUPING_KEY] ?? null;
const includeSingles = values[INCLUDE_SINGLES_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 nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null;
const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null; const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null;
const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0'; const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0';
const nativeQueueEnabled = parseBoolean(values[NATIVE_QUEUE_ENABLED_KEY] ?? null);
set({ set({
artistGroupingMode: parseGroupingMode(grouping), artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles), includeSingles: parseBoolean(includeSingles),
@@ -126,6 +132,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode), homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
listeningHistoryEnabled, listeningHistoryEnabled,
nativeQueueEnabled,
loaded: true, loaded: true,
}); });
}, },
@@ -194,4 +201,12 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
throw error; throw error;
} }
}, },
setNativeQueueEnabled: async (enabled) => {
if (get().nativeQueueEnabled === enabled) return;
set({ nativeQueueEnabled: enabled });
await AstraLibraryData.setSettings({
[NATIVE_QUEUE_ENABLED_KEY]: enabled ? 'true' : 'false',
});
},
})); }));