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