From 771d7a034c8c4e06556f06c4fe5b792f5e3e3e59 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:25:54 -0400 Subject: [PATCH] rewrite entire queue system to native --- .../android/build.gradle | 19 + .../3.json | 1077 +++++++++++++++++ .../data/RoomLibraryRepositoryTest.kt | 63 + .../data/UserMigrationTest.kt | 77 ++ .../astralibraryscanner/AstraQueueModule.kt | 218 ++++ .../data/AstraLibraryRepository.kt | 246 +++- .../astralibraryscanner/data/UserDatabase.kt | 281 ++++- .../astralibraryscanner/data/UserEntities.kt | 16 +- .../data/UserSnapshotStore.kt | 12 + .../queue/AstraQueueView.kt | 45 + .../queue/QueueContentView.kt | 1036 ++++++++++++++++ .../queue/QueueCoordinator.kt | 376 ++++++ .../astralibraryscanner/queue/QueueHaptics.kt | 144 +++ .../astralibraryscanner/queue/QueuePalette.kt | 44 + .../expo-module.config.json | 3 +- modules/astra-library-scanner/index.ts | 16 +- modules/astra-library-scanner/queue.ts | 97 ++ package.json | 2 +- patches/react-native-track-player+4.1.2.patch | 123 +- src/app/settings/experimental.tsx | 17 +- src/audio/playbackController.ts | 323 ++++- src/audio/playbackService.ts | 4 + src/audio/queueLoader.ts | 65 +- src/audio/virtualPlaybackWindow.test.mts | 39 + src/audio/virtualPlaybackWindow.ts | 40 + .../player/NowPlayingCompanionPane.tsx | 22 +- src/components/player/NowPlayingOverlay.tsx | 58 +- src/stores/queueStore.ts | 118 +- src/stores/settingsStore.ts | 15 + 29 files changed, 4376 insertions(+), 220 deletions(-) create mode 100644 modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/3.json create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueHaptics.kt create mode 100644 modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueuePalette.kt create mode 100644 modules/astra-library-scanner/queue.ts create mode 100644 src/audio/virtualPlaybackWindow.test.mts create mode 100644 src/audio/virtualPlaybackWindow.ts diff --git a/modules/astra-library-scanner/android/build.gradle b/modules/astra-library-scanner/android/build.gradle index fdb49e7..45a4d5c 100644 --- a/modules/astra-library-scanner/android/build.gradle +++ b/modules/astra-library-scanner/android/build.gradle @@ -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' diff --git a/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/3.json b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/3.json new file mode 100644 index 0000000..d6f4e7b --- /dev/null +++ b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/3.json @@ -0,0 +1,1077 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "ee4ee00d93e490d2188e3db46058f8e9", + "entities": [ + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tree_uri` TEXT NOT NULL, `display_name` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `last_scanned_at` INTEGER, `last_scan_status` TEXT NOT NULL, `last_scan_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "treeUri", + "columnName": "tree_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScannedAt", + "columnName": "last_scanned_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanStatus", + "columnName": "last_scan_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastScanError", + "columnName": "last_scan_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_folders_tree_uri", + "unique": true, + "columnNames": [ + "tree_uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_folders_tree_uri` ON `${TABLE_NAME}` (`tree_uri`)" + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `last_played_at` INTEGER, `kind` TEXT NOT NULL, `dynamic_rules_json` TEXT, `remote_source_id` INTEGER, `remote_playlist_id` TEXT, `sync_uid` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dynamicRulesJson", + "columnName": "dynamic_rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteSourceId", + "columnName": "remote_source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "remotePlaylistId", + "columnName": "remote_playlist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sync_uid", + "unique": true, + "columnNames": [ + "sync_uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_sync_uid` ON `${TABLE_NAME}` (`sync_uid`)" + }, + { + "name": "index_playlists_remote_source_id_remote_playlist_id", + "unique": true, + "columnNames": [ + "remote_source_id", + "remote_playlist_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_remote_source_id_remote_playlist_id` ON `${TABLE_NAME}` (`remote_source_id`, `remote_playlist_id`)" + }, + { + "name": "index_playlists_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "playlist_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlist_id` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `position` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `fallback_title` TEXT, `fallback_artist` TEXT, `fallback_album` TEXT, FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fallbackTitle", + "columnName": "fallback_title", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackArtist", + "columnName": "fallback_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackAlbum", + "columnName": "fallback_album", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_tracks_playlist_id_position", + "unique": false, + "columnNames": [ + "playlist_id", + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_position` ON `${TABLE_NAME}` (`playlist_id`, `position`)" + }, + { + "name": "index_playlist_tracks_playlist_id_track_path", + "unique": true, + "columnNames": [ + "playlist_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_track_path` ON `${TABLE_NAME}` (`playlist_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + } + }, + { + "tableName": "playback_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `last_played_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + }, + "indices": [ + { + "name": "index_playback_history_last_played_at", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_history_last_played_at` ON `${TABLE_NAME}` (`last_played_at`)" + } + ] + }, + { + "tableName": "listening_history_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `generation` TEXT NOT NULL, `started_at` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "listening_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_key` TEXT NOT NULL, `generation` TEXT NOT NULL, `track_path` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_names_json` TEXT, `album` TEXT NOT NULL, `album_artist` TEXT, `album_artist_names_json` TEXT, `album_identity_key` TEXT NOT NULL, `artwork_hash` TEXT, `source_type` TEXT NOT NULL, `source_id` INTEGER, `artwork_source_id` TEXT, `duration_seconds` REAL NOT NULL, `started_at` INTEGER NOT NULL, `ended_at` INTEGER, `listened_seconds` REAL NOT NULL, `qualified_at` INTEGER, PRIMARY KEY(`session_key`))", + "fields": [ + { + "fieldPath": "sessionKey", + "columnName": "session_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistNamesJson", + "columnName": "artist_names_json", + "affinity": "TEXT" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtistNamesJson", + "columnName": "album_artist_names_json", + "affinity": "TEXT" + }, + { + "fieldPath": "albumIdentityKey", + "columnName": "album_identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "duration_seconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "ended_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "listenedSeconds", + "columnName": "listened_seconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "qualifiedAt", + "columnName": "qualified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_key" + ] + }, + "indices": [ + { + "name": "index_listening_sessions_generation_started_at", + "unique": false, + "columnNames": [ + "generation", + "started_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_started_at` ON `${TABLE_NAME}` (`generation`, `started_at`)" + }, + { + "name": "index_listening_sessions_generation_qualified_at", + "unique": false, + "columnNames": [ + "generation", + "qualified_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_qualified_at` ON `${TABLE_NAME}` (`generation`, `qualified_at`)" + }, + { + "name": "index_listening_sessions_track_path", + "unique": false, + "columnNames": [ + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_track_path` ON `${TABLE_NAME}` (`track_path`)" + } + ] + }, + { + "tableName": "listening_segments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_key` TEXT NOT NULL, `segment_key` TEXT NOT NULL, `generation` TEXT NOT NULL, `started_at` INTEGER NOT NULL, `last_observed_at` INTEGER NOT NULL, `ended_at` INTEGER, `listened_seconds` REAL NOT NULL, PRIMARY KEY(`session_key`, `segment_key`), FOREIGN KEY(`session_key`) REFERENCES `listening_sessions`(`session_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionKey", + "columnName": "session_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "segmentKey", + "columnName": "segment_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastObservedAt", + "columnName": "last_observed_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "ended_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "listenedSeconds", + "columnName": "listened_seconds", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_key", + "segment_key" + ] + }, + "indices": [ + { + "name": "index_listening_segments_generation_started_at_last_observed_at", + "unique": false, + "columnNames": [ + "generation", + "started_at", + "last_observed_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_segments_generation_started_at_last_observed_at` ON `${TABLE_NAME}` (`generation`, `started_at`, `last_observed_at`)" + }, + { + "name": "index_listening_segments_session_key", + "unique": false, + "columnNames": [ + "session_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_segments_session_key` ON `${TABLE_NAME}` (`session_key`)" + } + ], + "foreignKeys": [ + { + "table": "listening_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_key" + ], + "referencedColumns": [ + "session_key" + ] + } + ] + }, + { + "tableName": "remote_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `base_url` TEXT NOT NULL, `username` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_status` TEXT NOT NULL, `last_error` TEXT, `last_sync_at` INTEGER, `last_checked_at` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "base_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus", + "columnName": "last_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "last_error", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "last_checked_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_sources_type_name", + "unique": false, + "columnNames": [ + "type", + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_sources_type_name` ON `${TABLE_NAME}` (`type`, `name`)" + } + ] + }, + { + "tableName": "favorite_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "favorite_sync_pending", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "playlist_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playlist_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `local_updated_at` INTEGER NOT NULL, `remote_updated_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteUpdatedAt", + "columnName": "remote_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playback_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `context_json` TEXT NOT NULL, `anchor_path` TEXT, `shuffle_seed` INTEGER, `active_position` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `queue_revision` INTEGER NOT NULL, `catalog_revision` INTEGER, `is_dirty` INTEGER NOT NULL, `next_entry_id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextJson", + "columnName": "context_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "anchorPath", + "columnName": "anchor_path", + "affinity": "TEXT" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffle_seed", + "affinity": "INTEGER" + }, + { + "fieldPath": "activePosition", + "columnName": "active_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queueRevision", + "columnName": "queue_revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "catalogRevision", + "columnName": "catalog_revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nextEntryId", + "columnName": "next_entry_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playback_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `entry_id` INTEGER NOT NULL, PRIMARY KEY(`session_id`, `entry_id`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entry_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "entry_id" + ] + }, + "indices": [ + { + "name": "index_playback_queue_entries_session_id_position", + "unique": true, + "columnNames": [ + "session_id", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_position` ON `${TABLE_NAME}` (`session_id`, `position`)" + }, + { + "name": "index_playback_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_original_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `entry_id` INTEGER NOT NULL, PRIMARY KEY(`session_id`, `entry_id`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entry_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "entry_id" + ] + }, + "indices": [ + { + "name": "index_playback_original_queue_entries_session_id_position", + "unique": true, + "columnNames": [ + "session_id", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_position` ON `${TABLE_NAME}` (`session_id`, `position`)" + }, + { + "name": "index_playback_original_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "snapshot_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `last_snapshot_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSnapshotAt", + "columnName": "last_snapshot_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee4ee00d93e490d2188e3db46058f8e9')" + ] + } +} \ No newline at end of file diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt index a9b41c9..864dd5a 100644 --- a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt @@ -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)) diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt index 2d6b625..6006bfd 100644 --- a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt @@ -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()) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt new file mode 100644 index 0000000..c516805 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt @@ -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 -> + 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? -> + view.palette = QueuePalette.from(values) + } + OnViewDidUpdateProps { view -> + view.setPlaybackRequestListener { entryId, revision -> + emitPlaybackRequest(entryId, revision) + } + } + } + } + + private fun presentDialog(options: Map) { + 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 + 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( + 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()) + } + } + } + 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(), + ), + ) + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt index 5b035af..8151c8e 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt @@ -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 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 = 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? { @@ -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, 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, 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): List { + 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 = diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt index fab0f1a..dda578c 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt @@ -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 + @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 + @Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position") + fun observeQueueEntries(sessionId: String): Flow> + @Upsert suspend fun putQueueEntries(entries: List) @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) + @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) + @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, + originalEntries: List, + ) { + 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, + after: List, + ): 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, + after: List, + ): 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, java.util.ArrayDeque>() + 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(), + ) + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt index 1889a35..8f41f39 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt @@ -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") diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt index 24200dd..b85d0cd 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt @@ -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"), ) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt new file mode 100644 index 0000000..fe63149 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt @@ -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() + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt new file mode 100644 index 0000000..e965c3a --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt @@ -0,0 +1,1036 @@ +package expo.modules.astralibraryscanner.queue + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.google.android.material.snackbar.Snackbar +import java.io.File +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +private val NON_INTER_CHARACTER = + Regex("[^\\u0000-\\u024F\\u0370-\\u03FF\\u0400-\\u04FF\\u2000-\\u206F\\u20A0-\\u20CF\\u2100-\\u214F]") +private const val MAX_ANIMATED_REORDER_ROWS = 48 + +class QueueContentView( + context: Context, +) : LinearLayout(context) { + fun interface PlaybackRequestListener { + fun onPlaybackRequest(entryId: Long, queueRevision: Long) + } + + private val coordinator = QueueCoordinator.get(context) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val selectedIds = linkedSetOf() + private val haptics = QueueHaptics(context) + var palette: QueuePalette = QueuePalette() + set(value) { + field = value + applyPalette() + } + private val regularTypeface = loadTypeface("Inter_400Regular.ttf", Typeface.NORMAL) + private val mediumTypeface = loadTypeface("Inter_500Medium.ttf", Typeface.NORMAL) + private val semiboldTypeface = loadTypeface("Inter_600SemiBold.ttf", Typeface.BOLD) + private val adapter = QueueAdapter() + private val recycler = RecyclerView(context) + private val layoutManager = LinearLayoutManager(context) + private val sheetHandle = View(context) + private val titleView = label("Queue", 20f, semiboldTypeface) + private val countView = label("No songs next", 12f, mediumTypeface) + private val editButton = label("Edit", 12f, mediumTypeface) + private val playingNowLabel = label("PLAYING NOW", 11f, regularTypeface) + private val upNextLabel = label("UP NEXT", 11f, regularTypeface) + private val nowArtwork = ImageView(context) + private val nowTitle = label("Nothing playing", 15f, regularTypeface) + private val nowArtist = label("", 12f, mediumTypeface) + private val nowIndicator = ImageView(context) + private val nowCard = LinearLayout(context) + private val actionBar = LinearLayout(context) + private val playNextButton = label("Play next", 12f, mediumTypeface) + private val removeButton = label("Remove", 12f, mediumTypeface) + private val emptyView = label("Nothing queued", 14f, regularTypeface) + private val swipePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val swipeIconPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val swipeIconPath = Path() + private var latestSnapshot = NativeQueueSnapshot.Empty + private var coordinatorAttached = false + private var editMode = false + private var dragFromId: Long? = null + private var dragTargetId: Long? = null + private var swipeEntryId: Long? = null + private var swipeArmed = false + + var playbackRequestListener: PlaybackRequestListener? = null + + var sheetMode: Boolean = false + set(value) { + field = value + sheetHandle.visibility = if (value) VISIBLE else GONE + applyPalette() + } + + var active: Boolean = true + set(value) { + field = value + if (value) attach() else detach() + } + + private val coordinatorListener: (NativeQueueSnapshot) -> Unit = { snapshot -> + post { render(snapshot) } + } + + private val touchHelper: ItemTouchHelper = ItemTouchHelper( + object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, + ) { + override fun isLongPressDragEnabled(): Boolean = false + + override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float = + (dp(84).toFloat() / max(1, viewHolder.itemView.width)) + .coerceIn(0.16f, 0.42f) + + override fun getSwipeEscapeVelocity(defaultValue: Float): Float = + defaultValue * 0.62f + + override fun getSwipeVelocityThreshold(defaultValue: Float): Float = + defaultValue * 0.72f + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ): Int { + val drag = if (editMode) 0 else ItemTouchHelper.UP or ItemTouchHelper.DOWN + val swipe = if (editMode) 0 else ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + return makeMovementFlags(drag, swipe) + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && viewHolder != null) { + recycler.itemAnimator = createDragItemAnimator() + adapter.rowAt(viewHolder.bindingAdapterPosition)?.let { row -> + dragFromId = row.entryId + dragTargetId = row.entryId + } + haptics.lift(viewHolder.itemView) + viewHolder.itemView.alpha = 0.96f + viewHolder.itemView.scaleX = 1.02f + viewHolder.itemView.scaleY = 1.02f + } + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean { + val from = viewHolder.bindingAdapterPosition + val to = target.bindingAdapterPosition + val targetId = adapter.rowAt(to)?.entryId ?: return false + if (!adapter.move(from, to)) return false + dragTargetId = targetId + haptics.step(target.itemView) + return true + } + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) { + super.clearView(recyclerView, viewHolder) + viewHolder.itemView.alpha = 1f + viewHolder.itemView.scaleX = 1f + viewHolder.itemView.scaleY = 1f + swipeEntryId = null + swipeArmed = false + val from = dragFromId + val to = dragTargetId + dragFromId = null + dragTargetId = null + if (from != null) { + // Animation is useful while neighboring rows make room for the + // dragged holder. End it at drop so later Room reconciliation and + // swipe recovery stay visually exact. + recycler.itemAnimator = null + } + if (from == null || to == null || from == to) return + haptics.drop(viewHolder.itemView) + launchMutation("Could not reorder the queue") { + coordinator.move(from, to) + } + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val entryId = viewHolder.itemId + val position = adapter.positionOf(entryId) + if (position < 0) { + adapter.notifyDataSetChanged() + return + } + if (direction == ItemTouchHelper.RIGHT) { + // A successful swipe remains in ItemTouchHelper's pending-cleanup + // set until its holder is detached. This row is moved rather than + // removed, so reset the helper to run clearView before RecyclerView + // reuses the same stable holder at position zero. + recycler.post { + touchHelper.attachToRecyclerView(null) + ItemTouchHelper.Callback.getDefaultUIUtil().clearView(viewHolder.itemView) + adapter.moveSwipedToFront(position) + touchHelper.attachToRecyclerView(recycler) + launchMutation("Could not move the song") { + coordinator.moveAfterActive(setOf(entryId)) + } + } + } else { + adapter.removeAt(position) + launchMutation("Could not remove the song") { + coordinator.remove(setOf(entryId)) + } + } + haptics.confirm(viewHolder.itemView) + swipeEntryId = null + swipeArmed = false + } + + override fun onChildDraw( + canvas: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean, + ) { + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + drawSwipeLane(canvas, viewHolder.itemView, dX) + val entryId = viewHolder.itemId + val armedNow = abs(dX) >= dp(84) + if (swipeEntryId != entryId) { + swipeEntryId = entryId + swipeArmed = false + } + if (armedNow != swipeArmed) { + swipeArmed = armedNow + haptics.threshold(viewHolder.itemView, armedNow) + } + } + super.onChildDraw( + canvas, + recyclerView, + viewHolder, + dX, + dY, + actionState, + isCurrentlyActive, + ) + } + }, + ) + + init { + orientation = VERTICAL + clipToPadding = false + + sheetHandle.visibility = GONE + addView( + sheetHandle, + LayoutParams(dp(38), dp(4)).apply { + gravity = Gravity.CENTER_HORIZONTAL + topMargin = dp(8) + bottomMargin = dp(8) + }, + ) + + val header = LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(16), 0, dp(8), dp(12)) + } + val headerText = LinearLayout(context).apply { + orientation = VERTICAL + addView(titleView) + addView(countView) + } + header.addView(headerText, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + editButton.apply { + gravity = Gravity.CENTER + setPadding(dp(16), dp(8), dp(16), dp(8)) + isClickable = true + isFocusable = true + contentDescription = "Edit queue" + setOnClickListener { setEditMode(!editMode) } + } + header.addView(editButton) + addView(header) + + addView(playingNowLabel.apply { + setPadding(dp(16), 0, dp(16), dp(4)) + }) + + nowCard.apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(7), dp(12), dp(7)) + } + nowArtwork.scaleType = ImageView.ScaleType.CENTER_CROP + prepareArtwork(nowArtwork) + nowCard.addView(nowArtwork, LayoutParams(dp(42), dp(42))) + val nowText = LinearLayout(context).apply { + orientation = VERTICAL + setPadding(dp(12), 0, dp(8), 0) + addView(nowTitle) + addView(nowArtist) + } + nowCard.addView(nowText, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + nowIndicator.setImageResource(android.R.drawable.ic_lock_silent_mode_off) + nowIndicator.contentDescription = "Playing now" + nowCard.addView(nowIndicator, LayoutParams(dp(22), dp(22))) + addView( + nowCard, + LayoutParams(LayoutParams.MATCH_PARENT, dp(64)).apply { + marginStart = dp(16) + marginEnd = dp(16) + }, + ) + + addView(upNextLabel.apply { + setPadding(dp(16), dp(12), dp(16), dp(4)) + }) + + recycler.layoutManager = layoutManager + recycler.adapter = adapter + recycler.setHasFixedSize(true) + recycler.itemAnimator = null + recycler.setItemViewCacheSize(12) + recycler.clipToPadding = false + recycler.setPadding(0, 0, 0, dp(30)) + touchHelper.attachToRecyclerView(recycler) + addView(recycler, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) + + emptyView.gravity = Gravity.CENTER + emptyView.visibility = GONE + addView(emptyView, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) + + actionBar.orientation = HORIZONTAL + actionBar.gravity = Gravity.CENTER + actionBar.setPadding(dp(12), dp(8), dp(12), dp(12)) + playNextButton.gravity = Gravity.CENTER + removeButton.gravity = Gravity.CENTER + actionBar.addView(playNextButton, LayoutParams(0, dp(48), 1f)) + actionBar.addView(removeButton, LayoutParams(0, dp(48), 1f)) + actionBar.visibility = GONE + addView(actionBar) + ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + val bottomInset = + insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + recycler.setPadding(0, 0, 0, dp(30) + bottomInset) + actionBar.setPadding(dp(12), dp(8), dp(12), dp(12) + bottomInset) + insets + } + + playNextButton.setOnClickListener { + val selected = selectedIds.toSet() + if (selected.isEmpty()) return@setOnClickListener + adapter.moveIdsToFront(selected) + clearSelection() + haptics.confirm(playNextButton) + launchMutation("Could not move the selected songs") { + coordinator.moveAfterActive(selected) + } + } + removeButton.setOnClickListener { + val selected = selectedIds.toSet() + if (selected.isEmpty()) return@setOnClickListener + adapter.removeIds(selected) + clearSelection() + haptics.confirm(removeButton) + launchMutation("Could not remove the selected songs") { + coordinator.remove(selected) + } + } + + adapter.onRowClick = { row -> + if (editMode) { + toggleSelected(row.entryId) + } else { + haptics.selection(this) + playbackRequestListener?.onPlaybackRequest(row.entryId, latestSnapshot.revision) + } + } + adapter.onSelectionClick = { row -> toggleSelected(row.entryId) } + adapter.onDragTouch = { holder, event -> + if (!editMode && event.actionMasked == MotionEvent.ACTION_DOWN) { + touchHelper.startDrag(holder) + } + } + applyPalette() + } + + fun attach() { + if (!active || coordinatorAttached) return + coordinatorAttached = true + coordinator.addListener(coordinatorListener) + coordinator.start() + } + + fun detach() { + if (!coordinatorAttached) return + coordinatorAttached = false + coordinator.removeListener(coordinatorListener) + } + + fun showPlaybackResult(success: Boolean, message: String?) { + if (!success) { + Snackbar.make( + this, + message ?: "Could not play that song", + Snackbar.LENGTH_SHORT, + ).show() + coordinator.refresh() + } + } + + private fun render(snapshot: NativeQueueSnapshot) { + latestSnapshot = snapshot + val activeIndex = snapshot.activePosition.toInt() + val current = snapshot.rows.firstOrNull { + it.position == snapshot.activePosition + } + val upcoming = snapshot.rows.filter { + it.position > snapshot.activePosition + } + + countView.text = when (val count = maxOf(0, snapshot.totalCount - activeIndex - 1)) { + 0 -> "No songs next" + 1 -> "1 song next" + else -> "$count songs next" + } + nowTitle.text = current?.title ?: "Nothing playing" + nowArtist.text = current?.artist.orEmpty() + loadArtwork(nowArtwork, current?.artworkThumbPath) + + val firstVisible = layoutManager.findFirstVisibleItemPosition() + val anchorId = adapter.rowAt(firstVisible)?.entryId + val anchorOffset = if (firstVisible >= 0) { + layoutManager.findViewByPosition(firstVisible)?.top ?: 0 + } else { + 0 + } + selectedIds.retainAll(upcoming.mapTo(hashSetOf(), QueueRowModel::entryId)) + adapter.submit(upcoming, selectedIds, editMode) { + val anchorPosition = anchorId?.let { id -> + upcoming.indexOfFirst { it.entryId == id }.takeIf { it >= 0 } + } + if (anchorPosition != null) { + layoutManager.scrollToPositionWithOffset(anchorPosition, anchorOffset) + } + } + recycler.visibility = if (upcoming.isEmpty()) GONE else VISIBLE + emptyView.visibility = if (upcoming.isEmpty()) VISIBLE else GONE + updateActionBar() + } + + private fun setEditMode(enabled: Boolean) { + editMode = enabled + if (!enabled) selectedIds.clear() + haptics.selection(editButton) + editButton.text = if (enabled) "Cancel" else "Edit" + editButton.contentDescription = if (enabled) "Cancel queue editing" else "Edit queue" + adapter.submit(adapter.rows(), selectedIds, editMode) + updateActionBar() + } + + private fun toggleSelected(entryId: Long) { + if (!selectedIds.add(entryId)) selectedIds.remove(entryId) + haptics.selection(recycler) + adapter.updateSelection(selectedIds) + updateActionBar() + } + + private fun clearSelection() { + selectedIds.clear() + adapter.updateSelection(selectedIds) + updateActionBar() + } + + private fun updateActionBar() { + val count = selectedIds.size + actionBar.visibility = if (editMode && count > 0) VISIBLE else GONE + playNextButton.text = "Play next ($count)" + removeButton.text = "Remove ($count)" + } + + private fun launchMutation(errorMessage: String, block: suspend () -> Boolean): Job = + scope.launch { + val success = runCatching { + kotlinx.coroutines.withContext(Dispatchers.IO) { block() } + }.getOrDefault(false) + if (!success) { + haptics.reject(this@QueueContentView) + Snackbar.make(this@QueueContentView, errorMessage, Snackbar.LENGTH_SHORT).show() + coordinator.refresh() + } + } + + private fun applyPalette() { + background = if (sheetMode) { + topRounded(palette.background, 16f) + } else { + ColorDrawable(palette.background) + } + sheetHandle.background = rounded(palette.divider, 999f) + titleView.setTextColor(palette.text) + countView.setTextColor(palette.textTertiary) + editButton.setTextColor(palette.accent) + playingNowLabel.setTextColor(palette.textTertiary) + upNextLabel.setTextColor(palette.textTertiary) + nowTitle.setTextColor(palette.accentTextStrong) + nowArtist.setTextColor(palette.accentText) + nowIndicator.imageTintList = ColorStateList.valueOf(palette.accent) + emptyView.setTextColor(palette.textSecondary) + playNextButton.setTextColor(palette.accent) + removeButton.setTextColor(palette.warning) + nowCard.background = roundedWithBorder( + palette.nowPlayingSurface, + palette.divider, + 6f, + ) + actionBar.setBackgroundColor(palette.elevatedSurface) + adapter.palette = palette + } + + private fun label(text: String, sizeSp: Float, font: Typeface): TextView = + TextView(context).apply { + this.text = text + textSize = sizeSp + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + setTextColor(palette.text) + typeface = font + includeFontPadding = false + } + + private fun rounded(color: Int, radiusDp: Float): GradientDrawable = + GradientDrawable().apply { + setColor(color) + cornerRadius = dp(radiusDp.toInt()).toFloat() + } + + private fun roundedWithBorder( + color: Int, + borderColor: Int, + radiusDp: Float, + ): GradientDrawable = + rounded(color, radiusDp).apply { + setStroke(max(1, dp(1) / 2), borderColor) + } + + private fun topRounded(color: Int, radiusDp: Float): GradientDrawable = + GradientDrawable().apply { + setColor(color) + val radius = dp(radiusDp.toInt()).toFloat() + cornerRadii = floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) + } + + private fun createDragItemAnimator(): DefaultItemAnimator = + DefaultItemAnimator().apply { + // ItemTouchHelper moves the held row itself. This short animation lets + // the surrounding rows glide into their slots as each boundary is + // crossed without adding cross-fades to later data reconciliation. + moveDuration = 140 + addDuration = 0 + removeDuration = 0 + changeDuration = 0 + supportsChangeAnimations = false + } + + private fun dp(value: Int): Int = + (value * resources.displayMetrics.density).toInt() + + private fun prepareArtwork(view: ImageView) { + view.background = rounded(palette.elevatedSurface, 6f) + view.clipToOutline = true + view.outlineProvider = ViewOutlineProvider.BACKGROUND + } + + private fun loadArtwork(view: ImageView, path: String?) { + if (path != null && File(path).isFile) { + Glide.with(view).load(File(path)).centerCrop().into(view) + } else { + Glide.with(view).clear(view) + view.setImageDrawable(null) + view.background = rounded(palette.elevatedSurface, 6f) + } + } + + private fun loadTypeface(assetName: String, fallbackStyle: Int): Typeface = + runCatching { Typeface.createFromAsset(context.assets, assetName) } + .getOrElse { Typeface.create("sans-serif", fallbackStyle) } + + private fun typefaceFor( + text: String, + latinTypeface: Typeface, + fallbackStyle: Int, + ): Typeface = + if (NON_INTER_CHARACTER.containsMatchIn(text)) { + Typeface.create("sans-serif", fallbackStyle) + } else { + latinTypeface + } + + private fun drawSwipeLane(canvas: Canvas, row: View, dX: Float) { + if (dX == 0f) return + val swipingRight = dX > 0 + swipePaint.color = if (swipingRight) palette.accent else palette.warning + val left = if (swipingRight) row.left.toFloat() else row.right + dX + val right = if (swipingRight) row.left + dX else row.right.toFloat() + canvas.drawRect(left, row.top.toFloat(), right, row.bottom.toFloat(), swipePaint) + + swipeIconPaint.apply { + color = palette.background + style = Paint.Style.FILL + strokeWidth = dp(2).toFloat() + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + val centerY = (row.top + row.bottom) / 2f + if (swipingRight) { + val centerX = row.left + dp(27).toFloat() + val half = dp(7).toFloat() + swipeIconPath.reset() + swipeIconPath.moveTo(centerX - half, centerY - half) + swipeIconPath.lineTo(centerX + dp(3), centerY) + swipeIconPath.lineTo(centerX - half, centerY + half) + swipeIconPath.close() + canvas.drawPath(swipeIconPath, swipeIconPaint) + canvas.drawRect( + centerX + dp(5), + centerY - half, + centerX + dp(7), + centerY + half, + swipeIconPaint, + ) + } else { + val centerX = row.right - dp(27).toFloat() + val halfWidth = dp(6).toFloat() + val top = centerY - dp(6) + swipeIconPaint.style = Paint.Style.STROKE + canvas.drawRoundRect( + centerX - halfWidth, + top, + centerX + halfWidth, + centerY + dp(8), + dp(1).toFloat(), + dp(1).toFloat(), + swipeIconPaint, + ) + canvas.drawLine( + centerX - dp(8), + centerY - dp(9), + centerX + dp(8), + centerY - dp(9), + swipeIconPaint, + ) + canvas.drawLine( + centerX - dp(3), + centerY - dp(11), + centerX + dp(3), + centerY - dp(11), + swipeIconPaint, + ) + swipeIconPaint.style = Paint.Style.FILL + } + } + + private inner class QueueAdapter : RecyclerView.Adapter() { + private val items = mutableListOf() + private var selected = emptySet() + private var editing = false + private var submitGeneration = 0L + + var palette: QueuePalette = this@QueueContentView.palette + set(value) { + field = value + notifyItemRangeChanged(0, itemCount) + } + var onRowClick: ((QueueRowModel) -> Unit)? = null + var onSelectionClick: ((QueueRowModel) -> Unit)? = null + var onDragTouch: ((RecyclerView.ViewHolder, MotionEvent) -> Unit)? = null + + init { + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long = items[position].entryId + override fun getItemCount(): Int = items.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + QueueRowHolder(QueueRowView(parent.context)) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + holder.itemView.translationX = 0f + holder.itemView.translationY = 0f + holder.itemView.alpha = 1f + holder.itemView.scaleX = 1f + holder.itemView.scaleY = 1f + (holder as QueueRowHolder).bind(items[position]) + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + val rowHolder = holder as QueueRowHolder + Glide.with(rowHolder.row.artwork).clear(rowHolder.row.artwork) + super.onViewRecycled(holder) + } + + fun submit( + rows: List, + selectedIds: Set, + editMode: Boolean, + onCommitted: () -> Unit = {}, + ) { + val structureChanged = + rows.size != items.size || rows.indices.any { items[it].entryId != rows[it].entryId } + val selectionChanged = selected != selectedIds + val editModeChanged = editing != editMode + selected = selectedIds.toSet() + editing = editMode + if (!structureChanged) { + submitGeneration += 1 + var firstChanged = -1 + var lastChanged = -1 + rows.indices.forEach { index -> + if (items[index] != rows[index]) { + items[index] = rows[index] + if (firstChanged < 0) firstChanged = index + lastChanged = index + } + } + if (selectionChanged || editModeChanged) { + notifyItemRangeChanged(0, items.size) + } else if (firstChanged >= 0) { + notifyItemRangeChanged(firstChanged, lastChanged - firstChanged + 1) + } + onCommitted() + return + } + + val previous = items.toList() + val next = rows.toList() + val generation = ++submitGeneration + if (previous.isEmpty() || next.isEmpty()) { + items.clear() + items.addAll(next) + notifyDataSetChanged() + onCommitted() + return + } + // A shuffle can relocate nearly every stable ID. Dispatching thousands of + // individual DiffUtil move callbacks makes RecyclerView spend several + // frames bookkeeping animations even though only the visible holders need + // to be rebound. Stable IDs let a single invalidation preserve the visible + // rows while keeping the mutation cost bounded. + if ( + previous.size == next.size && + previous.indices.count { + previous[it].entryId != next[it].entryId + } > MAX_ANIMATED_REORDER_ROWS + ) { + items.clear() + items.addAll(next) + notifyDataSetChanged() + onCommitted() + return + } + scope.launch(Dispatchers.Default) { + val diff = DiffUtil.calculateDiff( + object : DiffUtil.Callback() { + override fun getOldListSize(): Int = previous.size + override fun getNewListSize(): Int = next.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + previous[oldItemPosition].entryId == next[newItemPosition].entryId + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + previous[oldItemPosition] == next[newItemPosition] + }, + true, + ) + kotlinx.coroutines.withContext(Dispatchers.Main.immediate) { + if (generation != submitGeneration) return@withContext + items.clear() + items.addAll(next) + diff.dispatchUpdatesTo(this@QueueAdapter) + onCommitted() + } + } + } + + fun updateSelection(selectedIds: Set) { + selected = selectedIds.toSet() + notifyItemRangeChanged(0, itemCount) + } + + fun rows(): List = items.toList() + fun rowAt(position: Int): QueueRowModel? = items.getOrNull(position) + fun positionOf(entryId: Long): Int = + items.indexOfFirst { it.entryId == entryId } + + fun move(from: Int, to: Int): Boolean { + if (from !in items.indices || to !in items.indices || from == to) return false + submitGeneration += 1 + val moved = items.removeAt(from) + items.add(to, moved) + notifyItemMoved(from, to) + return true + } + + fun moveSwipedToFront(from: Int) { + if (from !in items.indices) { + notifyDataSetChanged() + return + } + if (from == 0) { + notifyItemChanged(0) + return + } + submitGeneration += 1 + val moved = items.removeAt(from) + items.add(0, moved) + // ItemTouchHelper has just completed a removal-shaped gesture. A move + // notification in the same layout cycle makes RecyclerView preserve its + // swipe pre-layout holders, including the offscreen transform. Stable + // IDs plus one invalidation rebind only attached/prefetched rows and keep + // the complete queue data resident without that transient blank state. + notifyDataSetChanged() + } + + fun removeAt(position: Int) { + if (position !in items.indices) return + submitGeneration += 1 + items.removeAt(position) + notifyItemRemoved(position) + } + + fun removeIds(ids: Set) { + submitGeneration += 1 + items.removeAll { it.entryId in ids } + notifyDataSetChanged() + } + + fun moveIdsToFront(ids: Set) { + val selectedRows = items.filter { it.entryId in ids } + if (selectedRows.isEmpty()) return + submitGeneration += 1 + items.removeAll { it.entryId in ids } + items.addAll(0, selectedRows) + notifyDataSetChanged() + } + + private inner class QueueRowHolder( + val row: QueueRowView, + ) : RecyclerView.ViewHolder(row) { + fun bind(item: QueueRowModel) { + row.title.text = item.title + row.artist.text = item.artist + row.title.setTextColor(palette.text) + row.artist.setTextColor(palette.textSecondary) + row.title.typeface = typefaceFor(item.title, regularTypeface, Typeface.NORMAL) + row.artist.typeface = typefaceFor(item.artist, mediumTypeface, Typeface.NORMAL) + row.handle.tint = palette.textTertiary + row.checkbox.tint = palette.accent + row.checkbox.uncheckedTint = palette.textTertiary + row.checkbox.checkColor = palette.background + row.checkbox.checked = item.entryId in selected + row.checkbox.visibility = if (editing) VISIBLE else GONE + row.artwork.visibility = if (editing) GONE else VISIBLE + row.handle.visibility = if (editing) GONE else VISIBLE + row.setSurfaceColor( + if (item.entryId in selected) palette.selectedSurface else palette.surface, + ) + loadArtwork(row.artwork, item.artworkThumbPath) + row.setOnClickListener { onRowClick?.invoke(item) } + row.checkbox.setOnClickListener { onSelectionClick?.invoke(item) } + row.handle.setOnTouchListener { _, event -> + onDragTouch?.invoke(this, event) + false + } + row.contentDescription = if (editing) { + "${if (item.entryId in selected) "Selected" else "Not selected"}, ${item.title}, ${item.artist}" + } else { + "Play ${item.title} by ${item.artist}" + } + } + } + } + + private inner class QueueRowView(context: Context) : FrameLayout(context) { + val artwork = ImageView(context) + val title = label("", 15f, regularTypeface) + val artist = label("", 12f, mediumTypeface) + val checkbox = QueueSelectionView(context) + val handle = QueueHandleView(context) + private var dividerColor = palette.divider + + init { + isClickable = true + isFocusable = true + layoutParams = RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(64), + ) + setPadding(dp(12), dp(7), dp(10), dp(7)) + + artwork.scaleType = ImageView.ScaleType.CENTER_CROP + prepareArtwork(artwork) + addView( + artwork, + LayoutParams(dp(42), dp(42), Gravity.CENTER_VERTICAL).apply { + marginStart = dp(2) + }, + ) + addView( + checkbox, + LayoutParams(dp(34), LayoutParams.MATCH_PARENT, Gravity.START or Gravity.CENTER_VERTICAL), + ) + val textColumn = LinearLayout(context).apply { + orientation = VERTICAL + gravity = Gravity.CENTER_VERTICAL + addView(title) + addView(artist) + } + addView( + textColumn, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply { + marginStart = dp(62) + marginEnd = dp(42) + }, + ) + handle.contentDescription = "Reorder" + addView( + handle, + LayoutParams(dp(42), LayoutParams.MATCH_PARENT, Gravity.END or Gravity.CENTER_VERTICAL), + ) + } + + fun setSurfaceColor(color: Int) { + dividerColor = palette.divider + background = RippleDrawable( + ColorStateList.valueOf(palette.ripple), + ColorDrawable(color), + null, + ) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + swipePaint.color = dividerColor + canvas.drawRect( + paddingLeft.toFloat(), + (height - max(1, dp(1) / 2)).toFloat(), + width.toFloat(), + height.toFloat(), + swipePaint, + ) + } + } + + private inner class QueueHandleView(context: Context) : View(context) { + var tint: Int = palette.textTertiary + set(value) { + field = value + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + swipeIconPaint.apply { + color = tint + strokeWidth = dp(2).toFloat() + strokeCap = Paint.Cap.ROUND + } + val center = width / 2f + val half = dp(8).toFloat() + for (offset in intArrayOf(-5, 0, 5)) { + val y = height / 2f + dp(offset).toFloat() + canvas.drawLine(center - half, y, center + half, y, swipeIconPaint) + } + } + } + + private inner class QueueSelectionView(context: Context) : View(context) { + var checked: Boolean = false + set(value) { + field = value + invalidate() + } + var tint: Int = palette.accent + set(value) { + field = value + invalidate() + } + var uncheckedTint: Int = palette.textTertiary + set(value) { + field = value + invalidate() + } + var checkColor: Int = palette.background + set(value) { + field = value + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val radius = dp(9).toFloat() + val centerX = width / 2f + val centerY = height / 2f + swipeIconPaint.style = if (checked) Paint.Style.FILL else Paint.Style.STROKE + swipeIconPaint.strokeWidth = dp(1).coerceAtLeast(1).toFloat() + swipeIconPaint.color = if (checked) tint else uncheckedTint + canvas.drawCircle(centerX, centerY, radius, swipeIconPaint) + if (!checked) return + swipeIconPaint.apply { + style = Paint.Style.STROKE + strokeWidth = dp(2).toFloat() + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + color = checkColor + } + swipeIconPath.reset() + swipeIconPath.moveTo(centerX - dp(4), centerY) + swipeIconPath.lineTo(centerX - dp(1), centerY + dp(3)) + swipeIconPath.lineTo(centerX + dp(5), centerY - dp(4)) + canvas.drawPath(swipeIconPath, swipeIconPaint) + swipeIconPaint.style = Paint.Style.FILL + } + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt new file mode 100644 index 0000000..67a151f --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueCoordinator.kt @@ -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, + 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, +) + +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 = mutableSnapshot.asStateFlow() + + private var observationJob: Job? = null + private var hydrationGeneration = 0L + @Volatile + private var pendingMutation: PendingQueueMutation? = null + @Volatile + private var latestEntries: List = emptyList() + private val metadataByPath = + object : LinkedHashMap(MAX_CACHED_ROWS, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry, + ): 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): 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): 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, + ) { + 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 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, + 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, + 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 } + } + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueHaptics.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueHaptics.kt new file mode 100644 index 0000000..26a7de0 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueHaptics.kt @@ -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, + ) +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueuePalette.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueuePalette.kt new file mode 100644 index 0000000..88bdf78 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueuePalette.kt @@ -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?): 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), + ) + } + } +} diff --git a/modules/astra-library-scanner/expo-module.config.json b/modules/astra-library-scanner/expo-module.config.json index 26da79e..9015c17 100644 --- a/modules/astra-library-scanner/expo-module.config.json +++ b/modules/astra-library-scanner/expo-module.config.json @@ -3,7 +3,8 @@ "android": { "modules": [ "expo.modules.astralibraryscanner.AstraLibraryScannerModule", - "expo.modules.astralibraryscanner.AstraLibraryDataModule" + "expo.modules.astralibraryscanner.AstraLibraryDataModule", + "expo.modules.astralibraryscanner.AstraQueueModule" ] } } diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 6ccc2c2..9ee0c8a 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -251,12 +251,13 @@ export type LibraryQuery = export interface NativePlaybackWindow { sessionId: string; - items: (T & { queuePosition: number })[]; + items: (T & { queuePosition: number; queueEntryId: number })[]; windowStart: number; activePosition: number; totalCount: number; contextJson: string; shuffleSeed: number | null; + queueRevision: number; catalogRevision: string; } @@ -532,3 +533,16 @@ declare class AstraLibraryDataModuleType extends NativeModule('AstraLibraryData'); + +export { + AstraQueue, + AstraQueueView, + toNativeQueuePalette, +} from './queue'; +export type { + AstraQueueViewProps, + NativeQueuePlaybackRequest, + NativeQueuePalette, + NativeQueuePresentationOptions, + NativeQueueRevisionEvent, +} from './queue'; diff --git a/modules/astra-library-scanner/queue.ts b/modules/astra-library-scanner/queue.ts new file mode 100644 index 0000000..135703e --- /dev/null +++ b/modules/astra-library-scanner/queue.ts @@ -0,0 +1,97 @@ +import { + requireNativeModule, + requireNativeViewManager, + type NativeModule, +} from 'expo-modules-core'; +import { processColor, type ViewProps } from 'react-native'; +import type { Palette } from '../../src/theme/palettes'; + +export interface NativeQueuePalette { + background: number; + surface: number; + elevatedSurface: number; + selectedSurface: number; + nowPlayingSurface: number; + divider: number; + ripple: number; + text: number; + textSecondary: number; + textTertiary: number; + accent: number; + accentText: number; + accentTextStrong: number; + warning: number; +} + +export interface NativeQueuePresentationOptions { + palette: NativeQueuePalette; +} + +export interface NativeQueuePlaybackRequest { + requestId: string; + kind: 'playEntry'; + entryId: number; + queueRevision: number; +} + +export interface NativeQueueRevisionEvent { + sessionId: string | null; + queueRevision: number; + activePosition: number; + totalCount: number; +} + +type AstraQueueEvents = { + onDismissed: () => void; + onPlaybackRequest: (event: NativeQueuePlaybackRequest) => void; + onQueueRevision: (event: NativeQueueRevisionEvent) => void; +}; + +declare class AstraQueueModuleType extends NativeModule { + present(options: NativeQueuePresentationOptions): Promise; + dismiss(): void; + resolvePlaybackRequest( + requestId: string, + success: boolean, + message?: string | null, + ): void; + resolveEntryPosition( + entryId: number, + expectedRevision: number, + ): Promise; +} + +export interface AstraQueueViewProps extends ViewProps { + active: boolean; + palette: NativeQueuePalette; +} + +function nativeColor(value: string): number { + const processed = processColor(value); + return typeof processed === 'number' ? processed : 0; +} + +export function toNativeQueuePalette(colors: Palette): NativeQueuePalette { + return { + background: nativeColor(colors.bgSecondary), + surface: nativeColor(colors.bgSecondary), + elevatedSurface: nativeColor(colors.bgTertiary), + selectedSurface: nativeColor(colors.glassHighlight), + nowPlayingSurface: nativeColor(colors.glassBg), + divider: nativeColor(colors.glassBorder), + ripple: nativeColor(colors.ripple), + text: nativeColor(colors.textPrimary), + textSecondary: nativeColor(colors.textSecondary), + textTertiary: nativeColor(colors.textTertiary), + accent: nativeColor(colors.accent), + accentText: nativeColor(colors.accentText), + accentTextStrong: nativeColor(colors.accentTextStrong), + // Destructive queue actions are semantically different from Astra's amber + // warning token. Keep remove affordances unmistakably red in every theme. + warning: nativeColor('#ef5350'), + }; +} + +export const AstraQueue = requireNativeModule('AstraQueue'); +export const AstraQueueView = + requireNativeViewManager('AstraQueue'); diff --git a/package.json b/package.json index 8b49571..df3c087 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", - "test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/queue/virtualQueuePaging.test.mts src/components/swipeableRowState.test.mts", + "test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/virtualPlaybackWindow.test.mts src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/queue/virtualQueuePaging.test.mts src/components/swipeableRowState.test.mts", "test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts src/services/desktopRemoteTransport.test.mts", "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts", "test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts", diff --git a/patches/react-native-track-player+4.1.2.patch b/patches/react-native-track-player+4.1.2.patch index c48672b..57f9faa 100644 --- a/patches/react-native-track-player+4.1.2.patch +++ b/patches/react-native-track-player+4.1.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt -index b2409a0..4491bad 100644 +index b2409a0..119caaf 100644 --- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt +++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt @@ -18,9 +18,11 @@ import com.doublesymmetry.trackplayer.utils.RejectionException @@ -45,7 +45,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch val options = Arguments.toBundle(data) -@@ -262,13 +267,16 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -262,19 +267,24 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -60,11 +60,20 @@ index b2409a0..4491bad 100644 - val tracks = readableArrayToTrackList(data); + // Track conversion is O(queue) work (Bundle parsing, Uri resolution) — + // keep it off the main thread so long queues don't freeze the UI/ANR. -+ val tracks = withContext(Dispatchers.Default) { readableArrayToTrackList(data) }; ++ val tracks = withContext(Dispatchers.Default) { ++ readableArrayToTrackList(data).map { it.toAudioItem() } ++ }; if (insertBeforeIndex < -1 || insertBeforeIndex > musicService.tracks.size) { callback.reject("index_out_of_bounds", "The track index is out of bounds") return@launch -@@ -283,9 +291,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM + } + val index = if (insertBeforeIndex == -1) musicService.tracks.size else insertBeforeIndex +- musicService.add( ++ musicService.addPrepared( + tracks, + index + ) +@@ -283,9 +293,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM rejectWithException(callback, exception) } } @@ -76,7 +85,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch if (data == null) { callback.resolve(null) -@@ -299,16 +308,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -299,16 +310,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.reject("invalid_track_object", "Track was not a dictionary type") } } @@ -97,7 +106,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch val inputIndexes = Arguments.toList(data) if (inputIndexes != null) { -@@ -329,9 +340,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -329,9 +342,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM } callback.resolve(null) } @@ -109,7 +118,7 @@ index b2409a0..4491bad 100644 scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch -@@ -346,9 +358,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -346,9 +360,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } } @@ -121,7 +130,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch if (musicService.tracks.isEmpty()) -@@ -362,9 +375,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -362,9 +377,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -133,7 +142,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch if (musicService.tracks.isEmpty()) -@@ -373,17 +387,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -373,17 +389,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM musicService.clearNotificationMetadata() callback.resolve(null) } @@ -155,7 +164,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.skip(index) -@@ -394,9 +410,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -394,9 +412,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -167,7 +176,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.skipToNext() -@@ -407,9 +424,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -407,9 +426,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -179,7 +188,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.skipToPrevious() -@@ -420,9 +438,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -420,9 +440,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -191,7 +200,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.stop() -@@ -431,188 +450,222 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -431,188 +452,224 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -374,10 +383,12 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch try { -+ val tracks = withContext(Dispatchers.Default) { readableArrayToTrackList(data) } ++ val tracks = withContext(Dispatchers.Default) { ++ readableArrayToTrackList(data).map { it.toAudioItem() } ++ } musicService.clear() - musicService.add(readableArrayToTrackList(data)) -+ musicService.add(tracks) ++ musicService.addPrepared(tracks, 0) callback.resolve(null) } catch (exception: Exception) { rejectWithException(callback, exception) @@ -443,7 +454,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch var bundle = Bundle() bundle.putDouble("duration", musicService.getDurationInSeconds()); -@@ -620,10 +664,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -620,10 +677,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds()); callback.resolve(Arguments.fromBundle(bundle)) } @@ -458,15 +469,87 @@ index b2409a0..4491bad 100644 + } } diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt -index afa6b0f..c6f01d5 100644 +index afa6b0f..87af67c 100644 --- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt +++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt -@@ -337,0 +338,4 @@ class MusicService : HeadlessJsTaskService() { +@@ -9,6 +9,7 @@ import android.os.Binder + import android.os.Build + import android.os.Bundle + import android.os.IBinder ++import android.os.Trace + import android.support.v4.media.RatingCompat + import androidx.annotation.MainThread + import androidx.core.app.NotificationCompat +@@ -300,6 +301,18 @@ class MusicService : HeadlessJsTaskService() { + player.add(items, atIndex) + } + ++ /** ++ * Track -> AudioItem construction is safe off the player looper and can be ++ * noticeable even for a bounded window. MusicModule prepares these on ++ * Dispatchers.Default; only the actual Media3 playlist mutation stays here. ++ */ ++ @MainThread ++ fun addPrepared(items: List, atIndex: Int) { ++ tracePlaylistMutation("AstraQueue.rntpAdd") { ++ player.add(items, atIndex) ++ } ++ } ++ + @MainThread + fun load(track: Track) { + player.load(track.toAudioItem()) +@@ -307,7 +320,9 @@ class MusicService : HeadlessJsTaskService() { + + @MainThread + fun move(fromIndex: Int, toIndex: Int) { +- player.move(fromIndex, toIndex); ++ tracePlaylistMutation("AstraQueue.rntpMove") { ++ player.move(fromIndex, toIndex); ++ } + } + + @MainThread +@@ -317,12 +332,25 @@ class MusicService : HeadlessJsTaskService() { + + @MainThread + fun remove(indexes: List) { +- player.remove(indexes) ++ tracePlaylistMutation("AstraQueue.rntpRemove") { ++ player.remove(indexes) ++ } + } + + @MainThread + fun clear() { +- player.clear() ++ tracePlaylistMutation("AstraQueue.rntpClear") { ++ player.clear() ++ } ++ } ++ ++ private inline fun tracePlaylistMutation(name: String, block: () -> T): T { ++ Trace.beginSection(name) ++ return try { ++ block() ++ } finally { ++ Trace.endSection() ++ } + } + + @MainThread +@@ -335,6 +363,10 @@ class MusicService : HeadlessJsTaskService() { + player.pause() + } + + fun setPauseAtEndOfItem(enabled: Boolean) { + player.setPauseAtEndOfItem(enabled) + } + -@@ -741,7 +745,7 @@ class MusicService : HeadlessJsTaskService() { + @MainThread + fun stop() { + player.stop() +@@ -741,7 +773,7 @@ class MusicService : HeadlessJsTaskService() { @MainThread private fun emit(event: String, data: Bundle? = null) { @@ -475,7 +558,7 @@ index afa6b0f..c6f01d5 100644 ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?.emit(event, data?.let { Arguments.fromBundle(it) }) } -@@ -751,7 +755,7 @@ class MusicService : HeadlessJsTaskService() { +@@ -751,7 +783,7 @@ class MusicService : HeadlessJsTaskService() { val payload = Arguments.createArray() data.forEach { payload.pushMap(Arguments.fromBundle(it)) } diff --git a/src/app/settings/experimental.tsx b/src/app/settings/experimental.tsx index 266f019..ac16909 100644 --- a/src/app/settings/experimental.tsx +++ b/src/app/settings/experimental.tsx @@ -2,14 +2,17 @@ import { useEffect } from 'react'; import { InteractionManager } from 'react-native'; import { useRouter } from 'expo-router'; import { + SettingsCard, SettingsNavRow, SettingsSectionLabel, SettingsSectionScreen, + SettingsToggleRow, } from '@/components/settings/SettingsSectionScaffold'; import { formatRelativeTime } from '@/lib/format'; import { useColors } from '@/theme/themed'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; +import { useSettingsStore } from '@/stores/settingsStore'; export default function ExperimentalSettingsScreen() { const colors = useColors(); @@ -20,6 +23,8 @@ export default function ExperimentalSettingsScreen() { const desktopSyncStatus = useDesktopSyncStore((s) => s.status); const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt); const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length); + const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled); + const setNativeQueueEnabled = useSettingsStore((s) => s.setNativeQueueEnabled); useEffect(() => { const task = InteractionManager.runAfterInteractions(() => { @@ -44,7 +49,17 @@ export default function ExperimentalSettingsScreen() { return ( - DESKTOP + PLAYBACK + + void setNativeQueueEnabled(enabled)} + /> + + + DESKTOP | null = null; let virtualContext: { sessionId: string; + queueRevision: number; windowStart: number; loadedEnd: number; totalCount: number; } | null = null; let virtualRefillPromise: Promise | null = null; +let requestedQueueRevision = 0; +let requestedActivePosition: number | null = null; +let virtualRevisionSyncPromise: Promise | null = null; + +export { + TRANSPORT_HISTORY, + TRANSPORT_REFILL_THRESHOLD, + TRANSPORT_UPCOMING, +}; export interface VirtualQueuePageItem { track: RntpTrack; @@ -85,11 +106,12 @@ export interface PlaybackStartOptions { } function toVirtualRntpTrack( - item: DbTrack & { queuePosition: number }, + item: DbTrack & { queuePosition: number; queueEntryId: number }, ): RntpTrack { return { ...toRntpTrack(dbTrackToTrack(item)), astraQueuePosition: item.queuePosition, + astraQueueEntryId: item.queueEntryId, }; } @@ -110,6 +132,11 @@ function toRntpRepeat(mode: RepeatModeStr): RepeatMode { } } +function toEffectiveRntpRepeat(mode: RepeatModeStr): RepeatMode { + if (virtualContext && mode === 'all') return RepeatMode.Off; + return toRntpRepeat(mode); +} + function mapRntpState(state?: State): PlaybackState { switch (state) { case State.Playing: @@ -143,6 +170,24 @@ function setOptimisticTrack(track: RntpTrack | undefined, playbackState?: Playba if (playbackState) player.setPlaybackState(playbackState); } +function setVirtualQueueSnapshot( + tracks: RntpTrack[], + activeLocalIndex: number, + source?: PlaybackSource | null, +): void { + const context = virtualContext; + useQueueStore.getState().setSnapshot(tracks, activeLocalIndex, { + ...(source !== undefined ? { source } : {}), + transport: context + ? { + sessionId: context.sessionId, + queueRevision: context.queueRevision, + windowStart: context.windowStart, + } + : null, + }); +} + async function reconcilePlayerFromNative(): Promise { try { const [activeTrack, playbackState, progress] = await Promise.all([ @@ -240,7 +285,13 @@ async function materializeRestoredSession(): Promise { // Play. Rebuild every RNTP row from its stable Astra identity at the lazy // materialization boundary so URL resolution is fresh. const materializedTracks = queue.tracks.map((track) => toRntpTrack(rntpToTrack(track))); - useQueueStore.getState().setSnapshot(materializedTracks, queue.activeIndex); + if (virtualContext) { + setVirtualQueueSnapshot(materializedTracks, queue.activeIndex); + } else { + useQueueStore.getState().setSnapshot(materializedTracks, queue.activeIndex, { + transport: null, + }); + } if (player.currentTime > 0) player.setPendingSeek(player.currentTime); await materializePlaybackQueue( { @@ -252,7 +303,7 @@ async function materializeRestoredSession(): Promise { { loadQueue: loadQueueChunked, setRepeat: async (repeat) => { - await TrackPlayer.setRepeatMode(toRntpRepeat(repeat)); + await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(repeat)); }, seek: (position) => TrackPlayer.seekTo(position), } @@ -272,7 +323,7 @@ async function ensurePlayerReady( ): Promise { await setupPlayer(options); if (options.materializeRestored !== false) await materializeRestoredSession(); - await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat)); + await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(usePlayerStore.getState().repeat)); } function discardPendingRestoredSession(): void { @@ -316,7 +367,7 @@ export function restorePlaybackSession( const player = usePlayerStore.getState(); if (!session || session.tracks.length === 0) { originalOrder = null; - useQueueStore.getState().setSnapshot([], -1, { source: null }); + useQueueStore.getState().setSnapshot([], -1, { source: null, transport: null }); player.reset(); player.setShuffle(false); player.setRepeat('none'); @@ -331,6 +382,7 @@ export function restorePlaybackSession( .filter((id): id is string => Boolean(id)); useQueueStore.getState().setSnapshot(queueTracks, session.activeIndex, { source: session.source, + transport: null, }); player.setCurrentTrack(activeTrack); player.setProgress(session.position, activeTrack.duration); @@ -361,14 +413,14 @@ export function restoreVirtualPlaybackContext( ); virtualContext = { sessionId: window.sessionId, + queueRevision: window.queueRevision, windowStart: window.windowStart, loadedEnd: window.items[window.items.length - 1].queuePosition + 1, totalCount: window.totalCount, }; + requestedQueueRevision = window.queueRevision; originalOrder = session.shuffle ? null : tracks.map((track) => track.id); - useQueueStore.getState().setSnapshot(queueTracks, activeIndex, { - source: session.source, - }); + setVirtualQueueSnapshot(queueTracks, activeIndex, session.source); const activeTrack = tracks[activeIndex]; const player = usePlayerStore.getState(); player.setCurrentTrack(activeTrack); @@ -414,8 +466,8 @@ export interface LibraryPlaybackStartOptions extends PlaybackStartOptions { } /** - * Starts a native virtual library context. Only 25 previous + 200 upcoming - * tracks cross into JavaScript; the complete ordered path set remains in Room. + * Starts a native virtual library context. Only eight historical, the current, + * and 32 upcoming tracks cross into JavaScript; complete order remains in Room. */ export async function playLibraryQuery( query: LibraryQuery, @@ -450,17 +502,20 @@ async function startVirtualWindow( ); virtualContext = { sessionId: window.sessionId, + queueRevision: window.queueRevision, windowStart: window.windowStart, loadedEnd: window.items.length === 0 ? window.windowStart : window.items[window.items.length - 1].queuePosition + 1, totalCount: window.totalCount, }; + requestedQueueRevision = window.queueRevision; + await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(usePlayerStore.getState().repeat)); originalOrder = shuffle ? null : tracks.map((track) => track.id); usePlayerStore.getState().setShuffle(shuffle); const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none'); const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; - useQueueStore.getState().setSnapshot(queueTracks, startIndex, { source }); + setVirtualQueueSnapshot(queueTracks, startIndex, source); setOptimisticTrack(queueTracks[startIndex], 'loading'); try { await prepareAudioProcessingForPlayback(playbackTarget, 'virtual-queue-play'); @@ -485,6 +540,47 @@ export function handleVirtualPlaybackAdvance(_nativeEventIndex?: number): Promis return virtualRefillPromise; } +/** Continues a starved bounded window, or implements repeat-all over Room. */ +export async function handleVirtualQueueEnded(): Promise { + const context = virtualContext; + if (!context || context.totalCount <= 0) return false; + await queueLoadSettled(); + if (virtualContext !== context) return false; + const currentPosition = + context.windowStart + Math.max(0, useQueueStore.getState().activeIndex); + const target = context.loadedEnd < context.totalCount + ? Math.min(context.totalCount - 1, currentPosition + 1) + : usePlayerStore.getState().repeat === 'all' + ? 0 + : null; + if (target == null) return false; + await AstraLibraryData.updatePlaybackPosition(context.sessionId, target); + const window = await AstraLibraryData.getPlaybackWindow( + context.sessionId, + virtualPlaybackWindowStart(target), + TRANSPORT_WINDOW_SIZE, + ); + if (virtualContext !== context || window.items.length === 0) return false; + await startVirtualWindow( + window, + useQueueStore.getState().source ?? { kind: 'library', label: 'Library' }, + usePlayerStore.getState().shuffle, + ); + return true; +} + +async function appendTransportTracks( + tracks: RntpTrack[], + baseCount: number, +): Promise { + for (let index = 0; index < tracks.length; index += TRANSPORT_APPEND_BATCH) { + await appendUpcomingChunked( + tracks.slice(index, index + TRANSPORT_APPEND_BATCH), + baseCount + index, + ); + } +} + async function replenishVirtualContext(): Promise { const context = virtualContext; if (!context) return; @@ -496,8 +592,8 @@ async function replenishVirtualContext(): Promise { void AstraLibraryData.updatePlaybackPosition(context.sessionId, activePosition).catch(() => {}); let localIndex = nativeIndex; - if (localIndex > 25) { - const removeCount = localIndex - 25; + const removeCount = virtualPlaybackTrimCount(localIndex); + if (removeCount > 0) { const indices = Array.from({ length: removeCount }, (_, index) => index); await TrackPlayer.remove(indices); useQueueStore.getState().removeIndices(indices); @@ -507,11 +603,21 @@ async function replenishVirtualContext(): Promise { const currentLength = useQueueStore.getState().tracks.length; const upcoming = currentLength - localIndex - 1; - if (upcoming >= 50 || context.loadedEnd >= context.totalCount) return; + if (!shouldRefillVirtualPlayback( + upcoming, + context.loadedEnd, + context.totalCount, + )) return; + const requested = virtualPlaybackRefillLimit( + upcoming, + context.loadedEnd, + context.totalCount, + ); + if (requested <= 0) return; const next = await AstraLibraryData.getPlaybackWindow( context.sessionId, context.loadedEnd, - 100, + requested, ); if (virtualContext !== context || next.items.length === 0) return; const additions = next.items @@ -524,12 +630,10 @@ async function replenishVirtualContext(): Promise { const nextLoadedEnd = Number(additions[additions.length - 1].astraQueuePosition) + 1; if (!Number.isFinite(nextLoadedEnd) || nextLoadedEnd <= context.loadedEnd) return; const before = useQueueStore.getState(); - await appendUpcomingChunked(additions, before.tracks.length); - useQueueStore.getState().setSnapshot( - [...before.tracks, ...additions], - localIndex, - ); + await appendTransportTracks(additions, before.tracks.length); context.loadedEnd = Math.min(context.totalCount, nextLoadedEnd); + context.queueRevision = Math.max(context.queueRevision, next.queueRevision); + setVirtualQueueSnapshot([...before.tracks, ...additions], localIndex); } /** Returns a bounded page from the native virtual queue, or null for ordinary queues. */ @@ -563,6 +667,7 @@ export async function getVirtualQueuePage( export function getVirtualQueueState(): { sessionId: string; + queueRevision: number; activePosition: number; totalCount: number; } | null { @@ -571,11 +676,73 @@ export function getVirtualQueueState(): { const localActive = useQueueStore.getState().activeIndex; return { sessionId: context.sessionId, + queueRevision: context.queueRevision, activePosition: context.windowStart + Math.max(0, localActive), totalCount: context.totalCount, }; } +/** + * Coalesces Room revisions emitted by the Kotlin queue and rebuilds only RNTP's + * bounded upcoming tail. The currently playing MediaSource is never replaced. + */ +export function synchronizeVirtualQueueRevision( + queueRevision: number, + activePosition?: number, +): Promise { + const context = virtualContext; + if (!context || queueRevision <= context.queueRevision) return Promise.resolve(); + requestedQueueRevision = Math.max(requestedQueueRevision, queueRevision); + if (activePosition != null) requestedActivePosition = activePosition; + if (virtualRevisionSyncPromise) return virtualRevisionSyncPromise; + + virtualRevisionSyncPromise = (async () => { + while (virtualContext && requestedQueueRevision > virtualContext.queueRevision) { + const targetRevision = requestedQueueRevision; + const targetActive = requestedActivePosition; + requestedActivePosition = null; + await synchronizeVirtualTransportOnce(targetRevision, targetActive); + } + })().finally(() => { + virtualRevisionSyncPromise = null; + }); + return virtualRevisionSyncPromise; +} + +async function synchronizeVirtualTransportOnce( + targetRevision: number, + emittedActivePosition: number | null, +): Promise { + const context = virtualContext; + if (!context || targetRevision <= context.queueRevision) return; + await queueLoadSettled(); + if (virtualContext !== context) return; + const nativeIndex = await TrackPlayer.getActiveTrackIndex(); + if (nativeIndex == null || nativeIndex < 0) return; + const activePosition = emittedActivePosition ?? + context.windowStart + nativeIndex; + const window = await AstraLibraryData.getPlaybackWindow( + context.sessionId, + virtualPlaybackWindowStart(activePosition), + TRANSPORT_WINDOW_SIZE, + ); + if (virtualContext !== context || window.queueRevision < targetRevision) return; + + const upcoming = window.items + .filter((item) => item.queuePosition > window.activePosition) + .slice(0, TRANSPORT_UPCOMING) + .map(toVirtualRntpTrack); + const before = useQueueStore.getState(); + const prefix = before.tracks.slice(0, nativeIndex + 1); + await TrackPlayer.removeUpcomingTracks(); + await appendTransportTracks(upcoming, prefix.length); + context.windowStart = window.activePosition - nativeIndex; + context.loadedEnd = window.activePosition + upcoming.length + 1; + context.totalCount = window.totalCount; + context.queueRevision = window.queueRevision; + setVirtualQueueSnapshot([...prefix, ...upcoming], nativeIndex); +} + async function adoptCurrentQueueAsVirtualContext(): Promise { if (virtualContext) return true; const snapshot = await getQueueSnapshot(); @@ -590,10 +757,12 @@ async function adoptCurrentQueueAsVirtualContext(): Promise { ); virtualContext = { sessionId: window.sessionId, + queueRevision: window.queueRevision, windowStart: window.activePosition - activeIndex, loadedEnd: Math.min(window.totalCount, snapshot.queue.length), totalCount: window.totalCount, }; + requestedQueueRevision = window.queueRevision; originalOrder = null; return true; } @@ -632,19 +801,18 @@ async function mutateVirtualQueue( const before = useQueueStore.getState(); const prefix = before.tracks.slice(0, boundedActive + 1); + context.queueRevision = window.queueRevision; await TrackPlayer.removeUpcomingTracks(); - if (upcoming.length > 0) { - await appendUpcomingChunked(upcoming, prefix.length); - } - useQueueStore.getState().setSnapshot( - [...prefix, ...upcoming], - boundedActive, - ); + await appendTransportTracks(upcoming.slice(0, TRANSPORT_UPCOMING), prefix.length); context.windowStart = window.activePosition - boundedActive; context.loadedEnd = upcoming.length > 0 - ? window.activePosition + upcoming.length + 1 + ? window.activePosition + Math.min(upcoming.length, TRANSPORT_UPCOMING) + 1 : window.activePosition + 1; context.totalCount = window.totalCount; + setVirtualQueueSnapshot( + [...prefix, ...upcoming.slice(0, TRANSPORT_UPCOMING)], + boundedActive, + ); return window; } @@ -684,6 +852,7 @@ async function playTracksInternal( const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; useQueueStore.getState().setSnapshot(queueTracks, startIndex, { source: startOptions.source, + transport: null, }); setOptimisticTrack(queueTracks[startIndex], 'loading'); try { @@ -713,7 +882,7 @@ export async function shuffleTracks( const queueTracks = shuffleArray(tracks).map(toRntpTrack); const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none'); const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; - useQueueStore.getState().setSnapshot(queueTracks, 0, { source }); + useQueueStore.getState().setSnapshot(queueTracks, 0, { source, transport: null }); setOptimisticTrack(queueTracks[0], 'loading'); try { await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play'); @@ -743,13 +912,14 @@ export async function playSample(): Promise { originalOrder = SAMPLE_TRACKS.map((t) => t.id); useQueueStore.getState().setSnapshot(sampleQueue, 0, { source: { kind: 'sample', label: 'Astra Sample' }, + transport: null, }); setOptimisticTrack(sampleQueue[0], 'loading'); } else { const activeIndex = await TrackPlayer.getActiveTrackIndex(); playbackTarget = dspTargetFromTrack(queue[activeIndex ?? 0], 'immediate'); await prepareAudioProcessingForPlayback(playbackTarget, 'sample-resume'); - useQueueStore.getState().setSnapshot(queue, activeIndex); + useQueueStore.getState().setSnapshot(queue, activeIndex, { transport: null }); setOptimisticTrack(queue[activeIndex ?? 0], 'loading'); } try { @@ -825,6 +995,16 @@ export async function skipToNext(): Promise { ]); const playbackStateAtIntent = mapRntpState(nativePlaybackState.state); const resumeAfterSkip = shouldResumeAfterExplicitNext(playbackStateAtIntent); + const virtualState = getVirtualQueueState(); + if ( + virtualState && + nativeIndex != null && + nativeIndex >= nativeQueue.length - 1 && + virtualState.activePosition + 1 < virtualState.totalCount + ) { + await jumpToQueueIndex(virtualState.activePosition + 1, { virtualPosition: true }); + return; + } const playbackTarget = dspTargetFromTrack( nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1], 'none', @@ -882,6 +1062,11 @@ export async function skipToPrevious(): Promise { TrackPlayer.getQueue(), TrackPlayer.getActiveTrackIndex(), ]); + const virtualState = getVirtualQueueState(); + if (virtualState && nativeIndex === 0 && virtualState.activePosition > 0) { + await jumpToQueueIndex(virtualState.activePosition - 1, { virtualPosition: true }); + return; + } await prepareAudioProcessingForPlayback( dspTargetFromTrack( nativeIndex == null ? undefined : nativeQueue[nativeIndex - 1], @@ -914,7 +1099,7 @@ export async function cycleRepeat(): Promise { const next = NEXT_REPEAT[usePlayerStore.getState().repeat]; usePlayerStore.getState().setRepeat(next); await ensurePlayerReady(); - await TrackPlayer.setRepeatMode(toRntpRepeat(next)); + await TrackPlayer.setRepeatMode(toEffectiveRntpRepeat(next)); } /** @@ -925,47 +1110,59 @@ export async function cycleRepeat(): Promise { export async function toggleShuffle(): Promise { const store = usePlayerStore.getState(); const next = !store.shuffle; + // The control is a direct-manipulation toggle: reflect it immediately while + // Room reorders the authoritative queue and the bounded RNTP tail catches up. + // A failed native mutation rolls the visual state back. + store.setShuffle(next); await ensurePlayerReady(); await queueLoadSettled(); if (virtualContext) { - await mutateVirtualQueue('shuffle', { - enabled: next, - seed: next ? Date.now() : null, - }); - store.setShuffle(next); + try { + await mutateVirtualQueue('shuffle', { + enabled: next, + seed: next ? Date.now() : null, + }); + } catch (error) { + store.setShuffle(!next); + throw error; + } return; } - const snapshot = await getQueueSnapshot(); - const queue = snapshot.queue; - const activeIndex = snapshot.activeIndex >= 0 ? snapshot.activeIndex : 0; - let mirroredQueue = queue; + try { + const snapshot = await getQueueSnapshot(); + const queue = snapshot.queue; + const activeIndex = snapshot.activeIndex >= 0 ? snapshot.activeIndex : 0; + let mirroredQueue = queue; - if (next) { - if (originalOrder === null) originalOrder = queue.map(rntpTrackId); - const upcoming = queue.slice(activeIndex + 1); - if (upcoming.length > 1) { - const shuffledUpcoming = shuffleArray(upcoming); + if (next) { + if (originalOrder === null) originalOrder = queue.map(rntpTrackId); + const upcoming = queue.slice(activeIndex + 1); + if (upcoming.length > 1) { + const shuffledUpcoming = shuffleArray(upcoming); + await TrackPlayer.removeUpcomingTracks(); + await appendUpcomingChunked(shuffledUpcoming, activeIndex + 1); + mirroredQueue = [...queue.slice(0, activeIndex + 1), ...shuffledUpcoming]; + } + } else if (originalOrder) { + const byId = new Map(queue.map((t) => [rntpTrackId(t), t])); + const currentId = queue[activeIndex] ? rntpTrackId(queue[activeIndex]) : null; + const origPos = currentId ? originalOrder.indexOf(currentId) : -1; + const restoredIds = origPos >= 0 ? originalOrder.slice(origPos + 1) : originalOrder; + const restored = restoredIds + .map((id) => byId.get(id)) + .filter((t): t is RntpTrack => Boolean(t)); await TrackPlayer.removeUpcomingTracks(); - await appendUpcomingChunked(shuffledUpcoming, activeIndex + 1); - mirroredQueue = [...queue.slice(0, activeIndex + 1), ...shuffledUpcoming]; + if (restored.length) await appendUpcomingChunked(restored, activeIndex + 1); + mirroredQueue = [...queue.slice(0, activeIndex + 1), ...restored]; } - } else if (originalOrder) { - const byId = new Map(queue.map((t) => [rntpTrackId(t), t])); - const currentId = queue[activeIndex] ? rntpTrackId(queue[activeIndex]) : null; - const origPos = currentId ? originalOrder.indexOf(currentId) : -1; - const restoredIds = origPos >= 0 ? originalOrder.slice(origPos + 1) : originalOrder; - const restored = restoredIds - .map((id) => byId.get(id)) - .filter((t): t is RntpTrack => Boolean(t)); - await TrackPlayer.removeUpcomingTracks(); - if (restored.length) await appendUpcomingChunked(restored, activeIndex + 1); - mirroredQueue = [...queue.slice(0, activeIndex + 1), ...restored]; - } - useQueueStore.getState().setSnapshot(mirroredQueue, activeIndex); - store.setShuffle(next); + useQueueStore.getState().setSnapshot(mirroredQueue, activeIndex, { transport: null }); + } catch (error) { + store.setShuffle(!next); + throw error; + } } /** Insert a track right after the current one ("Play next"). */ @@ -1127,8 +1324,8 @@ export async function jumpToQueueIndex( await AstraLibraryData.updatePlaybackPosition(context.sessionId, bounded); const window = await AstraLibraryData.getPlaybackWindow( context.sessionId, - Math.max(0, bounded - 25), - 226, + virtualPlaybackWindowStart(bounded), + TRANSPORT_WINDOW_SIZE, ); await startVirtualWindow( window, diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index 037c0c6..d64eeae 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -4,6 +4,7 @@ import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync'; import { applyNormalizationForActiveTrack } from './applyNormalization'; import { startAudioProcessingWarmup } from './audioProcessingStartup'; import { + handleVirtualQueueEnded, handleVirtualPlaybackAdvance, playForCar, skipToNext, @@ -112,6 +113,9 @@ export async function PlaybackService(): Promise { }); TrackPlayer.addEventListener(Event.PlaybackQueueEnded, ({ position }) => { handleListeningQueueEnded(position); + void handleVirtualQueueEnded().catch((error) => { + console.warn('[playback] virtual queue end recovery failed', error); + }); if (useSleepTimerStore.getState().timer?.mode !== 'end-of-track') return; void TrackPlayer.getProgress() .then(({ position, duration }) => useSleepTimerStore.getState().reconcileEndOfTrack(position, duration, false)) diff --git a/src/audio/queueLoader.ts b/src/audio/queueLoader.ts index d7dca41..62930de 100644 --- a/src/audio/queueLoader.ts +++ b/src/audio/queueLoader.ts @@ -7,26 +7,17 @@ import { /** * Chunked feeder for RNTP's native queue. Loading a long context in one * setQueue/add stalls the Android main thread for seconds (per-track Bundle → - * Track → MediaSource construction), so playback starts from a small first - * chunk and the rest streams in behind it: the upcoming tail first (it plays - * next), then the head prepended in reverse chunk order. The JS queue mirror - * holds the full context from the start; while the head is still missing, - * native indices trail absolute (mirror) indices by `headRemaining`. + * Track → MediaSource construction), so playback starts from history + current + * + eight upcoming rows and the rest streams in behind it. We never prepend: + * native indices stay stable for the lifetime of a transport window. */ -// The first chunk's setQueue lands on the Android main thread at the exact -// moment of the play tap, so it stays tiny. Each background add() also occupies -// the main thread (= the UI thread) for time proportional to its size, so the -// chunks stay small with generous yields — a longer total fill is invisible, -// per-chunk frame drops are not. -const FIRST_CHUNK = 12; -const CHUNK = 50; -const YIELD_MS = 64; +const INITIAL_UPCOMING = 8; +const CHUNK = 8; +const YIELD_MS = 16; interface QueueLoad { generation: number; - /** Head tracks not yet prepended: absolute = native + headRemaining. */ - headRemaining: number; /** Tracks currently in the native queue (per this loader's bookkeeping). */ loadedCount: number; settled: Promise; @@ -54,19 +45,15 @@ export function queueLoadSettled(): Promise { return load ? load.settled : Promise.resolve(); } -/** Map a native RNTP queue index to an absolute (full-queue mirror) index. */ +/** RNTP and the JS transport mirror share one stable local index space. */ export function nativeIndexToAbsolute(nativeIndex: number): number { - return load ? nativeIndex + load.headRemaining : nativeIndex; + return nativeIndex; } -/** - * Map an absolute index to its native index, or null while that part of the - * queue has not been loaded yet. - */ +/** Map a local mirror index to RNTP, or null until its append has landed. */ export function absoluteIndexToNative(absoluteIndex: number): number | null { if (!load) return absoluteIndex; - const nativeIndex = absoluteIndex - load.headRemaining; - return nativeIndex >= 0 && nativeIndex < load.loadedCount ? nativeIndex : null; + return absoluteIndex >= 0 && absoluteIndex < load.loadedCount ? absoluteIndex : null; } function sleep(ms: number): Promise { @@ -81,7 +68,7 @@ async function supersedePreviousLoad(): Promise { return gen; } -function beginLoad(gen: number, headRemaining: number, loadedCount: number): QueueLoad { +function beginLoad(gen: number, loadedCount: number): QueueLoad { let resolveSettled!: () => void; let resolveLoopDone!: () => void; const settled = new Promise((resolve) => { @@ -92,7 +79,6 @@ function beginLoad(gen: number, headRemaining: number, loadedCount: number): Que }); const next: QueueLoad = { generation: gen, - headRemaining, loadedCount, settled, resolveSettled, @@ -123,21 +109,26 @@ export async function loadQueueChunked( const gen = await supersedePreviousLoad(); if (gen !== generation) return; - const current = beginLoad(gen, startIndex, 0); + const current = beginLoad(gen, 0); const manualTransition = markManualRecentPlayTransition( options.manualTransitionFromPath, ); try { - const first = tracks.slice(startIndex, startIndex + FIRST_CHUNK); + const firstEnd = Math.min( + tracks.length, + Math.max(startIndex + 1, startIndex + 1 + INITIAL_UPCOMING), + ); + const first = tracks.slice(0, firstEnd); await TrackPlayer.setQueue(first); current.loadedCount = first.length; + if (startIndex > 0) await TrackPlayer.skip(startIndex); } catch (err) { cancelManualRecentPlayTransition(manualTransition); finishLoad(current, false); throw err; } - void fillRemainder(current, tracks, startIndex); + void fillRemainder(current, tracks); } /** @@ -150,7 +141,7 @@ export async function appendUpcomingChunked(tracks: RntpTrack[], baseCount: numb const gen = await supersedePreviousLoad(); if (gen !== generation || tracks.length === 0) return; - const current = beginLoad(gen, 0, baseCount); + const current = beginLoad(gen, baseCount); try { const first = tracks.slice(0, CHUNK); await TrackPlayer.add(first); @@ -180,24 +171,10 @@ async function fillTail(current: QueueLoad, tracks: RntpTrack[], fromIndex: numb async function fillRemainder( current: QueueLoad, tracks: RntpTrack[], - startIndex: number, ): Promise { let failed = false; try { - // Tail first — it's what plays next. - await fillTail(current, tracks, startIndex + current.loadedCount); - - // Head second, prepended in reverse chunk order so [0..startIndex) ends up - // in original order and `absolute = native + headRemaining` holds throughout. - for (let end = startIndex; end > 0; end -= CHUNK) { - await sleep(YIELD_MS); - if (current.generation !== generation) return; - const begin = Math.max(0, end - CHUNK); - const chunk = tracks.slice(begin, end); - await TrackPlayer.add(chunk, 0); - current.headRemaining = begin; - current.loadedCount += chunk.length; - } + await fillTail(current, tracks, current.loadedCount); } catch { failed = true; } finally { diff --git a/src/audio/virtualPlaybackWindow.test.mts b/src/audio/virtualPlaybackWindow.test.mts new file mode 100644 index 0000000..27d2fb6 --- /dev/null +++ b/src/audio/virtualPlaybackWindow.test.mts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + VIRTUAL_PLAYBACK_APPEND_BATCH, + VIRTUAL_PLAYBACK_HISTORY, + VIRTUAL_PLAYBACK_REFILL_THRESHOLD, + VIRTUAL_PLAYBACK_UPCOMING, + VIRTUAL_PLAYBACK_WINDOW_SIZE, + shouldRefillVirtualPlayback, + virtualPlaybackRefillLimit, + virtualPlaybackTrimCount, + virtualPlaybackWindowStart, +} from './virtualPlaybackWindow.ts'; + +test('uses the 8 history + current + 32 upcoming transport window', () => { + assert.equal(VIRTUAL_PLAYBACK_HISTORY, 8); + assert.equal(VIRTUAL_PLAYBACK_UPCOMING, 32); + assert.equal(VIRTUAL_PLAYBACK_WINDOW_SIZE, 41); + assert.equal(VIRTUAL_PLAYBACK_APPEND_BATCH, 8); + assert.equal(virtualPlaybackWindowStart(8), 0); + assert.equal(virtualPlaybackWindowStart(40), 32); +}); + +test('trims history without changing the logical queue position', () => { + assert.equal(virtualPlaybackTrimCount(8), 0); + assert.equal(virtualPlaybackTrimCount(9), 1); + assert.equal(virtualPlaybackTrimCount(20), 12); +}); + +test('refills only below sixteen upcoming and caps coverage at thirty-two', () => { + assert.equal(VIRTUAL_PLAYBACK_REFILL_THRESHOLD, 16); + assert.equal(shouldRefillVirtualPlayback(16, 100, 200), false); + assert.equal(shouldRefillVirtualPlayback(15, 100, 200), true); + assert.equal(shouldRefillVirtualPlayback(0, 200, 200), false); + assert.equal(virtualPlaybackRefillLimit(15, 100, 200), 17); + assert.equal(virtualPlaybackRefillLimit(0, 190, 200), 10); + assert.equal(virtualPlaybackRefillLimit(31, 199, 200), 1); + assert.equal(virtualPlaybackRefillLimit(0, 200, 200), 0); +}); diff --git a/src/audio/virtualPlaybackWindow.ts b/src/audio/virtualPlaybackWindow.ts new file mode 100644 index 0000000..8274f39 --- /dev/null +++ b/src/audio/virtualPlaybackWindow.ts @@ -0,0 +1,40 @@ +/** + * RNTP is a bounded transport, never the source of truth for a library queue. + * Room owns complete order while these helpers keep local indices stable. + */ +export const VIRTUAL_PLAYBACK_HISTORY = 8; +export const VIRTUAL_PLAYBACK_UPCOMING = 32; +export const VIRTUAL_PLAYBACK_WINDOW_SIZE = + VIRTUAL_PLAYBACK_HISTORY + 1 + VIRTUAL_PLAYBACK_UPCOMING; +export const VIRTUAL_PLAYBACK_REFILL_THRESHOLD = 16; +export const VIRTUAL_PLAYBACK_APPEND_BATCH = 8; + +export function virtualPlaybackWindowStart(activePosition: number): number { + return Math.max(0, Math.floor(activePosition) - VIRTUAL_PLAYBACK_HISTORY); +} + +export function virtualPlaybackTrimCount(activeLocalIndex: number): number { + return Math.max(0, Math.floor(activeLocalIndex) - VIRTUAL_PLAYBACK_HISTORY); +} + +export function shouldRefillVirtualPlayback( + upcomingCount: number, + loadedEnd: number, + totalCount: number, +): boolean { + return upcomingCount < VIRTUAL_PLAYBACK_REFILL_THRESHOLD && loadedEnd < totalCount; +} + +export function virtualPlaybackRefillLimit( + upcomingCount: number, + loadedEnd: number, + totalCount: number, +): number { + return Math.max( + 0, + Math.min( + VIRTUAL_PLAYBACK_UPCOMING - Math.max(0, upcomingCount), + totalCount - loadedEnd, + ), + ); +} diff --git a/src/components/player/NowPlayingCompanionPane.tsx b/src/components/player/NowPlayingCompanionPane.tsx index 7e1591a..a426136 100644 --- a/src/components/player/NowPlayingCompanionPane.tsx +++ b/src/components/player/NowPlayingCompanionPane.tsx @@ -5,7 +5,7 @@ import { QueueTray } from '@/components/queue/QueueTray'; import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; import { seekTo } from '@/audio/playbackController'; import { spacing } from '@/theme'; -import { createThemedStyles } from '@/theme/themed'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { getNowPlayingTrackTransitionKey, NowPlayingTrackFadeThrough, @@ -14,6 +14,10 @@ import { usePlayerStore } from '@/stores/playerStore'; import { useSettingsStore } from '@/stores/settingsStore'; import type { NowPlayingCompanion } from './nowPlayingPreferences'; import type { Track } from '@/types/audio'; +import { + AstraQueueView, + toNativeQueuePalette, +} from '../../../modules/astra-library-scanner'; const COMPANION_SEGMENTS = [ { key: 'queue', label: 'Queue' }, @@ -35,7 +39,9 @@ export function NowPlayingCompanionPane({ track, }: NowPlayingCompanionPaneProps) { const styles = useStyles(); + const colors = useColors(); const companion = useSettingsStore((s) => s.nowPlayingCompanion); + const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled); const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion); const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0)); const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration)); @@ -68,7 +74,15 @@ export function NowPlayingCompanionPane({ {companion === 'queue' ? ( - + nativeQueueEnabled ? ( + + ) : ( + + ) ) : track ? ( ({ minHeight: 0, position: 'relative', }, + nativeQueue: { + flex: 1, + minHeight: 0, + }, })); diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 0588a38..7491808 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -108,12 +108,18 @@ import { useThemeStore } from '@/stores/themeStore'; import type { DbTrack } from '@/types/library'; import { cycleRepeat, + jumpToQueueIndex, seekTo, skipToNext, skipToPrevious, + synchronizeVirtualQueueRevision, togglePlay, toggleShuffle } from '@/audio/playbackController'; +import { + AstraQueue, + toNativeQueuePalette, +} from '../../../modules/astra-library-scanner'; import { desktopConnectionLabel, getDesktopPlaybackPresentation, @@ -174,6 +180,7 @@ export function NowPlayingOverlay() { const nowPlayingAccentSource = useThemeStore((s) => s.nowPlayingAccentSource); const coverArtAccentMethod = useThemeStore((s) => s.coverArtAccentMethod); const scopeMode = useSettingsStore((s) => s.scopeMode); + const nativeQueueEnabled = useSettingsStore((s) => s.nativeQueueEnabled); const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle); @@ -378,6 +385,12 @@ export function NowPlayingOverlay() { } suspendPanForChildTransition(); setQueueOpen(true); + if (nativeQueueEnabled && !isDesktopTarget) { + void AstraQueue.present({ palette: toNativeQueuePalette(colors) }).catch((error) => { + console.warn('[queue] native presentation failed', error); + setQueueOpen(false); + }); + } }; const swapScopeMode = () => @@ -742,10 +755,49 @@ export function NowPlayingOverlay() { // Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it. const closeQueue = useCallback(() => { suspendPanForChildTransition(); + if (nativeQueueEnabled && !isDesktopTarget) AstraQueue.dismiss(); setQueueOpen(false); // Shared values and the state setter remain stable for this overlay mount. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isDesktopTarget, nativeQueueEnabled]); + + useEffect(() => { + if (!nativeQueueEnabled) return undefined; + const dismissed = AstraQueue.addListener('onDismissed', () => { + setQueueOpen(false); + }); + const playbackRequest = AstraQueue.addListener('onPlaybackRequest', (request) => { + void (async () => { + try { + const position = await AstraQueue.resolveEntryPosition( + request.entryId, + request.queueRevision, + ); + if (position == null) { + throw new Error('The queue changed. Try that song again.'); + } + await jumpToQueueIndex(position, { virtualPosition: true }); + AstraQueue.resolvePlaybackRequest(request.requestId, true); + } catch (error) { + const message = error instanceof Error ? error.message : 'Could not play that song'; + AstraQueue.resolvePlaybackRequest(request.requestId, false, message); + } + })(); + }); + const revision = AstraQueue.addListener('onQueueRevision', (event) => { + void synchronizeVirtualQueueRevision( + event.queueRevision, + event.activePosition, + ).catch((error) => { + console.warn('[queue] transport revision sync failed', error); + }); + }); + return () => { + dismissed.remove(); + playbackRequest.remove(); + revision.remove(); + }; + }, [nativeQueueEnabled]); // Hardware back, innermost layer first: menu → queue tray → player. Registered // only while open, so it sits above the focused screen's own handlers (LIFO) @@ -1692,9 +1744,9 @@ export function NowPlayingOverlay() { {queueOpen && !hasTabletCompanion && ( isDesktopTarget ? ( - ) : ( + ) : !nativeQueueEnabled ? ( - ) + ) : null )} Promise; refreshActiveIndex: () => Promise; setSnapshot: ( @@ -47,53 +62,100 @@ export const useQueueStore = create((set) => ({ activeIndex: -1, hasSnapshot: false, source: null, + transport: null, refreshFromNative: async () => { - // Mid chunked-load the native queue is partial and index-shifted — wait it out. + // Mid chunked-load the native queue is partial — wait until all appends land. await queueLoadSettled(); const [tracks, activeIndex] = await Promise.all([ TrackPlayer.getQueue(), TrackPlayer.getActiveTrackIndex(), ]); - set({ - tracks, - activeIndex: normalizeActiveIndex(activeIndex, tracks.length), - hasSnapshot: true, + set((state) => { + const normalized = normalizeActiveIndex(activeIndex, tracks.length); + return { + tracks, + activeIndex: normalized, + hasSnapshot: true, + transport: state.transport + ? { ...state.transport, activeLocalIndex: normalized, tracks } + : null, + }; }); }, refreshActiveIndex: async () => { const activeIndex = await TrackPlayer.getActiveTrackIndex(); - set((s) => ({ - activeIndex: normalizeActiveIndex( + set((s) => { + const normalized = normalizeActiveIndex( activeIndex == null ? activeIndex : nativeIndexToAbsolute(activeIndex), s.tracks.length, - ), - })); + ); + return { + activeIndex: normalized, + transport: s.transport + ? { ...s.transport, activeLocalIndex: normalized, tracks: s.tracks } + : null, + }; + }); }, setSnapshot: (tracks, activeIndex = 0, options) => - set((state) => ({ - tracks, - activeIndex: normalizeActiveIndex(activeIndex, tracks.length), - hasSnapshot: true, - source: options && Object.hasOwn(options, 'source') - ? options.source ?? null - : state.source, - })), + set((state) => { + const normalized = normalizeActiveIndex(activeIndex, tracks.length); + const transport = options && Object.hasOwn(options, 'transport') + ? options.transport + ? { + ...options.transport, + activeLocalIndex: normalized, + tracks, + } + : null + : state.transport + ? { ...state.transport, activeLocalIndex: normalized, tracks } + : null; + return { + tracks, + activeIndex: normalized, + hasSnapshot: true, + source: options && Object.hasOwn(options, 'source') + ? options.source ?? null + : state.source, + transport, + }; + }), setActiveIndex: (activeIndex) => - set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) })), + set((s) => { + const normalized = normalizeActiveIndex(activeIndex, s.tracks.length); + return { + activeIndex: normalized, + transport: s.transport + ? { ...s.transport, activeLocalIndex: normalized, tracks: s.tracks } + : null, + }; + }), insertTrack: (track, index) => set((s) => { const insertAt = boundedInsertIndex(index, s.tracks.length); const tracks = [...s.tracks]; tracks.splice(insertAt, 0, track); const activeIndex = s.activeIndex >= insertAt ? s.activeIndex + 1 : s.activeIndex; - return { tracks, activeIndex, hasSnapshot: true }; + return { + tracks, + activeIndex, + hasSnapshot: true, + transport: s.transport + ? { ...s.transport, activeLocalIndex: activeIndex, tracks } + : null, + }; }), replaceUpcoming: (upcoming) => set((s) => { const prefixEnd = s.activeIndex >= 0 ? s.activeIndex + 1 : 0; + const tracks = [...s.tracks.slice(0, prefixEnd), ...upcoming]; return { - tracks: [...s.tracks.slice(0, prefixEnd), ...upcoming], + tracks, hasSnapshot: true, + transport: s.transport + ? { ...s.transport, tracks } + : null, }; }), moveItem: (fromIndex, toIndex) => @@ -114,7 +176,14 @@ export const useQueueStore = create((set) => ({ activeIndex += 1; } - return { tracks, activeIndex, hasSnapshot: true }; + return { + tracks, + activeIndex, + hasSnapshot: true, + transport: s.transport + ? { ...s.transport, activeLocalIndex: activeIndex, tracks } + : null, + }; }), removeIndices: (indices) => set((s) => { @@ -143,6 +212,13 @@ export const useQueueStore = create((set) => ({ tracks, activeIndex: normalizeActiveIndex(activeIndex, tracks.length), hasSnapshot: true, + transport: s.transport + ? { + ...s.transport, + activeLocalIndex: normalizeActiveIndex(activeIndex, tracks.length), + tracks, + } + : null, }; }), })); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 5b24542..f03acf8 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -29,6 +29,7 @@ const LYRICS_VISIBLE_KEY = 'lyrics_visible'; const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion'; const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode'; const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled'; +const NATIVE_QUEUE_ENABLED_KEY = 'native_queue_enabled'; /** Which visualizer the now-playing scope stage shows. */ export type ScopeMode = 'spectrum' | 'scope'; @@ -68,6 +69,7 @@ interface SettingsStore { nowPlayingCompanion: NowPlayingCompanion; homeGreetingTextMode: HomeGreetingTextMode; listeningHistoryEnabled: boolean; + nativeQueueEnabled: boolean; loaded: boolean; load: () => Promise; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; @@ -79,6 +81,7 @@ interface SettingsStore { setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise; setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise; setListeningHistoryEnabled: (enabled: boolean) => Promise; + setNativeQueueEnabled: (enabled: boolean) => Promise; } export const useSettingsStore = create((set, get) => ({ @@ -91,6 +94,7 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingCompanion: 'queue', homeGreetingTextMode: 'messages', listeningHistoryEnabled: true, + nativeQueueEnabled: false, loaded: false, load: async () => { @@ -106,6 +110,7 @@ export const useSettingsStore = create((set, get) => ({ NOW_PLAYING_COMPANION_KEY, HOME_GREETING_TEXT_MODE_KEY, LISTENING_HISTORY_ENABLED_KEY, + NATIVE_QUEUE_ENABLED_KEY, ]); const grouping = values[ARTIST_GROUPING_KEY] ?? null; const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null; @@ -116,6 +121,7 @@ export const useSettingsStore = create((set, get) => ({ const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null; const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null; const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0'; + const nativeQueueEnabled = parseBoolean(values[NATIVE_QUEUE_ENABLED_KEY] ?? null); set({ artistGroupingMode: parseGroupingMode(grouping), includeSingles: parseBoolean(includeSingles), @@ -126,6 +132,7 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode), listeningHistoryEnabled, + nativeQueueEnabled, loaded: true, }); }, @@ -194,4 +201,12 @@ export const useSettingsStore = create((set, get) => ({ throw error; } }, + + setNativeQueueEnabled: async (enabled) => { + if (get().nativeQueueEnabled === enabled) return; + set({ nativeQueueEnabled: enabled }); + await AstraLibraryData.setSettings({ + [NATIVE_QUEUE_ENABLED_KEY]: enabled ? 'true' : 'false', + }); + }, }));