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