rewrite entire queue system to native

This commit is contained in:
Boof2015
2026-07-29 19:25:54 -04:00
parent d4c88042e0
commit 771d7a034c
29 changed files with 4376 additions and 220 deletions
@@ -14,6 +14,16 @@ apply plugin: 'com.google.devtools.ksp'
group = 'expo.modules.astralibraryscanner'
version = '0.1.0'
def queueFontAssetsDir = layout.buildDirectory.dir("generated/astraQueueFonts")
def prepareQueueFontAssets = tasks.register("prepareQueueFontAssets", Copy) {
from(
"$rootDir/../node_modules/@expo-google-fonts/inter/400Regular/Inter_400Regular.ttf",
"$rootDir/../node_modules/@expo-google-fonts/inter/500Medium/Inter_500Medium.ttf",
"$rootDir/../node_modules/@expo-google-fonts/inter/600SemiBold/Inter_600SemiBold.ttf",
)
into(queueFontAssetsDir)
}
android {
namespace "expo.modules.astralibraryscanner"
defaultConfig {
@@ -23,12 +33,17 @@ android {
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas")
main.assets.srcDir(queueFontAssetsDir)
}
lintOptions {
abortOnError false
}
}
tasks.named("preBuild").configure {
dependsOn(prepareQueueFontAssets)
}
dependencies {
// ExoPlayer 2.19.0 (same version the vendored kotlin-audio fork pulls in) for
// MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without
@@ -36,6 +51,10 @@ dependencies {
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0'
implementation 'androidx.room:room-runtime:2.8.4'
implementation 'androidx.room:room-ktx:2.8.4'
implementation 'androidx.recyclerview:recyclerview:1.4.0'
implementation 'androidx.metrics:metrics-performance:1.0.0'
implementation 'com.google.android.material:material:1.13.0'
implementation 'com.github.bumptech.glide:glide:5.0.5'
ksp 'androidx.room:room-compiler:2.8.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
@@ -208,6 +208,69 @@ class RoomLibraryRepositoryTest {
assertEquals(2L, dao.countQueueEntries(session.id))
}
@Test
fun stableQueueMutationPreservesIdsAcrossMoveRemoveInsertAndDuplicates() = runBlocking {
val dao = user.userDao()
val session = PlaybackSessionEntity(
id = "active-context",
contextJson = """{"kind":"manual"}""",
anchorPath = "/a.flac",
shuffleSeed = 42,
activePosition = 0,
createdAt = 1,
updatedAt = 1,
queueRevision = 1,
nextEntryId = 14,
)
dao.replacePlaybackQueue(
session,
listOf(
PlaybackQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackQueueEntryEntity(session.id, 1, "/b.flac", 11),
PlaybackQueueEntryEntity(session.id, 2, "/a.flac", 12),
PlaybackQueueEntryEntity(session.id, 3, "/c.flac", 13),
),
listOf(
PlaybackOriginalQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackOriginalQueueEntryEntity(session.id, 1, "/b.flac", 11),
PlaybackOriginalQueueEntryEntity(session.id, 2, "/a.flac", 12),
PlaybackOriginalQueueEntryEntity(session.id, 3, "/c.flac", 13),
),
)
dao.applyPlaybackQueueMutation(
session.copy(queueRevision = 2, nextEntryId = 15),
listOf(
PlaybackQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackQueueEntryEntity(session.id, 1, "/c.flac", 13),
PlaybackQueueEntryEntity(session.id, 2, "/a.flac", 12),
PlaybackQueueEntryEntity(session.id, 3, "/d.flac", 14),
),
listOf(
PlaybackOriginalQueueEntryEntity(session.id, 0, "/a.flac", 10),
PlaybackOriginalQueueEntryEntity(session.id, 1, "/a.flac", 12),
PlaybackOriginalQueueEntryEntity(session.id, 2, "/c.flac", 13),
PlaybackOriginalQueueEntryEntity(session.id, 3, "/d.flac", 14),
),
)
assertEquals(
listOf(10L, 13L, 12L, 14L),
dao.getAllQueueEntries(session.id).map(PlaybackQueueEntryEntity::entryId),
)
assertEquals(
listOf("/a.flac", "/c.flac", "/a.flac", "/d.flac"),
dao.getAllQueueEntries(session.id).map(PlaybackQueueEntryEntity::trackPath),
)
assertEquals(
listOf(10L, 12L, 13L, 14L),
dao.getOriginalQueueEntries(session.id)
.map(PlaybackOriginalQueueEntryEntity::entryId),
)
assertEquals(2L, dao.getPlaybackSession(session.id)?.queueRevision)
assertEquals(15L, dao.getPlaybackSession(session.id)?.nextEntryId)
}
@Test
fun playbackWindowNeverClampsPastTheEndBackToTheLastTrack() {
assertEquals(0L, boundedPlaybackWindowStart(-10, 3))
@@ -104,6 +104,83 @@ class UserMigrationTest {
assertTrue("listening_segments" in tables)
}
@Test
fun queueV3MigrationAssignsStableIdsByDuplicateOccurrence() {
helper.createDatabase(TEST_DATABASE, 2).apply {
execSQL(
"""
INSERT INTO playback_sessions
(id, context_json, anchor_path, shuffle_seed, active_position, created_at, updated_at)
VALUES ('active-context', '{}', '/a.flac', 42, 0, 10, 20)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playback_queue_entries (session_id, position, track_path) VALUES
('active-context', 0, '/a.flac'),
('active-context', 1, '/b.flac'),
('active-context', 2, '/a.flac')
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playback_original_queue_entries (session_id, position, track_path) VALUES
('active-context', 0, '/a.flac'),
('active-context', 1, '/a.flac'),
('active-context', 2, '/b.flac')
""".trimIndent(),
)
close()
}
val database = helper.runMigrationsAndValidate(
TEST_DATABASE,
3,
true,
USER_MIGRATION_2_3,
)
val current = database.query(
"""
SELECT entry_id, track_path
FROM playback_queue_entries
WHERE session_id = 'active-context'
ORDER BY position
""".trimIndent(),
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getString(1))
}
}
val original = database.query(
"""
SELECT entry_id, track_path
FROM playback_original_queue_entries
WHERE session_id = 'active-context'
ORDER BY position
""".trimIndent(),
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getString(1))
}
}
assertEquals(listOf(0L to "/a.flac", 1L to "/b.flac", 2L to "/a.flac"), current)
assertEquals(listOf(0L to "/a.flac", 2L to "/a.flac", 1L to "/b.flac"), original)
assertEquals(
3,
database.singleInt(
"SELECT next_entry_id FROM playback_sessions WHERE id = 'active-context'",
),
)
assertEquals(
1,
database.singleInt(
"SELECT queue_revision FROM playback_sessions WHERE id = 'active-context'",
),
)
}
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
this.query(query).use { cursor ->
assertTrue(cursor.moveToFirst())
@@ -0,0 +1,218 @@
package expo.modules.astralibraryscanner
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.WindowManager
import android.content.pm.ApplicationInfo
import android.os.Trace
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.metrics.performance.JankStats
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import expo.modules.astralibraryscanner.queue.AstraQueueView
import expo.modules.astralibraryscanner.queue.NativeQueueSnapshot
import expo.modules.astralibraryscanner.queue.QueueContentView
import expo.modules.astralibraryscanner.queue.QueueCoordinator
import expo.modules.astralibraryscanner.queue.QueuePalette
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AstraQueueModule : Module() {
private var dialog: BottomSheetDialog? = null
private var dialogContent: QueueContentView? = null
private var lastRevision = Long.MIN_VALUE
private var coordinator: QueueCoordinator? = null
private var jankStats: JankStats? = null
private val coordinatorListener: (NativeQueueSnapshot) -> Unit = { snapshot ->
if (snapshot.revision != lastRevision) {
lastRevision = snapshot.revision
sendEvent(
"onQueueRevision",
mapOf(
"sessionId" to snapshot.sessionId,
"queueRevision" to snapshot.revision.toDouble(),
"activePosition" to snapshot.activePosition.toDouble(),
"totalCount" to snapshot.totalCount,
),
)
}
}
override fun definition() = ModuleDefinition {
Name("AstraQueue")
Events(
"onDismissed",
"onPlaybackRequest",
"onQueueRevision",
)
OnCreate {
val context = appContext.reactContext ?: return@OnCreate
QueueCoordinator.get(context).also {
coordinator = it
it.addListener(coordinatorListener)
it.start()
}
}
OnDestroy {
coordinator?.removeListener(coordinatorListener)
coordinator = null
appContext.mainQueue.launch {
jankStats?.isTrackingEnabled = false
jankStats = null
dialog?.dismiss()
dialog = null
dialogContent = null
}
}
AsyncFunction("present") Coroutine { options: Map<String, Any?> ->
withContext(Dispatchers.Main) {
presentDialog(options)
}
}
Function("dismiss") {
appContext.mainQueue.launch {
dialog?.dismiss()
}
}
Function("resolvePlaybackRequest") {
requestId: String,
success: Boolean,
message: String?,
->
requestId.length
appContext.mainQueue.launch {
dialogContent?.showPlaybackResult(success, message)
}
}
AsyncFunction("resolveEntryPosition") Coroutine {
entryId: Double,
expectedRevision: Double,
->
coordinator?.positionForEntry(entryId.toLong(), expectedRevision.toLong())?.toDouble()
}
View(AstraQueueView::class) {
Prop("active") { view, active: Boolean ->
view.active = active
}
Prop("palette") { view, values: Map<String, Any?>? ->
view.palette = QueuePalette.from(values)
}
OnViewDidUpdateProps { view ->
view.setPlaybackRequestListener { entryId, revision ->
emitPlaybackRequest(entryId, revision)
}
}
}
}
private fun presentDialog(options: Map<String, Any?>) {
Trace.beginSection("AstraQueue.present")
try {
val activity = appContext.currentActivity
?: error("AstraQueue requires a foreground Activity")
dialog?.dismiss()
@Suppress("UNCHECKED_CAST")
val paletteValues = options["palette"] as? Map<String, Any?>
val content = QueueContentView(activity).apply {
sheetMode = true
palette = QueuePalette.from(paletteValues)
playbackRequestListener = QueueContentView.PlaybackRequestListener { entryId, revision ->
emitPlaybackRequest(entryId, revision)
}
attach()
}
val next = BottomSheetDialog(activity).apply {
setContentView(content)
setCanceledOnTouchOutside(true)
setOnShowListener {
val displayHeight = activity.resources.displayMetrics.heightPixels
findViewById<FrameLayout>(
com.google.android.material.R.id.design_bottom_sheet,
)?.apply {
layoutParams = layoutParams.apply {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
background = ColorDrawable(Color.TRANSPARENT)
}
content.layoutParams = content.layoutParams.apply {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
behavior.peekHeight = (displayHeight * 0.58f).toInt()
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
behavior.isFitToContents = false
behavior.expandedOffset = 0
behavior.isHideable = true
behavior.skipCollapsed = false
window?.apply {
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
attributes = attributes.apply { dimAmount = 0.58f }
setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
)
}
val debuggable =
activity.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
if (debuggable) {
window?.let { queueWindow ->
jankStats?.isTrackingEnabled = false
jankStats = JankStats.createAndTrack(queueWindow) { frame ->
if (frame.isJank) {
Log.d(
"AstraQueueJank",
"frameMs=${frame.frameDurationUiNanos / 1_000_000.0}",
)
}
}
}
}
}
setOnDismissListener {
jankStats?.isTrackingEnabled = false
jankStats = null
content.detach()
if (dialog === this) {
dialog = null
dialogContent = null
sendEvent("onDismissed", emptyMap<String, Any?>())
}
}
}
dialog = next
dialogContent = content
next.show()
} finally {
Trace.endSection()
}
}
private fun emitPlaybackRequest(entryId: Long, revision: Long) {
sendEvent(
"onPlaybackRequest",
mapOf(
"requestId" to UUID.randomUUID().toString(),
"kind" to "playEntry",
"entryId" to entryId.toDouble(),
"queueRevision" to revision.toDouble(),
),
)
}
}
@@ -3,6 +3,8 @@ package expo.modules.astralibraryscanner.data
import android.content.Context
import android.database.sqlite.SQLiteDatabaseCorruptException
import android.database.sqlite.SQLiteException
import android.os.Build
import android.os.Trace
import androidx.room.Room
import androidx.room.RoomDatabase
import java.io.File
@@ -12,6 +14,7 @@ import java.text.Normalizer
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicInteger
import kotlin.random.Random
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -33,7 +36,26 @@ private const val CUTOVER_PREFS = "astra-room-cutover"
private const val CUTOVER_COMPLETE = "room-cutover-v1-complete"
private const val SNAPSHOT_DEBOUNCE_MS = 2_000L
private const val MOBILE_SESSION_ID = "mobile"
private const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context"
internal const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context"
internal const val PLAYBACK_HISTORY_WINDOW = 8
internal const val PLAYBACK_UPCOMING_WINDOW = 32
internal const val PLAYBACK_WINDOW_SIZE =
PLAYBACK_HISTORY_WINDOW + 1 + PLAYBACK_UPCOMING_WINDOW
private val traceCookie = AtomicInteger()
private suspend fun <T> traceAsyncSection(
name: String,
block: suspend () -> T,
): T {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return block()
val cookie = traceCookie.incrementAndGet()
Trace.beginAsyncSection(name, cookie)
return try {
block()
} finally {
Trace.endAsyncSection(name, cookie)
}
}
class StaleRevisionException : IllegalStateException("STALE_REVISION")
internal class ScanCancelledException : IllegalStateException("SCAN_CANCELLED")
@@ -59,6 +81,11 @@ private data class RemoteSyncHandle(
val seenPaths: MutableSet<String> = ConcurrentHashMap.newKeySet(),
)
private data class OrderedQueueItem(
val entryId: Long,
val trackPath: String,
)
/**
* Single owner for both Room files. Every app surface—including Android Auto—
* reaches SQLite through this repository so connection and recovery policy
@@ -915,11 +942,25 @@ class AstraLibraryRepository private constructor(
initialize()
val catalogDao = requireCatalog().catalogDao()
val userDao = requireUser().userDao()
val paths = resolvePlaybackPaths(context, catalogDao, userDao)
val availablePaths = filterAvailablePaths(paths, catalogDao)
val contextKind = context["kind"] as? String ?: "library"
val catalogRevision = catalogDao.getRevision()
val persistedContextJson = org.json.JSONObject(context).toString()
val paths = traceAsyncSection("AstraQueue.contextCreate") {
resolvePlaybackPaths(context, catalogDao, userDao)
}
val availablePaths = when (contextKind) {
"library", "album", "artist", "folder", "search", "dynamicPlaylist" -> paths
else -> filterAvailablePaths(paths, catalogDao)
}
val seed = requestedSeed ?: System.currentTimeMillis()
val ordered = availablePaths.toMutableList()
var activePosition = anchorPath?.let(ordered::indexOf)?.takeIf { it >= 0 } ?: 0
val original = availablePaths.mapIndexed { index, path ->
OrderedQueueItem(index.toLong(), path)
}
val ordered = original.toMutableList()
var activePosition = anchorPath
?.let { anchor -> ordered.indexOfFirst { it.trackPath == anchor } }
?.takeIf { it >= 0 }
?: 0
if (shuffle && ordered.size > 1) {
val anchor = ordered.getOrNull(activePosition)
if (anchor != null) ordered.removeAt(activePosition)
@@ -928,36 +969,80 @@ class AstraLibraryRepository private constructor(
activePosition = 0
}
val now = System.currentTimeMillis()
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(
id = ACTIVE_PLAYBACK_CONTEXT_ID,
contextJson = org.json.JSONObject(context).toString(),
anchorPath = ordered.getOrNull(activePosition),
contextJson = persistedContextJson,
anchorPath = ordered.getOrNull(activePosition)?.trackPath,
shuffleSeed = if (shuffle) seed else null,
activePosition = activePosition.toLong(),
createdAt = now,
updatedAt = now,
queueRevision = (previous?.queueRevision ?: 0) + 1,
catalogRevision = catalogRevision,
isDirty = false,
nextEntryId = original.size.toLong(),
),
ordered.mapIndexed { index, path ->
ordered.mapIndexed { index, item ->
PlaybackQueueEntryEntity(
sessionId = ACTIVE_PLAYBACK_CONTEXT_ID,
position = index.toLong(),
trackPath = path,
trackPath = item.trackPath,
entryId = item.entryId,
)
},
availablePaths.mapIndexed { index, path ->
PlaybackOriginalQueueEntryEntity(
sessionId = ACTIVE_PLAYBACK_CONTEXT_ID,
position = index.toLong(),
trackPath = path,
)
if (shuffle) {
original.mapIndexed { index, item ->
PlaybackOriginalQueueEntryEntity(
sessionId = ACTIVE_PLAYBACK_CONTEXT_ID,
position = index.toLong(),
trackPath = item.trackPath,
entryId = item.entryId,
)
}
} else {
emptyList()
},
)
)
}
scheduleSnapshot()
return playbackWindow(
ACTIVE_PLAYBACK_CONTEXT_ID,
(activePosition - 25).coerceAtLeast(0).toLong(),
226,
(activePosition - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong(),
PLAYBACK_WINDOW_SIZE,
)
}
@@ -978,7 +1063,6 @@ class AstraLibraryRepository private constructor(
val bounded = activePosition.coerceIn(0, total - 1)
val anchor = dao.getQueueWindow(sessionId, bounded, 1).firstOrNull()?.trackPath
dao.updatePlaybackPosition(sessionId, bounded, anchor, System.currentTimeMillis())
scheduleSnapshot()
}
suspend fun restorePlaybackContext(): Map<String, Any?>? {
@@ -1006,20 +1090,23 @@ class AstraLibraryRepository private constructor(
val normalized = retained.mapIndexed { index, row ->
row.copy(position = index.toLong())
}
val retainedIds = normalized.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
val original = dao.getOriginalQueueEntries(session.id)
.filter { it.trackPath in available }
.filter { it.entryId in retainedIds && it.trackPath in available }
.mapIndexed { index, row -> row.copy(position = index.toLong()) }
database.userDao().replacePlaybackQueue(
session.copy(
anchorPath = normalized[activeIndex].trackPath,
activePosition = activeIndex.toLong(),
updatedAt = System.currentTimeMillis(),
queueRevision = session.queueRevision + 1,
isDirty = true,
),
normalized,
original,
)
val start = (activeIndex - 25).coerceAtLeast(0).toLong()
return playbackWindow(session.id, start, 226)
val start = (activeIndex - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong()
return playbackWindow(session.id, start, PLAYBACK_WINDOW_SIZE)
}
suspend fun mutatePlaybackContext(
@@ -1030,18 +1117,27 @@ class AstraLibraryRepository private constructor(
val database = requireUser()
val dao = database.userDao()
val session = dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID) ?: return null
val current = dao.getAllQueueEntries(session.id).map(PlaybackQueueEntryEntity::trackPath).toMutableList()
val current = dao.getAllQueueEntries(session.id)
.map { OrderedQueueItem(it.entryId, it.trackPath) }
.toMutableList()
if (current.isEmpty()) return null
val originalRows = dao.getOriginalQueueEntries(session.id)
val original = (if (originalRows.isEmpty()) current else originalRows.map(PlaybackOriginalQueueEntryEntity::trackPath))
val original = (
if (originalRows.isEmpty()) {
current
} else {
originalRows.map { OrderedQueueItem(it.entryId, it.trackPath) }
}
)
.toMutableList()
var active = session.activePosition.toInt().coerceIn(current.indices)
val activePath = current[active]
val activeEntryId = current[active].entryId
var nextEntryId = session.nextEntryId
fun move(paths: MutableList<String>, from: Int, to: Int) {
if (from !in paths.indices || to !in paths.indices || from == to) return
val item = paths.removeAt(from)
paths.add(to.coerceIn(0, paths.size), item)
fun move(items: MutableList<OrderedQueueItem>, from: Int, to: Int) {
if (from !in items.indices || to !in items.indices || from == to) return
val item = items.removeAt(from)
items.add(to.coerceIn(0, items.size), item)
}
when (operation) {
@@ -1055,16 +1151,19 @@ class AstraLibraryRepository private constructor(
}
val paths = filterAvailablePaths(requested, requireCatalog().catalogDao())
if (paths.isNotEmpty()) {
val additions = paths.map { path ->
OrderedQueueItem(nextEntryId++, path)
}
val append = operation == "append" || operation == "appendQuery"
val insertAt = if (append) current.size else active + 1
current.addAll(insertAt, paths)
val originalAnchor = original.indexOf(activePath)
current.addAll(insertAt, additions)
val originalAnchor = original.indexOfFirst { it.entryId == activeEntryId }
val originalInsert = if (append || originalAnchor < 0) {
original.size
} else {
originalAnchor + 1
}
original.addAll(originalInsert, paths)
original.addAll(originalInsert, additions)
}
}
"remove" -> {
@@ -1077,7 +1176,9 @@ class AstraLibraryRepository private constructor(
for (position in positions) {
if (position !in current.indices || position == active) continue
val removed = current.removeAt(position)
original.indexOf(removed).takeIf { it >= 0 }?.let(original::removeAt)
original.indexOfFirst { it.entryId == removed.entryId }
.takeIf { it >= 0 }
?.let(original::removeAt)
if (position < active) active -= 1
}
}
@@ -1085,13 +1186,13 @@ class AstraLibraryRepository private constructor(
val from = (values["from"] as? Number)?.toInt() ?: -1
val to = (values["to"] as? Number)?.toInt() ?: -1
if (from in current.indices && to in current.indices && from != active && to != active) {
val movedPath = current[from]
val targetPath = current[to]
val movedEntryId = current[from].entryId
val targetEntryId = current[to].entryId
move(current, from, to)
val originalFrom = original.indexOf(movedPath)
val originalTo = original.indexOf(targetPath)
val originalFrom = original.indexOfFirst { it.entryId == movedEntryId }
val originalTo = original.indexOfFirst { it.entryId == targetEntryId }
if (originalFrom >= 0 && originalTo >= 0) move(original, originalFrom, originalTo)
active = current.indexOf(activePath).coerceAtLeast(0)
active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
}
}
"moveManyAfterActive" -> {
@@ -1105,18 +1206,14 @@ class AstraLibraryRepository private constructor(
if (positions.isNotEmpty()) {
val selected = positions.map(current::get)
positions.asReversed().forEach { current.removeAt(it) }
active = current.indexOf(activePath).coerceAtLeast(0)
active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
current.addAll(active + 1, selected)
val selectedCounts = selected.groupingBy { it }.eachCount().toMutableMap()
val remainingOriginal = original.filter { path ->
val count = selectedCounts[path] ?: 0
if (count <= 0) true else {
if (count == 1) selectedCounts.remove(path) else selectedCounts[path] = count - 1
false
}
}.toMutableList()
val originalActive = remainingOriginal.indexOf(activePath)
val selectedIds = selected.mapTo(hashSetOf(), OrderedQueueItem::entryId)
val remainingOriginal = original
.filterNot { it.entryId in selectedIds }
.toMutableList()
val originalActive = remainingOriginal.indexOfFirst { it.entryId == activeEntryId }
remainingOriginal.addAll(
if (originalActive >= 0) originalActive + 1 else 0,
selected,
@@ -1137,7 +1234,7 @@ class AstraLibraryRepository private constructor(
} else {
current.clear()
current.addAll(original)
active = current.indexOf(activePath).coerceAtLeast(0)
active = current.indexOfFirst { it.entryId == activeEntryId }.coerceAtLeast(0)
}
}
else -> error("Unknown playback context mutation.")
@@ -1150,22 +1247,45 @@ class AstraLibraryRepository private constructor(
shuffleEnabled -> (values["seed"] as? Number)?.toLong() ?: now
else -> null
}
dao.replacePlaybackQueue(
traceAsyncSection("AstraQueue.roomMutation") {
dao.applyPlaybackQueueMutation(
session.copy(
anchorPath = current.getOrNull(active),
anchorPath = current.getOrNull(active)?.trackPath,
activePosition = active.toLong(),
shuffleSeed = nextSeed,
updatedAt = now,
queueRevision = session.queueRevision + 1,
isDirty = true,
nextEntryId = nextEntryId,
),
current.mapIndexed { index, path ->
PlaybackQueueEntryEntity(session.id, index.toLong(), path)
current.mapIndexed { index, item ->
PlaybackQueueEntryEntity(
sessionId = session.id,
position = index.toLong(),
trackPath = item.trackPath,
entryId = item.entryId,
)
},
original.mapIndexed { index, path ->
PlaybackOriginalQueueEntryEntity(session.id, index.toLong(), path)
if (nextSeed != null) {
original.mapIndexed { index, item ->
PlaybackOriginalQueueEntryEntity(
sessionId = session.id,
position = index.toLong(),
trackPath = item.trackPath,
entryId = item.entryId,
)
}
} else {
emptyList()
},
)
)
}
scheduleSnapshot()
return playbackWindow(session.id, (active - 25).coerceAtLeast(0).toLong(), 226)
return playbackWindow(
session.id,
(active - PLAYBACK_HISTORY_WINDOW).coerceAtLeast(0).toLong(),
PLAYBACK_WINDOW_SIZE,
)
}
suspend fun recordTrackPlayed(path: String): Boolean {
@@ -2450,6 +2570,7 @@ class AstraLibraryRepository private constructor(
val items = entries.mapNotNull { entry ->
tracks[entry.trackPath]?.toBridgeMap()?.toMutableMap()?.apply {
this["queuePosition"] = entry.position.toDouble()
this["queueEntryId"] = entry.entryId.toDouble()
}
}
return mapOf(
@@ -2460,6 +2581,7 @@ class AstraLibraryRepository private constructor(
"totalCount" to total.toDouble(),
"contextJson" to session.contextJson,
"shuffleSeed" to session.shuffleSeed?.toDouble(),
"queueRevision" to session.queueRevision.toDouble(),
"catalogRevision" to requireCatalog().catalogDao().getRevision().toString(),
)
}
@@ -2482,6 +2604,12 @@ class AstraLibraryRepository private constructor(
return requireUser()
}
internal suspend fun nativeQueueTracks(paths: List<String>): List<ActiveTrackView> {
initialize()
if (paths.isEmpty()) return emptyList()
return requireCatalog().catalogDao().getActiveTracks(paths.distinct())
}
suspend fun catalogDb(): AstraCatalogDatabase {
initialize()
return requireCatalog()
@@ -2792,7 +2920,7 @@ class AstraLibraryRepository private constructor(
private fun buildUserDatabase(): AstraUserDatabase =
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(USER_MIGRATION_1_2)
.addMigrations(USER_MIGRATION_1_2, USER_MIGRATION_2_3)
.build()
private fun buildCatalogDatabase(): AstraCatalogDatabase =
@@ -11,6 +11,7 @@ import androidx.room.Transaction
import androidx.room.Upsert
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
data class RemotePlaylistSyncPlan(
val playlist: PlaylistEntity,
@@ -326,6 +327,9 @@ interface UserDao {
@Query("SELECT * FROM playback_sessions WHERE id = :id")
suspend fun getPlaybackSession(id: String): PlaybackSessionEntity?
@Query("SELECT * FROM playback_sessions WHERE id = :id")
fun observePlaybackSession(id: String): Flow<PlaybackSessionEntity?>
@Query("SELECT * FROM playback_sessions ORDER BY updated_at DESC LIMIT 1")
suspend fun getLatestPlaybackSession(): PlaybackSessionEntity?
@@ -372,12 +376,36 @@ interface UserDao {
@Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position")
suspend fun getAllQueueEntries(sessionId: String): List<PlaybackQueueEntryEntity>
@Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position")
fun observeQueueEntries(sessionId: String): Flow<List<PlaybackQueueEntryEntity>>
@Upsert
suspend fun putQueueEntries(entries: List<PlaybackQueueEntryEntity>)
@Query("DELETE FROM playback_queue_entries WHERE session_id = :sessionId")
suspend fun clearQueueEntries(sessionId: String)
@Query(
"""
UPDATE playback_queue_entries
SET position = position + :offset
WHERE session_id = :sessionId AND position >= :start
""",
)
suspend fun parkQueuePositions(
sessionId: String,
start: Long,
offset: Long,
)
@Query(
"""
DELETE FROM playback_queue_entries
WHERE session_id = :sessionId AND entry_id IN (:entryIds)
""",
)
suspend fun deleteQueueEntriesById(sessionId: String, entryIds: List<Long>)
@Query("SELECT COUNT(*) FROM playback_queue_entries WHERE session_id = :sessionId")
suspend fun countQueueEntries(sessionId: String): Long
@@ -390,6 +418,27 @@ interface UserDao {
@Query("DELETE FROM playback_original_queue_entries WHERE session_id = :sessionId")
suspend fun clearOriginalQueueEntries(sessionId: String)
@Query(
"""
UPDATE playback_original_queue_entries
SET position = position + :offset
WHERE session_id = :sessionId AND position >= :start
""",
)
suspend fun parkOriginalQueuePositions(
sessionId: String,
start: Long,
offset: Long,
)
@Query(
"""
DELETE FROM playback_original_queue_entries
WHERE session_id = :sessionId AND entry_id IN (:entryIds)
""",
)
suspend fun deleteOriginalQueueEntriesById(sessionId: String, entryIds: List<Long>)
@Query("SELECT * FROM snapshot_metadata WHERE id = 1")
suspend fun getSnapshotMetadata(): SnapshotMetadataEntity?
@@ -409,6 +458,88 @@ interface UserDao {
if (originalEntries.isNotEmpty()) putOriginalQueueEntries(originalEntries)
}
/**
* Rewrites only the position range changed by an edit. Rows are parked in a
* disjoint position space first so the unique (session, position) index never
* observes a transient collision during moves.
*/
@Transaction
suspend fun applyPlaybackQueueMutation(
session: PlaybackSessionEntity,
entries: List<PlaybackQueueEntryEntity>,
originalEntries: List<PlaybackOriginalQueueEntryEntity>,
) {
val oldEntries = getAllQueueEntries(session.id)
putPlaybackSession(session)
val changedAt = firstChangedQueuePosition(oldEntries, entries)
if (changedAt != null) {
parkQueuePositions(session.id, changedAt.toLong(), 1_000_000_000_000L)
val retainedIds = entries.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
val removedIds = oldEntries
.asSequence()
.map(PlaybackQueueEntryEntity::entryId)
.filterNot(retainedIds::contains)
.toList()
if (removedIds.isNotEmpty()) deleteQueueEntriesById(session.id, removedIds)
val changedRows = entries.drop(changedAt)
if (changedRows.isNotEmpty()) putQueueEntries(changedRows)
}
val oldOriginal = getOriginalQueueEntries(session.id)
if (originalEntries.isEmpty()) {
if (oldOriginal.isNotEmpty()) clearOriginalQueueEntries(session.id)
return
}
val originalChangedAt = firstChangedOriginalPosition(oldOriginal, originalEntries)
if (originalChangedAt != null) {
parkOriginalQueuePositions(
session.id,
originalChangedAt.toLong(),
1_000_000_000_000L,
)
val retainedIds = originalEntries
.mapTo(hashSetOf(), PlaybackOriginalQueueEntryEntity::entryId)
val removedIds = oldOriginal
.asSequence()
.map(PlaybackOriginalQueueEntryEntity::entryId)
.filterNot(retainedIds::contains)
.toList()
if (removedIds.isNotEmpty()) {
deleteOriginalQueueEntriesById(session.id, removedIds)
}
val changedRows = originalEntries.drop(originalChangedAt)
if (changedRows.isNotEmpty()) putOriginalQueueEntries(changedRows)
}
}
private fun firstChangedQueuePosition(
before: List<PlaybackQueueEntryEntity>,
after: List<PlaybackQueueEntryEntity>,
): Int? {
val shared = minOf(before.size, after.size)
for (index in 0 until shared) {
if (
before[index].entryId != after[index].entryId ||
before[index].trackPath != after[index].trackPath
) return index
}
return shared.takeIf { before.size != after.size }
}
private fun firstChangedOriginalPosition(
before: List<PlaybackOriginalQueueEntryEntity>,
after: List<PlaybackOriginalQueueEntryEntity>,
): Int? {
val shared = minOf(before.size, after.size)
for (index in 0 until shared) {
if (
before[index].entryId != after[index].entryId ||
before[index].trackPath != after[index].trackPath
) return index
}
return shared.takeIf { before.size != after.size }
}
@Transaction
suspend fun replaceRemoteUserState(
sourceId: Long,
@@ -525,7 +656,7 @@ interface UserDao {
PlaybackOriginalQueueEntryEntity::class,
SnapshotMetadataEntity::class,
],
version = 2,
version = 3,
exportSchema = true,
)
abstract class AstraUserDatabase : RoomDatabase() {
@@ -608,3 +739,151 @@ internal val USER_MIGRATION_1_2 = object : Migration(1, 2) {
)
}
}
internal val USER_MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `queue_revision` INTEGER NOT NULL DEFAULT 0",
)
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `catalog_revision` INTEGER",
)
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `is_dirty` INTEGER NOT NULL DEFAULT 0",
)
database.execSQL(
"ALTER TABLE `playback_sessions` ADD COLUMN `next_entry_id` INTEGER NOT NULL DEFAULT 0",
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `playback_queue_entries_new` (
`session_id` TEXT NOT NULL,
`entry_id` INTEGER NOT NULL,
`position` INTEGER NOT NULL,
`track_path` TEXT NOT NULL,
PRIMARY KEY(`session_id`, `entry_id`),
FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent(),
)
database.execSQL(
"""
INSERT INTO `playback_queue_entries_new` (`session_id`, `entry_id`, `position`, `track_path`)
SELECT `session_id`, `position`, `position`, `track_path`
FROM `playback_queue_entries`
""".trimIndent(),
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `playback_original_queue_entries_new` (
`session_id` TEXT NOT NULL,
`entry_id` INTEGER NOT NULL,
`position` INTEGER NOT NULL,
`track_path` TEXT NOT NULL,
PRIMARY KEY(`session_id`, `entry_id`),
FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent(),
)
val idsBySessionAndPath = mutableMapOf<Pair<String, String>, java.util.ArrayDeque<Long>>()
var nextSyntheticId = 0L
database.query(
"""
SELECT `session_id`, `entry_id`, `track_path`
FROM `playback_queue_entries_new`
ORDER BY `session_id`, `position`
""".trimIndent(),
).use { cursor ->
while (cursor.moveToNext()) {
val sessionId = cursor.getString(0)
val entryId = cursor.getLong(1)
val path = cursor.getString(2)
idsBySessionAndPath
.getOrPut(sessionId to path) { java.util.ArrayDeque() }
.addLast(entryId)
nextSyntheticId = maxOf(nextSyntheticId, entryId + 1)
}
}
database.compileStatement(
"""
INSERT INTO `playback_original_queue_entries_new`
(`session_id`, `entry_id`, `position`, `track_path`)
VALUES (?, ?, ?, ?)
""".trimIndent(),
).use { insert ->
database.query(
"""
SELECT `session_id`, `position`, `track_path`
FROM `playback_original_queue_entries`
ORDER BY `session_id`, `position`
""".trimIndent(),
).use { cursor ->
while (cursor.moveToNext()) {
val sessionId = cursor.getString(0)
val position = cursor.getLong(1)
val path = cursor.getString(2)
val matchingIds = idsBySessionAndPath[sessionId to path]
val entryId = if (matchingIds != null && matchingIds.isNotEmpty()) {
matchingIds.removeFirst()
} else {
nextSyntheticId++
}
insert.clearBindings()
insert.bindString(1, sessionId)
insert.bindLong(2, entryId)
insert.bindLong(3, position)
insert.bindString(4, path)
insert.executeInsert()
}
}
}
database.execSQL("DROP TABLE `playback_queue_entries`")
database.execSQL("ALTER TABLE `playback_queue_entries_new` RENAME TO `playback_queue_entries`")
database.execSQL("DROP TABLE `playback_original_queue_entries`")
database.execSQL(
"ALTER TABLE `playback_original_queue_entries_new` RENAME TO `playback_original_queue_entries`",
)
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_position` " +
"ON `playback_queue_entries` (`session_id`, `position`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_track_path` " +
"ON `playback_queue_entries` (`session_id`, `track_path`)",
)
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_position` " +
"ON `playback_original_queue_entries` (`session_id`, `position`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_track_path` " +
"ON `playback_original_queue_entries` (`session_id`, `track_path`)",
)
database.execSQL(
"""
UPDATE `playback_sessions`
SET `queue_revision` = CASE
WHEN EXISTS (
SELECT 1 FROM `playback_queue_entries`
WHERE `session_id` = `playback_sessions`.`id`
) THEN 1
ELSE 0
END,
`next_entry_id` = COALESCE(
(
SELECT MAX(`entry_id`) + 1
FROM `playback_queue_entries`
WHERE `session_id` = `playback_sessions`.`id`
),
0
)
""".trimIndent(),
)
}
}
@@ -226,11 +226,15 @@ data class PlaybackSessionEntity(
@ColumnInfo(name = "active_position") val activePosition: Long,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "queue_revision") val queueRevision: Long = 0,
@ColumnInfo(name = "catalog_revision") val catalogRevision: Long? = null,
@ColumnInfo(name = "is_dirty") val isDirty: Boolean = false,
@ColumnInfo(name = "next_entry_id") val nextEntryId: Long = 0,
)
@Entity(
tableName = "playback_queue_entries",
primaryKeys = ["session_id", "position"],
primaryKeys = ["session_id", "entry_id"],
foreignKeys = [
ForeignKey(
entity = PlaybackSessionEntity::class,
@@ -240,6 +244,7 @@ data class PlaybackSessionEntity(
),
],
indices = [
Index(value = ["session_id", "position"], unique = true),
Index(value = ["session_id", "track_path"]),
],
)
@@ -247,11 +252,12 @@ data class PlaybackQueueEntryEntity(
@ColumnInfo(name = "session_id") val sessionId: String,
val position: Long,
@ColumnInfo(name = "track_path") val trackPath: String,
@ColumnInfo(name = "entry_id") val entryId: Long = position,
)
@Entity(
tableName = "playback_original_queue_entries",
primaryKeys = ["session_id", "position"],
primaryKeys = ["session_id", "entry_id"],
foreignKeys = [
ForeignKey(
entity = PlaybackSessionEntity::class,
@@ -260,12 +266,16 @@ data class PlaybackQueueEntryEntity(
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index(value = ["session_id", "track_path"])],
indices = [
Index(value = ["session_id", "position"], unique = true),
Index(value = ["session_id", "track_path"]),
],
)
data class PlaybackOriginalQueueEntryEntity(
@ColumnInfo(name = "session_id") val sessionId: String,
val position: Long,
@ColumnInfo(name = "track_path") val trackPath: String,
@ColumnInfo(name = "entry_id") val entryId: Long = position,
)
@Entity(tableName = "snapshot_metadata")
@@ -336,6 +336,10 @@ private fun PlaybackSessionEntity.toJson() = JSONObject()
.put("activePosition", activePosition)
.put("createdAt", createdAt)
.put("updatedAt", updatedAt)
.put("queueRevision", queueRevision)
.putNullable("catalogRevision", catalogRevision)
.put("isDirty", isDirty)
.put("nextEntryId", nextEntryId)
private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity(
id = json.getString("id"),
@@ -345,26 +349,34 @@ private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity(
activePosition = json.getLong("activePosition"),
createdAt = json.getLong("createdAt"),
updatedAt = json.getLong("updatedAt"),
queueRevision = json.optLong("queueRevision", 0),
catalogRevision = json.nullableLong("catalogRevision"),
isDirty = json.optBoolean("isDirty", false),
nextEntryId = json.optLong("nextEntryId", 0),
)
private fun PlaybackQueueEntryEntity.toJson() = JSONObject()
.put("sessionId", sessionId)
.put("entryId", entryId)
.put("position", position)
.put("trackPath", trackPath)
private fun playbackQueueFromJson(json: JSONObject) = PlaybackQueueEntryEntity(
sessionId = json.getString("sessionId"),
entryId = json.optLong("entryId", json.getLong("position")),
position = json.getLong("position"),
trackPath = json.getString("trackPath"),
)
private fun PlaybackOriginalQueueEntryEntity.toJson() = JSONObject()
.put("sessionId", sessionId)
.put("entryId", entryId)
.put("position", position)
.put("trackPath", trackPath)
private fun playbackOriginalQueueFromJson(json: JSONObject) = PlaybackOriginalQueueEntryEntity(
sessionId = json.getString("sessionId"),
entryId = json.optLong("entryId", json.getLong("position")),
position = json.getLong("position"),
trackPath = json.getString("trackPath"),
)
@@ -0,0 +1,45 @@
package expo.modules.astralibraryscanner.queue
import android.content.Context
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
class AstraQueueView(
context: Context,
appContext: AppContext,
) : ExpoView(context, appContext) {
private val content = QueueContentView(context)
var active: Boolean = true
set(value) {
field = value
content.active = value
}
var palette: QueuePalette = QueuePalette()
set(value) {
field = value
content.palette = value
}
init {
addView(
content,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
)
}
fun setPlaybackRequestListener(listener: QueueContentView.PlaybackRequestListener?) {
content.playbackRequestListener = listener
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
content.attach()
}
override fun onDetachedFromWindow() {
content.detach()
super.onDetachedFromWindow()
}
}
@@ -0,0 +1,376 @@
package expo.modules.astralibraryscanner.queue
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Trace
import expo.modules.astralibraryscanner.data.ACTIVE_PLAYBACK_CONTEXT_ID
import expo.modules.astralibraryscanner.data.ActiveTrackView
import expo.modules.astralibraryscanner.data.AstraLibraryRepository
import expo.modules.astralibraryscanner.data.PlaybackQueueEntryEntity
import expo.modules.astralibraryscanner.data.PlaybackSessionEntity
import java.io.File
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private const val FIRST_METADATA_ROWS = 64
private const val METADATA_CHUNK = 250
private const val MAX_CACHED_ROWS = 10_000
data class QueueRowModel(
val entryId: Long,
val position: Long,
val trackPath: String,
val title: String,
val artist: String,
val artworkThumbPath: String?,
val durationSeconds: Double,
val hydrated: Boolean,
)
data class NativeQueueSnapshot(
val sessionId: String?,
val revision: Long,
val activePosition: Long,
val totalCount: Int,
val rows: List<QueueRowModel>,
val loading: Boolean,
) {
init {
require(rows.all(QueueRowModel::hydrated)) {
"Native queue snapshots must never expose unresolved fallback rows"
}
}
companion object {
val Empty = NativeQueueSnapshot(
sessionId = null,
revision = 0,
activePosition = -1,
totalCount = 0,
rows = emptyList(),
loading = false,
)
}
}
private data class PendingQueueMutation(
val baseRevision: Long,
val previousEntryIds: List<Long>,
)
class QueueCoordinator private constructor(
context: Context,
) {
private val applicationContext = context.applicationContext
private val repository = AstraLibraryRepository.get(applicationContext)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutationMutex = Mutex()
private val traceCookie = AtomicInteger()
private val listeners = CopyOnWriteArraySet<(NativeQueueSnapshot) -> Unit>()
private val mutableSnapshot = MutableStateFlow(NativeQueueSnapshot.Empty)
val snapshot: StateFlow<NativeQueueSnapshot> = mutableSnapshot.asStateFlow()
private var observationJob: Job? = null
private var hydrationGeneration = 0L
@Volatile
private var pendingMutation: PendingQueueMutation? = null
@Volatile
private var latestEntries: List<PlaybackQueueEntryEntity> = emptyList()
private val metadataByPath =
object : LinkedHashMap<String, ActiveTrackView?>(MAX_CACHED_ROWS, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, ActiveTrackView?>,
): Boolean = size > MAX_CACHED_ROWS
}
fun addListener(listener: (NativeQueueSnapshot) -> Unit) {
listeners += listener
listener(mutableSnapshot.value)
}
fun removeListener(listener: (NativeQueueSnapshot) -> Unit) {
listeners -= listener
}
fun start() {
if (observationJob != null) return
observationJob = scope.launch {
val dao = repository.userDb().userDao()
combine(
dao.observePlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID),
dao.observeQueueEntries(ACTIVE_PLAYBACK_CONTEXT_ID),
) { session, entries -> session to entries }
.collectLatest { (session, entries) ->
publishAndHydrate(session, entries)
}
}
}
fun refresh() {
scope.launch {
val dao = repository.userDb().userDao()
publishAndHydrate(
dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID),
dao.getAllQueueEntries(ACTIVE_PLAYBACK_CONTEXT_ID),
)
}
}
suspend fun move(entryId: Long, targetEntryId: Long): Boolean =
mutate { current ->
val entries = latestEntries
val from = entries.indexOfFirst { it.entryId == entryId }
val to = entries.indexOfFirst { it.entryId == targetEntryId }
if (from < 0 || to < 0 || from == to || from == current.activePosition.toInt()) {
false
} else {
repository.mutatePlaybackContext(
"move",
mapOf("from" to from, "to" to to),
)
true
}
}
suspend fun remove(entryIds: Set<Long>): Boolean =
mutate { current ->
val positions = latestEntries
.mapIndexedNotNull { index, row ->
index.takeIf {
row.entryId in entryIds && index != current.activePosition.toInt()
}
}
if (positions.isEmpty()) {
false
} else {
repository.mutatePlaybackContext("remove", mapOf("positions" to positions))
true
}
}
suspend fun moveAfterActive(entryIds: Set<Long>): Boolean =
mutate { current ->
val positions = latestEntries
.mapIndexedNotNull { index, row ->
index.takeIf {
row.entryId in entryIds && index != current.activePosition.toInt()
}
}
if (positions.isEmpty()) {
false
} else {
repository.mutatePlaybackContext(
"moveManyAfterActive",
mapOf("positions" to positions),
)
true
}
}
fun positionForEntry(entryId: Long, expectedRevision: Long): Long? {
val current = mutableSnapshot.value
if (current.revision != expectedRevision) return null
return latestEntries.firstOrNull { it.entryId == entryId }?.position
}
private suspend fun mutate(block: suspend (NativeQueueSnapshot) -> Boolean): Boolean =
mutationMutex.withLock {
traceAsync("AstraQueue.mutate") {
val marker = PendingQueueMutation(
baseRevision = mutableSnapshot.value.revision,
previousEntryIds = latestEntries.map(PlaybackQueueEntryEntity::entryId),
)
pendingMutation = marker
try {
block(mutableSnapshot.value).also { changed ->
if (!changed && pendingMutation === marker) pendingMutation = null
}
} catch (error: Throwable) {
if (pendingMutation === marker) pendingMutation = null
throw error
}
}
}
private suspend fun publishAndHydrate(
session: PlaybackSessionEntity?,
entries: List<PlaybackQueueEntryEntity>,
) {
pendingMutation?.let { pending ->
val incomingEntryIds = entries.map(PlaybackQueueEntryEntity::entryId)
if (incomingEntryIds != pending.previousEntryIds) {
if (pendingMutation === pending) pendingMutation = null
} else if (
session != null &&
session.queueRevision > pending.baseRevision
) {
// Room invalidates the session and queue Flow separately even though
// the write itself is transactional. Ignore the brief mixed pair
// ("new revision, old rows") so an optimistic drag/remove/play-next
// never jumps back before the queue invalidation arrives.
return
}
}
val generation = ++hydrationGeneration
if (session == null || entries.isEmpty()) {
latestEntries = emptyList()
publish(NativeQueueSnapshot.Empty)
return
}
latestEntries = entries
traceAsync("AstraQueue.rowHydration") {
val activeIndex = session.activePosition
.coerceIn(0L, (entries.size - 1).toLong())
.toInt()
val displayEntries = entries
.subList(activeIndex, entries.size)
.take(MAX_CACHED_ROWS)
resolveMetadata(
displayEntries.take(FIRST_METADATA_ROWS),
generation,
)
if (generation != hydrationGeneration) return@traceAsync
var loadedEnd = minOf(FIRST_METADATA_ROWS, displayEntries.size)
while (
loadedEnd < displayEntries.size &&
metadataResolved(displayEntries[loadedEnd])
) {
loadedEnd += 1
}
publishHydrated(
session = session,
totalCount = entries.size,
entries = displayEntries,
loadedEnd = loadedEnd,
)
var start = FIRST_METADATA_ROWS
while (start < displayEntries.size && generation == hydrationGeneration) {
val end = minOf(displayEntries.size, start + METADATA_CHUNK)
resolveMetadata(displayEntries.subList(start, end), generation)
if (generation != hydrationGeneration) return@traceAsync
publishHydrated(
session = session,
totalCount = entries.size,
entries = displayEntries,
loadedEnd = end,
)
start += METADATA_CHUNK
}
if (generation == hydrationGeneration) {
publish(mutableSnapshot.value.copy(loading = false))
}
}
}
private suspend fun <T> traceAsync(name: String, block: suspend () -> T): T {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return block()
val cookie = traceCookie.incrementAndGet()
Trace.beginAsyncSection(name, cookie)
return try {
block()
} finally {
Trace.endAsyncSection(name, cookie)
}
}
private suspend fun resolveMetadata(
entries: List<PlaybackQueueEntryEntity>,
generation: Long,
) {
if (entries.isEmpty() || generation != hydrationGeneration) return
val unresolvedPaths = entries
.map(PlaybackQueueEntryEntity::trackPath)
.distinct()
.filterNot(metadataByPath::containsKey)
if (unresolvedPaths.isEmpty()) return
val metadata = repository.nativeQueueTracks(unresolvedPaths)
.associateBy(ActiveTrackView::path)
if (generation != hydrationGeneration) return
unresolvedPaths.forEach { path ->
metadataByPath[path] = metadata[path]
}
}
private fun publishHydrated(
session: PlaybackSessionEntity,
totalCount: Int,
entries: List<PlaybackQueueEntryEntity>,
loadedEnd: Int,
) {
val rows = entries.take(loadedEnd).map { entry ->
val track = metadataByPath[entry.trackPath]
QueueRowModel(
entryId = entry.entryId,
position = entry.position,
trackPath = entry.trackPath,
title = track?.title
?.ifBlank { track.fileName }
?.ifBlank { readableFileName(entry.trackPath) }
?: readableFileName(entry.trackPath),
artist = track?.artist?.ifBlank { "Unknown artist" } ?: "Unknown artist",
artworkThumbPath = track?.artworkHash?.let { hash ->
File(applicationContext.filesDir, "artwork-thumbs/${thumbFileName(hash)}").path
},
durationSeconds = track?.duration ?: 0.0,
hydrated = true,
)
}
publish(
NativeQueueSnapshot(
sessionId = session.id,
revision = session.queueRevision,
activePosition = session.activePosition,
totalCount = totalCount,
rows = rows,
loading = loadedEnd < entries.size,
),
)
}
private fun metadataResolved(entry: PlaybackQueueEntryEntity): Boolean =
metadataByPath.containsKey(entry.trackPath)
private fun readableFileName(path: String): String {
val decoded = Uri.decode(path)
val leaf = decoded
.substringAfterLast('/')
.substringAfterLast(':')
return leaf.substringBeforeLast('.', leaf).ifBlank { "Unknown title" }
}
private fun publish(next: NativeQueueSnapshot) {
mutableSnapshot.value = next
listeners.forEach { it(next) }
}
private fun thumbFileName(hash: String): String {
val stem = hash.substringBeforeLast('.', hash)
return "$stem.jpg"
}
companion object {
@Volatile
private var instance: QueueCoordinator? = null
fun get(context: Context): QueueCoordinator =
instance ?: synchronized(this) {
instance ?: QueueCoordinator(context).also { instance = it }
}
}
}
@@ -0,0 +1,144 @@
package expo.modules.astralibraryscanner.queue
import android.content.Context
import android.media.AudioAttributes
import android.os.Build
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.provider.Settings
import android.view.HapticFeedbackConstants
import android.view.View
/**
* Native equivalents of Astra's selected queue recipes. Keeping these in the
* queue renderer avoids a JS round-trip at the exact moment a gesture arms,
* crosses a row boundary, or lands.
*/
class QueueHaptics(context: Context) {
private val applicationContext = context.applicationContext
private val vibrator: Vibrator =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(
applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE)
as VibratorManager
).defaultVibrator
} else {
@Suppress("DEPRECATION")
applicationContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
fun lift(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 0.7f),
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.5f, 45),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.DRAG_START)
}
}
fun drop(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.7f),
Primitive(VibrationEffect.Composition.PRIMITIVE_THUD, 0.5f, 30),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)
}
}
fun step(view: View) {
if (!compose(Primitive(VibrationEffect.Composition.PRIMITIVE_TICK, 0.35f))) {
view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK)
}
}
fun threshold(view: View, armed: Boolean) {
view.performHapticFeedback(
if (armed) {
HapticFeedbackConstants.GESTURE_START
} else {
HapticFeedbackConstants.GESTURE_END
},
)
}
fun selection(view: View) {
if (!compose(Primitive(VibrationEffect.Composition.PRIMITIVE_TICK, 0.55f))) {
view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_TICK)
}
}
fun confirm(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 0.45f),
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.75f, 45),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
}
}
fun reject(view: View) {
if (!compose(
Primitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.75f),
Primitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, 0.7f, 45),
)
) {
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
}
}
private fun compose(vararg primitives: Primitive): Boolean {
if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
primitives.isEmpty() ||
!vibrator.hasVibrator() ||
!touchFeedbackEnabled()
) {
return false
}
val ids = primitives.map(Primitive::id).toIntArray()
if (!vibrator.areAllPrimitivesSupported(*ids)) return false
return runCatching {
val composition = VibrationEffect.startComposition()
primitives.forEach { primitive ->
composition.addPrimitive(primitive.id, primitive.scale, primitive.delayMs)
}
vibrate(composition.compose())
true
}.getOrDefault(false)
}
private fun vibrate(effect: VibrationEffect) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
vibrator.vibrate(
effect,
VibrationAttributes.createForUsage(VibrationAttributes.USAGE_TOUCH),
)
} else {
vibrator.vibrate(
effect,
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.build(),
)
}
}
private fun touchFeedbackEnabled(): Boolean =
Settings.System.getInt(
applicationContext.contentResolver,
Settings.System.HAPTIC_FEEDBACK_ENABLED,
1,
) != 0
private data class Primitive(
val id: Int,
val scale: Float,
val delayMs: Int = 0,
)
}
@@ -0,0 +1,44 @@
package expo.modules.astralibraryscanner.queue
import android.graphics.Color
data class QueuePalette(
val background: Int = Color.rgb(24, 24, 28),
val surface: Int = Color.rgb(35, 35, 41),
val elevatedSurface: Int = Color.rgb(48, 48, 56),
val selectedSurface: Int = Color.rgb(57, 62, 75),
val nowPlayingSurface: Int = Color.rgb(31, 34, 43),
val divider: Int = Color.rgb(72, 72, 82),
val ripple: Int = Color.argb(38, 140, 162, 208),
val text: Int = Color.WHITE,
val textSecondary: Int = Color.rgb(190, 190, 200),
val textTertiary: Int = Color.rgb(135, 135, 148),
val accent: Int = Color.rgb(122, 162, 255),
val accentText: Int = Color.rgb(155, 181, 239),
val accentTextStrong: Int = Color.rgb(186, 205, 248),
val warning: Int = Color.rgb(255, 105, 105),
) {
companion object {
fun from(values: Map<String, Any?>?): QueuePalette {
val defaults = QueuePalette()
fun color(key: String, fallback: Int): Int =
(values?.get(key) as? Number)?.toInt() ?: fallback
return QueuePalette(
background = color("background", defaults.background),
surface = color("surface", defaults.surface),
elevatedSurface = color("elevatedSurface", defaults.elevatedSurface),
selectedSurface = color("selectedSurface", defaults.selectedSurface),
nowPlayingSurface = color("nowPlayingSurface", defaults.nowPlayingSurface),
divider = color("divider", defaults.divider),
ripple = color("ripple", defaults.ripple),
text = color("text", defaults.text),
textSecondary = color("textSecondary", defaults.textSecondary),
textTertiary = color("textTertiary", defaults.textTertiary),
accent = color("accent", defaults.accent),
accentText = color("accentText", defaults.accentText),
accentTextStrong = color("accentTextStrong", defaults.accentTextStrong),
warning = color("warning", defaults.warning),
)
}
}
}