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