diff --git a/index.js b/index.js index 1178085..86485fb 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,10 @@ // MediaSession / lock-screen / Bluetooth remote controls work even when the // JS UI isn't mounted (headless). import 'expo-router/entry'; +import { AppRegistry } from 'react-native'; import TrackPlayer from 'react-native-track-player'; +import { handleAstraCarCommand } from './src/car/carCommandService'; import { PlaybackService } from './src/audio/playbackService'; TrackPlayer.registerPlaybackService(() => PlaybackService); +AppRegistry.registerHeadlessTask('AstraCarCommand', () => handleAstraCarCommand); diff --git a/modules/astra-car/android/build.gradle b/modules/astra-car/android/build.gradle new file mode 100644 index 0000000..7dfcf41 --- /dev/null +++ b/modules/astra-car/android/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.astracar' +version = '0.1.0' + +android { + namespace "expo.modules.astracar" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + implementation "androidx.media:media:1.6.0" + implementation "com.facebook.react:react-android" +} diff --git a/modules/astra-car/android/src/main/AndroidManifest.xml b/modules/astra-car/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c2baae3 --- /dev/null +++ b/modules/astra-car/android/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtwork.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtwork.kt new file mode 100644 index 0000000..e504407 --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtwork.kt @@ -0,0 +1,52 @@ +package expo.modules.astracar + +import android.content.Context +import android.net.Uri + +/** + * content:// URI scheme served by [AstraCarArtworkProvider]. Android Auto only loads + * artwork from `content://` (and `android.resource://`) URIs — never `file://` or + * `http(s)://` — so every browse-list icon and now-playing art URI routes through here. + */ +object AstraCarArtwork { + // Must match android:authorities="${applicationId}.astracar.artwork" in the manifest. + // context.packageName resolves to the applicationId at runtime. + private const val AUTHORITY_SUFFIX = ".astracar.artwork" + + const val ART_ID_PLACEHOLDER = "__ASTRA_ART_ID__" + + const val QUERY_FULL = "full" + + // Bump to change every art URI string. The Auto host (gearhead) caches image-load + // FAILURES in Glide keyed by URI+size; URIs from the earlier builds (where the grant + // was missing) are otherwise byte-identical and keep serving the cached SecurityException + // instead of retrying. A version param makes them fresh URIs so they re-fetch. + private const val CACHE_VERSION = "1" + + fun authority(context: Context): String = context.packageName + AUTHORITY_SUFFIX + + /** + * Local cached artwork, resolved by the scanner's md5 file name (hash). `full=true` + * requests the full-res `artwork/` (for now-playing); the default prefers the + * 128px `artwork-thumbs/` file (fine for small browse-list icons). + */ + fun localUri(context: Context, hash: String, full: Boolean = false): Uri = + base(context) + .appendPath("local") + .appendPath(hash) + .appendQueryParameter("v", CACHE_VERSION) + .apply { if (full) appendQueryParameter(QUERY_FULL, "1") } + .build() + + /** Remote (Subsonic/Jellyfin) cover art, downloaded + cached on demand. */ + fun remoteUri(context: Context, sourceId: Long, artworkSourceId: String): Uri = + base(context) + .appendPath("remote") + .appendPath(sourceId.toString()) + .appendPath(artworkSourceId) + .appendQueryParameter("v", CACHE_VERSION) + .build() + + private fun base(context: Context): Uri.Builder = + Uri.Builder().scheme("content").authority(authority(context)) +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt new file mode 100644 index 0000000..fd7fddf --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt @@ -0,0 +1,134 @@ +package expo.modules.astracar + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.util.Log +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +private const val TAG = "AstraCarArt" +private const val DOWNLOAD_TIMEOUT_MS = 8_000 + +/** + * Serves artwork to Android Auto as `content://` URIs (the only scheme Auto accepts). + * `openFile` runs on a binder pool thread (never the main thread), so the bounded remote + * download here can't ANR the browse UI. + * + * content:///local/ -> cached scanner file + * content:///remote// -> server cover (download + cache) + */ +class AstraCarArtworkProvider : ContentProvider() { + override fun onCreate(): Boolean = true + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + val ctx = context ?: throw FileNotFoundException("No context") + val segments = uri.pathSegments + val full = uri.getQueryParameter(AstraCarArtwork.QUERY_FULL) != null + val file = when (segments.firstOrNull()) { + "local" -> localFile(ctx, segments.getOrNull(1), full) + "remote" -> remoteFile(ctx, segments.getOrNull(1)?.toLongOrNull(), segments.getOrNull(2)) + else -> null + } + if (file == null) { + Log.w(TAG, "openFile miss for $uri") + throw FileNotFoundException("No artwork for $uri") + } + Log.d(TAG, "openFile hit for $uri -> ${file.absolutePath}") + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + + private fun localFile(ctx: Context, hash: String?, full: Boolean): File? { + val clean = hash?.trim().orEmpty() + if (clean.isEmpty()) return null + val dot = clean.lastIndexOf('.') + val stem = if (dot > 0) clean.substring(0, dot) else clean + val thumb = File(File(ctx.filesDir, "artwork-thumbs"), "$stem.jpg") + val original = File(File(ctx.filesDir, "artwork"), clean) + // Now-playing wants full-res (128px thumbs look blurry on the now-playing card); + // browse icons prefer the small thumb. Each falls back to the other if missing. + val ordered = if (full) listOf(original, thumb) else listOf(thumb, original) + return ordered.firstOrNull { it.exists() } + } + + private fun remoteFile(ctx: Context, sourceId: Long?, artworkSourceId: String?): File? { + if (sourceId == null || artworkSourceId.isNullOrBlank()) return null + val cacheDir = File(ctx.cacheDir, "astra-car-art").apply { mkdirs() } + val cacheFile = File(cacheDir, sha1("$sourceId:$artworkSourceId") + ".img") + if (cacheFile.exists() && cacheFile.length() > 0) return cacheFile + + // art_auth is a full cover-art URL template with an id placeholder, built by JS from + // the (well-tested) Subsonic/Jellyfin URL builders so the provider stays auth-agnostic. + val template = artAuthTemplate(ctx, sourceId) ?: return null + val url = template.replace(AstraCarArtwork.ART_ID_PLACEHOLDER, Uri.encode(artworkSourceId)) + return runCatching { + download(url, cacheFile) + cacheFile.takeIf { it.length() > 0 } + }.onFailure { Log.w(TAG, "remote art download failed for $sourceId/$artworkSourceId", it) } + .getOrNull() + } + + private fun artAuthTemplate(ctx: Context, sourceId: Long): String? = + AstraCarDb.openReadable(ctx)?.use { db -> + db.rawQuery( + "SELECT art_auth FROM remote_sources WHERE id = ? LIMIT 1", + arrayOf(sourceId.toString()), + ).use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null + } + }?.takeIf { it.isNotBlank() } + + private fun download(url: String, dest: File) { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = DOWNLOAD_TIMEOUT_MS + readTimeout = DOWNLOAD_TIMEOUT_MS + instanceFollowRedirects = true + } + try { + if (connection.responseCode !in 200..299) { + throw FileNotFoundException("HTTP ${connection.responseCode}") + } + val tmp = File(dest.absolutePath + ".tmp") + connection.inputStream.use { input -> FileOutputStream(tmp).use(input::copyTo) } + if (!tmp.renameTo(dest)) { + tmp.copyTo(dest, overwrite = true) + tmp.delete() + } + } finally { + connection.disconnect() + } + } + + private fun sha1(value: String): String = + MessageDigest.getInstance("SHA-1") + .digest(value.toByteArray()) + .joinToString("") { "%02x".format(it) } + + override fun getType(uri: Uri): String = "image/*" + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt new file mode 100644 index 0000000..cd17830 --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt @@ -0,0 +1,604 @@ +package expo.modules.astracar + +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.util.Log +import java.io.File +import java.util.Locale + +private const val TAG = "AstraCarCatalog" + +// Cap a node's children when the client doesn't paginate, so a large list (e.g. all albums) +// can't exceed the ~1MB Binder transaction limit — which silently drops the result and hangs +// the browser on a spinner. Auto pages large lists itself; this only guards the unpaged path. +private const val MAX_UNPAGED_CHILDREN = 500 + +private const val TRACK_ORDER = + "COALESCE(disc_number, 9999), COALESCE(track_number, 9999), title COLLATE NOCASE" + +// Fully alias-qualified (every column `t.`): these columns are SELECTed from joins where +// the other table (favorites / playlist_tracks) also has an `added_at`, so an unqualified +// `added_at` is ambiguous and SQLite throws. Single-table queries alias `tracks t` too. +private const val TRACK_COLUMNS = + "t.id, t.path, t.title, t.artist, t.album, t.album_artist, t.album_identity_key, t.duration, " + + "t.track_number, t.disc_number, t.year, t.artwork_hash, t.source_type, t.source_id, " + + "t.source_track_id, t.artwork_source_id, t.added_at, t.modified_at" + +class AstraCarCatalog(private val context: Context) { + fun loadChildren(parentId: String, options: Bundle? = null): List { + val media = AstraCarMediaIds.decode(parentId) ?: AstraCarMediaId(kind = "root") + val items = openReadableDb()?.use { db -> childrenFor(db, media) } + ?: if (media.kind == "root") rootItems() else emptyList() + return paginate(items, options) + } + + fun loadItem(mediaId: String): MediaItem? { + val media = AstraCarMediaIds.decode(mediaId) ?: return null + return openReadableDb()?.use { db -> + when (media.kind) { + "section" -> sectionItem(media.section ?: return@use null) + "album" -> getAlbums(db).firstOrNull { it.identityKey == media.key }?.let(::albumItem) + "artist" -> getArtists(db).firstOrNull { it.artist == media.key }?.let(::artistItem) + "playlist" -> media.id?.let { id -> getPlaylists(db).firstOrNull { it.id == id } }?.let(::playlistItem) + "track" -> media.path?.let { path -> getTrackByPath(db, path) }?.let { + trackItem(it, contextFromTrackMedia(media)) + } + "root" -> rootItem() + else -> null + } + } + } + + private fun openReadableDb(): SQLiteDatabase? = AstraCarDb.openReadable(context) + + private fun childrenFor(db: SQLiteDatabase, media: AstraCarMediaId): List = + when (media.kind) { + "root" -> rootItems() + "section" -> when (media.section) { + "recent" -> getRecentlyPlayed(db).map { trackItem(it, AstraCarMediaId(kind = "section", section = "recent")) } + "favorites" -> getFavorites(db).map { trackItem(it, AstraCarMediaId(kind = "section", section = "favorites")) } + "playlists" -> getPlaylists(db).map(::playlistItem) + "albums" -> getAlbums(db).map(::albumItem) + "artists" -> getArtists(db).map(::artistItem) + else -> emptyList() + } + "playlist" -> media.id?.let { id -> + getPlaylistTracks(db, id).map { trackItem(it, AstraCarMediaId(kind = "playlist", id = id)) } + } ?: emptyList() + "album" -> media.key?.let { key -> + getAlbumTracks(db, key).map { trackItem(it, AstraCarMediaId(kind = "album", key = key)) } + } ?: emptyList() + "artist" -> media.key?.let { name -> + getArtistTracks(db, name).map { trackItem(it, AstraCarMediaId(kind = "artist", key = name)) } + } ?: emptyList() + else -> emptyList() + } + + private fun rootItem(): MediaItem = + MediaItem( + MediaDescriptionCompat.Builder() + .setMediaId(AstraCarMediaIds.root) + .setTitle("Astra") + .build(), + MediaItem.FLAG_BROWSABLE, + ) + + private fun rootItems(): List = + listOf( + sectionItem("recent"), + sectionItem("favorites"), + sectionItem("playlists"), + sectionItem("albums"), + sectionItem("artists"), + ) + + private fun sectionItem(section: String): MediaItem { + val title = when (section) { + "recent" -> "Recently Played" + "favorites" -> "Favorites" + "playlists" -> "Playlists" + "albums" -> "Albums" + "artists" -> "Artists" + else -> section.replaceFirstChar { it.titlecase(Locale.ROOT) } + } + return browsable(AstraCarMediaIds.section(section), title, null) + } + + private fun albumItem(album: AlbumRow): MediaItem = + browsable( + AstraCarMediaIds.album(album.identityKey), + album.album, + album.artist, + artworkIconUri(album.artworkHash, album.sourceId, album.artworkSourceId), + "${album.trackCount} ${if (album.trackCount == 1L) "track" else "tracks"}", + ) + + private fun artistItem(artist: ArtistRow): MediaItem = + browsable( + AstraCarMediaIds.artist(artist.artist), + artist.artist, + "${artist.trackCount} ${if (artist.trackCount == 1L) "track" else "tracks"}", + artworkIconUri(artist.artworkHash, artist.sourceId, artist.artworkSourceId), + ) + + private fun playlistItem(playlist: PlaylistRow): MediaItem = + browsable( + AstraCarMediaIds.playlist(playlist.id), + playlist.name, + "${playlist.trackCount} ${if (playlist.trackCount == 1L) "track" else "tracks"}", + artworkIconUri(playlist.artworkHash, playlist.sourceId, playlist.artworkSourceId), + ) + + private fun trackItem(track: TrackRow, contextMedia: AstraCarMediaId): MediaItem { + val extras = Bundle().apply { + putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (track.duration * 1000).toLong()) + } + val description = MediaDescriptionCompat.Builder() + .setMediaId(AstraCarMediaIds.track(track.path, contextMedia)) + .setTitle(track.title) + .setSubtitle(track.artist) + .setDescription(track.album) + .setIconUri(artworkIconUri(track.artworkHash, track.sourceId, track.artworkSourceId)) + .setExtras(extras) + .build() + return MediaItem(description, MediaItem.FLAG_PLAYABLE) + } + + private fun browsable( + mediaId: String, + title: String, + subtitle: String?, + iconUri: Uri? = null, + description: String? = null, + ): MediaItem { + val mediaDescription = MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + .setSubtitle(subtitle) + .setDescription(description) + .setIconUri(iconUri) + .build() + return MediaItem(mediaDescription, MediaItem.FLAG_BROWSABLE) + } + + /** + * Browse-list icon URI: a local content:// URI when the scanner has cached art for + * `hash`, else a remote content:// URI for a remote track with server art, else null. + * Android Auto loads art only from content:// (never file:// / http), so everything + * routes through [AstraCarArtworkProvider]. + */ + private fun artworkIconUri(hash: String?, sourceId: Long?, artworkSourceId: String?): Uri? { + localArtworkUri(hash)?.let { return it } + if (sourceId != null && !artworkSourceId.isNullOrBlank()) { + return AstraCarArtwork.remoteUri(context, sourceId, artworkSourceId) + } + return null + } + + private fun localArtworkUri(hash: String?): Uri? { + val clean = hash?.trim().orEmpty() + if (clean.isEmpty()) return null + val dot = clean.lastIndexOf('.') + val stem = if (dot > 0) clean.substring(0, dot) else clean + val thumb = File(File(context.filesDir, "artwork-thumbs"), "$stem.jpg") + val full = File(File(context.filesDir, "artwork"), clean) + return if (thumb.exists() || full.exists()) AstraCarArtwork.localUri(context, clean) else null + } + + private fun paginate(items: List, options: Bundle?): List { + val page = options?.getInt(MediaBrowserCompat.EXTRA_PAGE, -1) ?: -1 + val pageSize = options?.getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1) ?: -1 + if (page < 0 || pageSize <= 0) { + if (items.size > MAX_UNPAGED_CHILDREN) { + Log.w(TAG, "capping ${items.size} children to $MAX_UNPAGED_CHILDREN (client did not paginate)") + return items.take(MAX_UNPAGED_CHILDREN) + } + return items + } + val from = page * pageSize + if (from >= items.size) return emptyList() + val to = minOf(from + pageSize, items.size) + return items.subList(from, to) + } + + private fun contextFromTrackMedia(media: AstraCarMediaId): AstraCarMediaId = + AstraCarMediaId( + kind = media.contextKind ?: "track", + section = media.contextSection, + key = media.contextKey, + id = media.contextId, + ) + + private fun queryTracks( + db: SQLiteDatabase, + sql: String, + args: Array = emptyArray(), + ): List = + db.rawQuery(sql, args).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.toTrackRow()) + } + } + + private fun getRecentlyPlayed(db: SQLiteDatabase): List = + queryTracks( + db, + "SELECT $TRACK_COLUMNS FROM playback_history h JOIN tracks t ON t.path = h.track_path " + + "ORDER BY h.last_played_at DESC LIMIT 24", + ) + + private fun getFavorites(db: SQLiteDatabase): List = + queryTracks( + db, + "SELECT $TRACK_COLUMNS FROM favorites f JOIN tracks t ON t.path = f.track_path " + + "ORDER BY f.added_at DESC", + ) + + private fun getPlaylistTracks(db: SQLiteDatabase, playlistId: Long): List = + queryTracks( + db, + "SELECT $TRACK_COLUMNS FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path " + + "WHERE pt.playlist_id = ? ORDER BY pt.position, pt.id", + arrayOf(playlistId.toString()), + ) + + private fun getAlbumTracks(db: SQLiteDatabase, identityKey: String): List = + queryTracks( + db, + "SELECT $TRACK_COLUMNS FROM tracks t WHERE t.album_identity_key = ? ORDER BY $TRACK_ORDER", + arrayOf(identityKey), + ) + + private fun getTrackByPath(db: SQLiteDatabase, path: String): TrackRow? = + queryTracks(db, "SELECT $TRACK_COLUMNS FROM tracks t WHERE t.path = ? LIMIT 1", arrayOf(path)).firstOrNull() + + private fun getAllTracks(db: SQLiteDatabase): List = + queryTracks( + db, + "SELECT $TRACK_COLUMNS FROM tracks t ORDER BY t.artist COLLATE NOCASE, t.album COLLATE NOCASE, $TRACK_ORDER", + ) + + private fun getArtistTracks(db: SQLiteDatabase, artist: String): List { + val mode = getArtistGroupingMode(db) + return getAllTracks(db).filter { trackMatchesBrowseArtist(it, normalizeKey(artist), mode) } + } + + private fun getAlbums(db: SQLiteDatabase): List = + db.rawQuery( + """ + SELECT album_identity_key AS identity_key, + MAX(album) AS album, + MAX(COALESCE(album_artist, artist)) AS artist, + MAX(year) AS year, + MAX(artwork_hash) AS artwork_hash, + MAX(source_id) AS source_id, + MAX(artwork_source_id) AS artwork_source_id, + COUNT(*) AS track_count + FROM tracks + GROUP BY album_identity_key + ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE + """.trimIndent(), + emptyArray(), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + AlbumRow( + identityKey = cursor.string("identity_key"), + album = cursor.string("album"), + artist = cursor.string("artist"), + artworkHash = cursor.nullableString("artwork_hash"), + sourceId = cursor.nullableLong("source_id"), + artworkSourceId = cursor.nullableString("artwork_source_id"), + trackCount = cursor.long("track_count"), + ), + ) + } + } + } + + private fun getPlaylists(db: SQLiteDatabase): List = + db.rawQuery( + """ + SELECT p.id, p.name, + (SELECT t.artwork_hash + FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path + WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL + ORDER BY pt.position, pt.id LIMIT 1) AS artwork_hash, + (SELECT t.source_id + FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path + WHERE pt.playlist_id = p.id AND t.artwork_source_id IS NOT NULL + ORDER BY pt.position, pt.id LIMIT 1) AS source_id, + (SELECT t.artwork_source_id + FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path + WHERE pt.playlist_id = p.id AND t.artwork_source_id IS NOT NULL + ORDER BY pt.position, pt.id LIMIT 1) AS artwork_source_id, + (SELECT COUNT(*) + FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path + WHERE pt.playlist_id = p.id) AS track_count + FROM playlists p + ORDER BY (p.last_played_at IS NULL), p.last_played_at DESC, p.updated_at DESC + """.trimIndent(), + emptyArray(), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + PlaylistRow( + id = cursor.long("id"), + name = cursor.string("name"), + artworkHash = cursor.nullableString("artwork_hash"), + sourceId = cursor.nullableLong("source_id"), + artworkSourceId = cursor.nullableString("artwork_source_id"), + trackCount = cursor.long("track_count"), + ), + ) + } + } + } + + private fun getArtistGroupingMode(db: SQLiteDatabase): String = + db.rawQuery( + "SELECT value FROM settings WHERE key = ? LIMIT 1", + arrayOf("artist_grouping_mode"), + ).use { cursor -> + if (cursor.moveToFirst() && cursor.string(0) == "fileTags") "fileTags" else "astra" + } + + private fun getArtists(db: SQLiteDatabase): List = + buildArtistList(getAllTracks(db), getArtistGroupingMode(db)) + + private fun buildArtistList(tracks: List, mode: String): List { + val byKey = linkedMapOf() + + for (track in tracks) { + val names = + if (mode == "fileTags") listOf(resolveStrictBrowseArtist(track)) + else getCanonicalArtistIndexNames(track) + val seen = mutableSetOf() + + for (name in names) { + val key = normalizeKey(name) + if (key.isEmpty() || !seen.add(key)) continue + val aggregate = byKey.getOrPut(key) { + ArtistAggregate( + artist = name, + trackCount = 0, + artworkHash = null, + artworkYear = -1, + artworkAddedAt = -1, + artworkModifiedAt = -1, + ) + } + aggregate.trackCount += 1 + + // Remote tracks carry no local artwork_hash; keep the first server cover ref as a + // fallback so remote-only artists still get a tile (local hash wins when present). + if (aggregate.remoteArtworkSourceId == null && + track.sourceId != null && !track.artworkSourceId.isNullOrBlank() + ) { + aggregate.remoteSourceId = track.sourceId + aggregate.remoteArtworkSourceId = track.artworkSourceId + } + + val artworkHash = track.artworkHash ?: continue + val candidateYear = track.year ?: -1 + val better = + aggregate.artworkHash == null || + candidateYear > aggregate.artworkYear || + (candidateYear == aggregate.artworkYear && + (track.addedAt > aggregate.artworkAddedAt || + (track.addedAt == aggregate.artworkAddedAt && track.modifiedAt > aggregate.artworkModifiedAt))) + if (!better) continue + aggregate.artworkHash = artworkHash + aggregate.artworkYear = candidateYear + aggregate.artworkAddedAt = track.addedAt + aggregate.artworkModifiedAt = track.modifiedAt + } + } + + return byKey.values + .map { + ArtistRow( + it.artist, + it.trackCount.toLong(), + it.artworkHash, + it.remoteSourceId, + it.remoteArtworkSourceId, + ) + } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.artist }) + } + + private fun normalizeDisplay(value: String?): String = + value.orEmpty().replace(Regex("\\s+"), " ").trim() + + private fun normalizeKey(value: String?): String = + normalizeDisplay(value).lowercase(Locale.ROOT) + + private fun splitCollaborators(rawArtist: String?): List { + val normalized = normalizeDisplay(rawArtist) + if (normalized.isEmpty()) return emptyList() + val unified = normalized + .replace(Regex("\\s*;\\s*"), ",") + .replace(Regex("\\s+&\\s+"), ",") + .replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",") + .replace(Regex("\\s+(feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",") + return dedupeByKey(unified.split(",")) + } + + private fun splitAlbumArtistCollaborators(rawAlbumArtist: String?): List { + val normalized = normalizeDisplay(rawAlbumArtist) + if (normalized.isEmpty()) return emptyList() + val unified = normalized + .replace(Regex("\\s*;\\s*"), ",") + .replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",") + .replace(Regex("\\s+(feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",") + return dedupeByKey(unified.split(",")) + } + + private fun dedupeByKey(parts: List): List { + val unique = linkedMapOf() + for (part in parts) { + val display = normalizeDisplay(part) + val key = normalizeKey(display) + if (key.isEmpty() || unique.containsKey(key)) continue + unique[key] = display + } + return unique.values.toList() + } + + private fun resolveStrictBrowseArtist(track: TrackRow): String { + val albumArtist = normalizeDisplay(track.albumArtist) + return albumArtist.ifEmpty { normalizeDisplay(track.artist).ifEmpty { "Unknown Artist" } } + } + + private fun resolveCanonicalBrowseArtist(track: TrackRow): String { + val albumArtist = normalizeDisplay(track.albumArtist) + if (albumArtist.isNotEmpty()) { + return splitAlbumArtistCollaborators(albumArtist).firstOrNull() ?: albumArtist + } + return splitCollaborators(track.artist).firstOrNull() ?: "Unknown Artist" + } + + private fun getCanonicalArtistIndexNames(track: TrackRow): List { + val unique = linkedMapOf() + fun add(name: String?) { + val display = normalizeDisplay(name) + val key = normalizeKey(display) + if (key.isEmpty() || unique.containsKey(key)) return + unique[key] = display + } + + add(resolveCanonicalBrowseArtist(track)) + val trackArtists = splitCollaborators(track.artist) + for (name in trackArtists) add(name) + if (trackArtists.isEmpty()) { + for (name in splitAlbumArtistCollaborators(track.albumArtist)) add(name) + } + return unique.values.toList() + } + + private fun trackMatchesBrowseArtist(track: TrackRow, targetArtistKey: String, mode: String): Boolean { + val browseKey = normalizeKey( + if (mode == "fileTags") resolveStrictBrowseArtist(track) else resolveCanonicalBrowseArtist(track), + ) + if (browseKey == targetArtistKey) return true + if (mode == "fileTags") return false + + val albumArtistKey = normalizeKey(track.albumArtist) + if (albumArtistKey.isNotEmpty() && albumArtistKey == targetArtistKey) return true + + val trackArtistKey = normalizeKey(track.artist) + if (trackArtistKey.isNotEmpty() && trackArtistKey == targetArtistKey) return true + + if (splitAlbumArtistCollaborators(track.albumArtist).any { normalizeKey(it) == targetArtistKey }) return true + return splitCollaborators(track.artist).any { normalizeKey(it) == targetArtistKey } + } +} + +private data class TrackRow( + val id: Long, + val path: String, + val title: String, + val artist: String, + val album: String, + val albumArtist: String?, + val albumIdentityKey: String, + val duration: Double, + val trackNumber: Long?, + val discNumber: Long?, + val year: Long?, + val artworkHash: String?, + val sourceType: String, + val sourceId: Long?, + val sourceTrackId: String?, + val artworkSourceId: String?, + val addedAt: Long, + val modifiedAt: Long, +) + +private data class AlbumRow( + val identityKey: String, + val album: String, + val artist: String, + val artworkHash: String?, + val sourceId: Long?, + val artworkSourceId: String?, + val trackCount: Long, +) + +private data class PlaylistRow( + val id: Long, + val name: String, + val artworkHash: String?, + val sourceId: Long?, + val artworkSourceId: String?, + val trackCount: Long, +) + +private data class ArtistRow( + val artist: String, + val trackCount: Long, + val artworkHash: String?, + val sourceId: Long?, + val artworkSourceId: String?, +) + +private data class ArtistAggregate( + val artist: String, + var trackCount: Int, + var artworkHash: String?, + var artworkYear: Long, + var artworkAddedAt: Long, + var artworkModifiedAt: Long, + var remoteSourceId: Long? = null, + var remoteArtworkSourceId: String? = null, +) + +private fun Cursor.toTrackRow(): TrackRow = + TrackRow( + id = long("id"), + path = string("path"), + title = string("title"), + artist = string("artist"), + album = string("album"), + albumArtist = nullableString("album_artist"), + albumIdentityKey = string("album_identity_key"), + duration = double("duration"), + trackNumber = nullableLong("track_number"), + discNumber = nullableLong("disc_number"), + year = nullableLong("year"), + artworkHash = nullableString("artwork_hash"), + sourceType = string("source_type"), + sourceId = nullableLong("source_id"), + sourceTrackId = nullableString("source_track_id"), + artworkSourceId = nullableString("artwork_source_id"), + addedAt = long("added_at"), + modifiedAt = long("modified_at"), + ) + +private fun Cursor.string(name: String): String = string(getColumnIndexOrThrow(name)) + +private fun Cursor.string(index: Int): String = getString(index) ?: "" + +private fun Cursor.nullableString(name: String): String? { + val index = getColumnIndexOrThrow(name) + return if (isNull(index)) null else getString(index) +} + +private fun Cursor.long(name: String): Long = getLong(getColumnIndexOrThrow(name)) + +private fun Cursor.nullableLong(name: String): Long? { + val index = getColumnIndexOrThrow(name) + return if (isNull(index)) null else getLong(index) +} + +private fun Cursor.double(name: String): Double = getDouble(getColumnIndexOrThrow(name)) diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt new file mode 100644 index 0000000..7737786 --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt @@ -0,0 +1,158 @@ +package expo.modules.astracar + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.facebook.react.HeadlessJsTaskService +import com.facebook.react.bridge.Arguments +import com.facebook.react.jstasks.HeadlessJsTaskConfig + +class AstraCarCommandService : HeadlessJsTaskService() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // We're started via startForegroundService (so transport from the car works even when + // the app is backgrounded — the media-session callback grants the FGS-start allowlist). + // Promote immediately to satisfy the "call startForeground within ~5s" requirement. + if (intent?.getStringExtra(EXTRA_COMMAND) != null) { + promoteToForeground() + } + return super.onStartCommand(intent, flags, startId) + } + + override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? { + val command = intent?.getStringExtra(EXTRA_COMMAND) ?: return null + val data = Arguments.createMap().apply { + putString("command", command) + intent.getBundleExtra(EXTRA_MEDIA)?.let { putMap("media", Arguments.fromBundle(it)) } + if (intent.hasExtra(EXTRA_QUERY)) putString("query", intent.getStringExtra(EXTRA_QUERY)) + if (intent.hasExtra(EXTRA_FOCUS)) putString("focus", intent.getStringExtra(EXTRA_FOCUS)) + if (intent.hasExtra(EXTRA_TITLE)) putString("title", intent.getStringExtra(EXTRA_TITLE)) + if (intent.hasExtra(EXTRA_ARTIST)) putString("artist", intent.getStringExtra(EXTRA_ARTIST)) + if (intent.hasExtra(EXTRA_ALBUM)) putString("album", intent.getStringExtra(EXTRA_ALBUM)) + if (intent.hasExtra(EXTRA_PLAYLIST)) putString("playlist", intent.getStringExtra(EXTRA_PLAYLIST)) + if (intent.hasExtra(EXTRA_POSITION)) putDouble("position", intent.getDoubleExtra(EXTRA_POSITION, 0.0)) + } + return HeadlessJsTaskConfig("AstraCarCommand", data, 30_000, true) + } + + override fun onHeadlessJsTaskFinish(taskId: Int) { + super.onHeadlessJsTaskFinish(taskId) + // The base impl stops the service when the last task finishes; drop the FGS notification. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + } + + private fun promoteToForeground() { + val promoted = runCatching { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + startForeground(NOTIFICATION_ID, notification) + } + }.isSuccess + // If we couldn't promote (e.g. FGS-start not allowed), stop now rather than let the + // system kill the whole process with "did not call startForeground in time". + if (!promoted) stopSelf() + } + + private fun buildNotification(): Notification { + ensureChannel() + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Astra") + .setContentText("Handling car controls") + .setSmallIcon(android.R.drawable.ic_media_play) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .build() + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Car controls", NotificationManager.IMPORTANCE_LOW).apply { + setShowBadge(false) + }, + ) + } + + companion object { + private const val EXTRA_COMMAND = "command" + private const val EXTRA_MEDIA = "media" + private const val EXTRA_QUERY = "query" + private const val EXTRA_FOCUS = "focus" + private const val EXTRA_TITLE = "title" + private const val EXTRA_ARTIST = "artist" + private const val EXTRA_ALBUM = "album" + private const val EXTRA_PLAYLIST = "playlist" + private const val EXTRA_POSITION = "position" + + private const val CHANNEL_ID = "astra_car_commands" + private const val NOTIFICATION_ID = 0xACAB + + fun startTransport(context: Context, command: String) { + start(context, Intent(context, AstraCarCommandService::class.java).putExtra(EXTRA_COMMAND, command)) + } + + fun startSeek(context: Context, positionMs: Long) { + start( + context, + Intent(context, AstraCarCommandService::class.java) + .putExtra(EXTRA_COMMAND, "seek") + .putExtra(EXTRA_POSITION, positionMs / 1000.0), + ) + } + + fun startPlayFromMediaId(context: Context, mediaId: String?) { + val media = AstraCarMediaIds.decode(mediaId) ?: return + start( + context, + Intent(context, AstraCarCommandService::class.java) + .putExtra(EXTRA_COMMAND, "playMediaId") + .putExtra(EXTRA_MEDIA, AstraCarMediaIds.toBundle(media)), + ) + } + + fun startPlayFromSearch(context: Context, query: String?, extras: Bundle?) { + val focus = extras?.getString(MediaStore.EXTRA_MEDIA_FOCUS)?.let(::normalizeFocus) + start( + context, + Intent(context, AstraCarCommandService::class.java) + .putExtra(EXTRA_COMMAND, "playSearch") + .putExtra(EXTRA_QUERY, query) + .putExtra(EXTRA_FOCUS, focus) + .putExtra(EXTRA_TITLE, extras?.getString(MediaStore.EXTRA_MEDIA_TITLE)) + .putExtra(EXTRA_ARTIST, extras?.getString(MediaStore.EXTRA_MEDIA_ARTIST)) + .putExtra(EXTRA_ALBUM, extras?.getString(MediaStore.EXTRA_MEDIA_ALBUM)) + .putExtra(EXTRA_PLAYLIST, extras?.getString(MediaStore.EXTRA_MEDIA_PLAYLIST)), + ) + } + + private fun start(context: Context, intent: Intent) { + HeadlessJsTaskService.acquireWakeLockNow(context) + runCatching { ContextCompat.startForegroundService(context.applicationContext, intent) } + } + + private fun normalizeFocus(value: String): String = + when { + value.contains("artist", ignoreCase = true) -> "artist" + value.contains("album", ignoreCase = true) -> "album" + value.contains("playlist", ignoreCase = true) -> "playlist" + value.contains("genre", ignoreCase = true) -> "genre" + else -> value + } + } +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarDb.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarDb.kt new file mode 100644 index 0000000..0f42f42 --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarDb.kt @@ -0,0 +1,30 @@ +package expo.modules.astracar + +import android.content.Context +import android.database.sqlite.SQLiteDatabase + +/** + * Shared read access to the op-sqlite library database (`astra-library.db`), used by + * both the browse catalog and the artwork ContentProvider. op-sqlite stores the file at + * [Context.getDatabasePath] (the same dir we read here). + */ +object AstraCarDb { + const val DB_NAME = "astra-library.db" + + /** + * Opens the library DB for reading. The JS side runs it in WAL mode + * (`PRAGMA journal_mode = WAL`), and a strict `OPEN_READONLY` open of a live WAL + * database can throw ("could not open in read/write mode" — read-only WAL needs a + * writable `-shm`/`-wal`). Try read-only first, then fall back to read/write (we only + * ever run `SELECT`s). Returns null if the DB is missing or can't be opened. + */ + fun openReadable(context: Context): SQLiteDatabase? { + val file = context.getDatabasePath(DB_NAME) + if (!file.exists()) return null + return runCatching { + SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY) + }.recoverCatching { + SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READWRITE) + }.getOrNull() + } +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaId.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaId.kt new file mode 100644 index 0000000..8b5397b --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaId.kt @@ -0,0 +1,125 @@ +package expo.modules.astracar + +import android.os.Bundle +import android.util.Base64 +import org.json.JSONObject + +data class AstraCarMediaId( + val kind: String, + val section: String? = null, + val key: String? = null, + val id: Long? = null, + val path: String? = null, + val contextKind: String? = null, + val contextSection: String? = null, + val contextKey: String? = null, + val contextId: Long? = null, +) + +object AstraCarMediaIds { + private const val PREFIX = "astra:" + + val root: String = encode(AstraCarMediaId(kind = "root")) + + fun section(section: String): String = + encode(AstraCarMediaId(kind = "section", section = section)) + + fun album(identityKey: String): String = + encode(AstraCarMediaId(kind = "album", key = identityKey)) + + fun artist(name: String): String = + encode(AstraCarMediaId(kind = "artist", key = name)) + + fun playlist(id: Long): String = + encode(AstraCarMediaId(kind = "playlist", id = id)) + + fun track(path: String, context: AstraCarMediaId): String = + encode( + AstraCarMediaId( + kind = "track", + path = path, + contextKind = context.kind, + contextSection = context.section, + contextKey = context.key, + contextId = context.id, + ), + ) + + fun encode(media: AstraCarMediaId): String { + val json = JSONObject() + .put("kind", media.kind) + .putIfPresent("section", media.section) + .putIfPresent("key", media.key) + .putIfPresent("id", media.id) + .putIfPresent("path", media.path) + .putIfPresent("contextKind", media.contextKind) + .putIfPresent("contextSection", media.contextSection) + .putIfPresent("contextKey", media.contextKey) + .putIfPresent("contextId", media.contextId) + .toString() + val encoded = Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING, + ) + return "$PREFIX$encoded" + } + + fun decode(mediaId: String?): AstraCarMediaId? { + if (mediaId.isNullOrBlank() || !mediaId.startsWith(PREFIX)) return null + return runCatching { + val jsonText = String( + Base64.decode(mediaId.removePrefix(PREFIX), Base64.URL_SAFE), + Charsets.UTF_8, + ) + val json = JSONObject(jsonText) + AstraCarMediaId( + kind = json.optString("kind"), + section = json.optStringOrNull("section"), + key = json.optStringOrNull("key"), + id = json.optLongOrNull("id"), + path = json.optStringOrNull("path"), + contextKind = json.optStringOrNull("contextKind"), + contextSection = json.optStringOrNull("contextSection"), + contextKey = json.optStringOrNull("contextKey"), + contextId = json.optLongOrNull("contextId"), + ) + }.getOrNull() + } + + fun toBundle(media: AstraCarMediaId): Bundle = + Bundle().apply { + putString("kind", media.kind) + putNullableString("section", media.section) + putNullableString("key", media.key) + media.id?.let { putDouble("id", it.toDouble()) } + putNullableString("path", media.path) + putNullableString("contextKind", media.contextKind) + putNullableString("contextSection", media.contextSection) + putNullableString("contextKey", media.contextKey) + media.contextId?.let { putDouble("contextId", it.toDouble()) } + } + + private fun JSONObject.putIfPresent(key: String, value: String?): JSONObject { + if (!value.isNullOrBlank()) put(key, value) + return this + } + + private fun JSONObject.putIfPresent(key: String, value: Long?): JSONObject { + if (value != null) put(key, value) + return this + } + + private fun JSONObject.optStringOrNull(key: String): String? { + if (!has(key) || isNull(key)) return null + return optString(key).trim().takeIf { it.isNotEmpty() } + } + + private fun JSONObject.optLongOrNull(key: String): Long? { + if (!has(key) || isNull(key)) return null + return optLong(key) + } + + private fun Bundle.putNullableString(key: String, value: String?) { + if (value != null) putString(key, value) + } +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt new file mode 100644 index 0000000..56e7104 --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt @@ -0,0 +1,135 @@ +package expo.modules.astracar + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.util.Log +import androidx.media.MediaBrowserServiceCompat + +private const val TAG = "AstraCarMedia" + +class AstraCarMediaService : MediaBrowserServiceCompat() { + private lateinit var mediaSession: MediaSessionCompat + private lateinit var catalog: AstraCarCatalog + + override fun onCreate() { + super.onCreate() + catalog = AstraCarCatalog(this) + mediaSession = MediaSessionCompat(this, "AstraCar").apply { + setCallback( + object : MediaSessionCompat.Callback() { + override fun onPlay() { + AstraCarCommandService.startTransport(this@AstraCarMediaService, "play") + } + + override fun onPause() { + AstraCarCommandService.startTransport(this@AstraCarMediaService, "pause") + } + + override fun onStop() { + AstraCarCommandService.startTransport(this@AstraCarMediaService, "pause") + } + + override fun onSkipToNext() { + AstraCarCommandService.startTransport(this@AstraCarMediaService, "next") + } + + override fun onSkipToPrevious() { + AstraCarCommandService.startTransport(this@AstraCarMediaService, "previous") + } + + override fun onSeekTo(pos: Long) { + AstraCarCommandService.startSeek(this@AstraCarMediaService, pos) + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + AstraCarCommandService.startPlayFromMediaId(this@AstraCarMediaService, mediaId) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + AstraCarCommandService.startPlayFromSearch(this@AstraCarMediaService, query, extras) + } + }, + ) + applyAstraState(AstraCarNowPlayingStore.load(this@AstraCarMediaService)) + } + sessionToken = mediaSession.sessionToken + AstraCarNowPlayingStore.attach(this) + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle?, + ): BrowserRoot { + // The art provider is exported=false; the framework does NOT auto-grant browse-item + // icon URIs to the Auto host on every head unit (observed SecurityException from + // gearhead). Explicitly grant the connecting client read access to our art subtrees. + grantArtworkAccess(clientPackageName) + return BrowserRoot(AstraCarMediaIds.root, null) + } + + private fun grantArtworkAccess(clientPackageName: String) { + val authority = AstraCarArtwork.authority(this) + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + for (path in listOf("local", "remote")) { + runCatching { + grantUriPermission(clientPackageName, Uri.parse("content://$authority/$path"), flags) + }.onFailure { Log.w(TAG, "grantUriPermission failed for $clientPackageName/$path", it) } + } + } + + override fun onLoadChildren(parentId: String, result: Result>) { + loadChildren(parentId, null, result) + } + + override fun onLoadChildren( + parentId: String, + result: Result>, + options: Bundle, + ) { + loadChildren(parentId, options, result) + } + + override fun onLoadItem(itemId: String, result: Result) { + result.detach() + Thread { + val item = runCatching { catalog.loadItem(itemId) } + .onFailure { Log.e(TAG, "loadItem failed for $itemId", it) } + .getOrNull() + runCatching { result.sendResult(item) } + .onFailure { Log.e(TAG, "sendResult(item) failed for $itemId", it) } + }.start() + } + + fun applyNowPlaying(state: AstraCarNowPlayingState) { + if (this::mediaSession.isInitialized) { + mediaSession.applyAstraState(state) + } + } + + override fun onDestroy() { + AstraCarNowPlayingStore.detach(this) + if (this::mediaSession.isInitialized) { + mediaSession.release() + } + super.onDestroy() + } + + private fun loadChildren( + parentId: String, + options: Bundle?, + result: Result>, + ) { + result.detach() + Thread { + val items = runCatching { catalog.loadChildren(parentId, options).toMutableList() } + .onFailure { Log.e(TAG, "loadChildren failed for $parentId", it) } + .getOrDefault(mutableListOf()) + runCatching { result.sendResult(items) } + .onFailure { Log.e(TAG, "sendResult failed for $parentId (${items.size} items)", it) } + }.start() + } +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt new file mode 100644 index 0000000..dc7f2d4 --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt @@ -0,0 +1,78 @@ +package expo.modules.astracar + +import android.content.Context +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record + +class AstraCarNowPlayingRecord : Record { + @Field + val title: String? = null + + @Field + val artist: String? = null + + @Field + val album: String? = null + + @Field + val artworkHash: String? = null + + @Field + val artworkSourceId: String? = null + + @Field + val artworkSourceKey: Double? = null + + @Field + val playbackState: String = "stopped" + + @Field + val hasTrack: Boolean = false + + @Field + val duration: Double? = null + + @Field + val position: Double? = null +} + +class AstraCarModule : Module() { + override fun definition() = ModuleDefinition { + Name("AstraCar") + + Function("setNowPlaying") { state: AstraCarNowPlayingRecord -> + val context = requireContext() + AstraCarNowPlayingStore.saveAndApply( + context, + AstraCarNowPlayingState( + title = state.title, + artist = state.artist, + album = state.album, + artworkUri = resolveArtworkUri(context, state), + playbackState = state.playbackState, + hasTrack = state.hasTrack, + durationSeconds = state.duration, + positionSeconds = state.position, + ), + ) + } + } + + /** Build the content:// art URI Android Auto can load (local hash wins; else remote ref). */ + private fun resolveArtworkUri(context: Context, state: AstraCarNowPlayingRecord): String? { + val hash = state.artworkHash?.trim() + if (!hash.isNullOrEmpty()) return AstraCarArtwork.localUri(context, hash, full = true).toString() + val sourceId = state.artworkSourceKey?.toLong() + val artId = state.artworkSourceId?.trim() + if (sourceId != null && !artId.isNullOrEmpty()) { + return AstraCarArtwork.remoteUri(context, sourceId, artId).toString() + } + return null + } + + private fun requireContext(): Context = + appContext.reactContext ?: throw Exceptions.ReactContextLost() +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt new file mode 100644 index 0000000..452a1cd --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt @@ -0,0 +1,157 @@ +package expo.modules.astracar + +import android.content.Context +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import org.json.JSONObject +import java.lang.ref.WeakReference + +data class AstraCarNowPlayingState( + val title: String?, + val artist: String?, + val album: String?, + val artworkUri: String?, + val playbackState: String, + val hasTrack: Boolean, + val durationSeconds: Double?, + val positionSeconds: Double?, +) + +object AstraCarNowPlayingStore { + private const val PREFS_NAME = "astra_car_now_playing" + private const val KEY_STATE = "state" + + private var serviceRef: WeakReference? = null + + fun attach(service: AstraCarMediaService) { + serviceRef = WeakReference(service) + } + + fun detach(service: AstraCarMediaService) { + if (serviceRef?.get() === service) serviceRef = null + } + + fun saveAndApply(context: Context, state: AstraCarNowPlayingState) { + context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_STATE, encode(state)) + .apply() + serviceRef?.get()?.applyNowPlaying(state) + } + + fun load(context: Context): AstraCarNowPlayingState = + decode( + context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_STATE, null), + ) + + fun buildMetadata(state: AstraCarNowPlayingState): MediaMetadataCompat = + MediaMetadataCompat.Builder().apply { + state.title?.let { + putString(MediaMetadataCompat.METADATA_KEY_TITLE, it) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, it) + } + state.artist?.let { + putString(MediaMetadataCompat.METADATA_KEY_ARTIST, it) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, it) + } + state.album?.let { + putString(MediaMetadataCompat.METADATA_KEY_ALBUM, it) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, it) + } + state.artworkUri?.let { + putString(MediaMetadataCompat.METADATA_KEY_ART_URI, it) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, it) + } + state.durationSeconds?.let { + putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (it * 1000).toLong()) + } + }.build() + + fun buildPlaybackState(state: AstraCarNowPlayingState): PlaybackStateCompat { + val playbackState = when (state.playbackState) { + "playing" -> PlaybackStateCompat.STATE_PLAYING + "paused" -> PlaybackStateCompat.STATE_PAUSED + "loading" -> PlaybackStateCompat.STATE_BUFFERING + else -> PlaybackStateCompat.STATE_STOPPED + } + val actions = + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + return PlaybackStateCompat.Builder() + .setActions(actions) + .setState( + playbackState, + ((state.positionSeconds ?: 0.0) * 1000).toLong(), + if (playbackState == PlaybackStateCompat.STATE_PLAYING) 1f else 0f, + ) + .build() + } + + private fun encode(state: AstraCarNowPlayingState): String = + JSONObject() + .put("title", state.title) + .put("artist", state.artist) + .put("album", state.album) + .put("artworkUri", state.artworkUri) + .put("playbackState", state.playbackState) + .put("hasTrack", state.hasTrack) + .put("durationSeconds", state.durationSeconds) + .put("positionSeconds", state.positionSeconds) + .toString() + + private fun decode(value: String?): AstraCarNowPlayingState { + if (value.isNullOrBlank()) return emptyState() + return runCatching { + val json = JSONObject(value) + AstraCarNowPlayingState( + title = json.optNullableString("title"), + artist = json.optNullableString("artist"), + album = json.optNullableString("album"), + artworkUri = json.optNullableString("artworkUri"), + playbackState = json.optString("playbackState", "stopped"), + hasTrack = json.optBoolean("hasTrack", false), + durationSeconds = json.optDoubleOrNull("durationSeconds"), + positionSeconds = json.optDoubleOrNull("positionSeconds"), + ) + }.getOrDefault(emptyState()) + } + + private fun emptyState(): AstraCarNowPlayingState = + AstraCarNowPlayingState( + title = null, + artist = null, + album = null, + artworkUri = null, + playbackState = "stopped", + hasTrack = false, + durationSeconds = null, + positionSeconds = null, + ) + + private fun JSONObject.optNullableString(key: String): String? { + if (!has(key) || isNull(key)) return null + return optString(key).trim().takeIf { it.isNotEmpty() } + } + + private fun JSONObject.optDoubleOrNull(key: String): Double? { + if (!has(key) || isNull(key)) return null + val value = optDouble(key) + return if (value.isNaN()) null else value + } +} + +fun MediaSessionCompat.applyAstraState(state: AstraCarNowPlayingState) { + setMetadata(AstraCarNowPlayingStore.buildMetadata(state)) + setPlaybackState(AstraCarNowPlayingStore.buildPlaybackState(state)) + isActive = state.hasTrack || state.playbackState != "stopped" +} diff --git a/modules/astra-car/android/src/main/res/xml/automotive_app_desc.xml b/modules/astra-car/android/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..08083bc --- /dev/null +++ b/modules/astra-car/android/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + diff --git a/modules/astra-car/expo-module.config.json b/modules/astra-car/expo-module.config.json new file mode 100644 index 0000000..0568003 --- /dev/null +++ b/modules/astra-car/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.astracar.AstraCarModule"] + } +} diff --git a/modules/astra-car/index.ts b/modules/astra-car/index.ts new file mode 100644 index 0000000..b501a27 --- /dev/null +++ b/modules/astra-car/index.ts @@ -0,0 +1,29 @@ +import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core'; + +import type { PlaybackState } from '@/types/audio'; + +export interface AstraCarNowPlayingState { + title?: string | null; + artist?: string | null; + album?: string | null; + /** Local cached artwork file name; native builds a content:// URI from it. */ + artworkHash?: string | null; + /** Remote server cover id (Subsonic/Jellyfin); used with artworkSourceKey. */ + artworkSourceId?: string | null; + /** Remote source row id (remote_sources.id) that owns artworkSourceId. */ + artworkSourceKey?: number | null; + playbackState: PlaybackState; + hasTrack: boolean; + duration?: number | null; + position?: number | null; +} + +declare class AstraCarModuleType extends NativeModule { + setNowPlaying(state: AstraCarNowPlayingState): void; +} + +const native = requireOptionalNativeModule('AstraCar'); + +export const AstraCar = (native ?? { + setNowPlaying: () => {}, +}) as AstraCarModuleType; diff --git a/patches/react-native-track-player+4.1.2.patch b/patches/react-native-track-player+4.1.2.patch index 7685661..497852a 100644 --- a/patches/react-native-track-player+4.1.2.patch +++ b/patches/react-native-track-player+4.1.2.patch @@ -1,8 +1,30 @@ 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..fb5d5a5 100644 +index b2409a0..ea4b393 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 -@@ -251,7 +251,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -169,8 +169,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM + return + } + ++ val bundledData = Arguments.toBundle(data) ++ val androidOptions = bundledData?.getBundle(MusicService.ANDROID_OPTIONS_KEY) ++ val allowBackgroundSetup = androidOptions?.getBoolean("allowBackgroundSetup") ?: false ++ + // prevent crash Fatal Exception: android.app.RemoteServiceException$ForegroundServiceDidNotStartInTimeException +- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && AppForegroundTracker.backgrounded) { ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && AppForegroundTracker.backgrounded && !allowBackgroundSetup) { + promise.reject( + "android_cannot_setup_player_in_background", + "On Android the app must be in the foreground when setting up the player." +@@ -179,7 +183,6 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM + } + + // Validate buffer keys. +- val bundledData = Arguments.toBundle(data) + val minBuffer = + bundledData?.getDouble(MusicService.MIN_BUFFER_KEY)?.toMilliseconds()?.toInt() + ?: DEFAULT_MIN_BUFFER_MS +@@ -251,7 +254,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM } @ReactMethod @@ -11,7 +33,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch val options = Arguments.toBundle(data) -@@ -262,9 +262,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -262,9 +265,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -23,7 +45,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch try { -@@ -283,9 +284,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -283,9 +287,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM rejectWithException(callback, exception) } } @@ -35,7 +57,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch if (data == null) { callback.resolve(null) -@@ -299,16 +301,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -299,16 +304,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.reject("invalid_track_object", "Track was not a dictionary type") } } @@ -56,7 +78,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch val inputIndexes = Arguments.toList(data) if (inputIndexes != null) { -@@ -329,9 +333,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -329,9 +336,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM } callback.resolve(null) } @@ -68,7 +90,7 @@ index b2409a0..fb5d5a5 100644 scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch -@@ -346,9 +351,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -346,9 +354,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } } @@ -80,7 +102,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch if (musicService.tracks.isEmpty()) -@@ -362,9 +368,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -362,9 +371,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -92,7 +114,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch if (musicService.tracks.isEmpty()) -@@ -373,17 +380,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -373,17 +383,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM musicService.clearNotificationMetadata() callback.resolve(null) } @@ -114,7 +136,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.skip(index) -@@ -394,9 +403,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -394,9 +406,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -126,7 +148,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.skipToNext() -@@ -407,9 +417,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -407,9 +420,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -138,7 +160,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.skipToPrevious() -@@ -420,9 +431,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -420,9 +434,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -150,7 +172,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.stop() -@@ -431,135 +443,152 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -431,135 +446,152 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -321,7 +343,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch try { -@@ -570,49 +599,54 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -570,49 +602,54 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM rejectWithException(callback, exception) } } @@ -385,7 +407,7 @@ index b2409a0..fb5d5a5 100644 if (verifyServiceBoundOrReject(callback)) return@launch var bundle = Bundle() bundle.putDouble("duration", musicService.getDurationInSeconds()); -@@ -620,10 +654,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -620,10 +657,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds()); callback.resolve(Arguments.fromBundle(bundle)) } diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 9e4f343..80e94e4 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -27,6 +27,14 @@ import { useNormalizationSync } from '@/audio/useNormalizationSync'; import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler'; import { colors } from '@/theme'; +// Anchor the root stack at the tabs so a deep link straight to a top-level route (the +// widget/notification opening `now-playing`, or `recently-played`) builds `[(tabs), route]` +// instead of just `[route]`. Without this, dismissing the now-playing modal pops to an empty +// stack → blank screen. Only affects deep-link/launch ordering; normal nav is unchanged. +export const unstable_settings = { + initialRouteName: '(tabs)', +}; + SplashScreen.preventAutoHideAsync(); /** Mirrors RNTP state into the player store. Renders nothing. */ @@ -63,11 +71,23 @@ export default function RootLayout() { JetBrainsMono_500Medium, }); + // Failsafe so the splash can never hang the UI blank. `preventAutoHideAsync` runs at + // module scope — including in the headless JS context Android Auto spins up — so when + // the process is started from the car first and the app is opened later, the normal + // "hide once fonts load" path can get stuck. Render (and hide the splash) anyway after + // a short timeout even if fonts haven't reported in. + const [splashTimedOut, setSplashTimedOut] = useState(false); useEffect(() => { - if (fontsLoaded) { - void SplashScreen.hideAsync(); + const timer = setTimeout(() => setSplashTimedOut(true), 2000); + return () => clearTimeout(timer); + }, []); + const ready = fontsLoaded || splashTimedOut; + + useEffect(() => { + if (ready) { + void SplashScreen.hideAsync().catch(() => {}); } - }, [fontsLoaded]); + }, [ready]); // Eager library init: SQLite open + initial reads are tens of ms, and the // Library tab + playback adapters get data immediately. EQ + audio settings load @@ -99,7 +119,7 @@ export default function RootLayout() { .catch((err) => console.error('[lastfm] init failed', err)); }, []); - if (!fontsLoaded) return null; + if (!ready) return null; return ( diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index 4b07501..5d16504 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -265,7 +265,14 @@ export default function NowPlayingScreen() { // a second native modal animation after release. const translateY = useSharedValue(0); const menuProgress = useSharedValue(0); - const dismiss = () => router.back(); + // Belt-and-suspenders for deep-link entry (widget/notification → now-playing with no + // history): `(tabs)` is the stack anchor (see root _layout unstable_settings), so back() + // returns there; if somehow there's nothing to go back to, replace to the tabs home so + // dismissing can never land on a blank screen. + const dismiss = () => { + if (router.canGoBack()) router.back(); + else router.replace('/'); + }; const finishCloseMenu = () => setMenuOpen(false); function openMenu() { diff --git a/src/audio/applyNormalization.ts b/src/audio/applyNormalization.ts new file mode 100644 index 0000000..14b1f5f --- /dev/null +++ b/src/audio/applyNormalization.ts @@ -0,0 +1,62 @@ +// Headless-safe per-track normalization. `useNormalizationSync` (the richer version with +// upcoming-track prefetch + oscilloscope gain) is a React hook that only runs while the UI +// is mounted — so playback started from Android Auto / Bluetooth with the app closed never +// got normalized. This applies the current track's gain from the headless PlaybackService. + +import TrackPlayer from 'react-native-track-player'; +import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; +import { resolveNormalizationGain, type LoudnessFacts } from '@/audio/normalization'; +import { ensureTrackLoudness } from '@/audio/trackAnalysis'; +import { + activateTrackGainNative, + setNormalizationGainNative, + setTrackGainNative, +} from '@/audio/eqNative'; + +const EMPTY_FACTS: LoudnessFacts = { + loudnessLufs: null, + samplePeak: null, + replayGainTrackDb: null, + replayGainAlbumDb: null, + replayGainTrackPeak: null, + replayGainAlbumPeak: null, +}; + +/** + * Resolve + apply the active RNTP track's normalization gain natively. Idempotent and safe + * to call alongside `useNormalizationSync` (both compute the same gain). Remote tracks get + * unity (no local file / synced facts, and decoding would download the stream). + */ +export async function applyNormalizationForActiveTrack(): Promise { + const track = await TrackPlayer.getActiveTrack(); + const url = typeof track?.url === 'string' ? track.url : null; + if (!url) { + setNormalizationGainNative(1); + return; + } + + const sourceType = typeof track?.sourceType === 'string' ? track.sourceType : undefined; + if (sourceType && sourceType !== 'local') { + setNormalizationGainNative(1); + return; + } + + await useAudioSettingsStore.getState().load(); + const settings = useAudioSettingsStore.getState().asNormalizationSettings(); + + let facts = EMPTY_FACTS; + try { + facts = await ensureTrackLoudness(url); + // Track advanced while we were analyzing — let the newer change win. + const now = await TrackPlayer.getActiveTrack(); + if (typeof now?.url !== 'string' || now.url !== url) return; + } catch { + /* fall back to unity via EMPTY_FACTS */ + } + + const resolved = resolveNormalizationGain(facts, settings); + // Register by URL (the key the native player swaps on at the media transition) and + // activate it now, since no transition fires for the already-current track. + setTrackGainNative(url, resolved.linearGain); + activateTrackGainNative(url); +} diff --git a/src/audio/carSync.ts b/src/audio/carSync.ts new file mode 100644 index 0000000..38eafeb --- /dev/null +++ b/src/audio/carSync.ts @@ -0,0 +1,109 @@ +import TrackPlayer, { State, type Track as RntpTrack } from 'react-native-track-player'; + +import type { PlaybackState, Track } from '@/types/audio'; +import { AstraCar } from '../../modules/astra-car'; + +function mapRntpState(state?: State): PlaybackState { + switch (state) { + case State.Playing: + return 'playing'; + case State.Buffering: + case State.Loading: + return 'loading'; + case State.Paused: + case State.Ready: + return 'paused'; + default: + return 'stopped'; + } +} + +type CarNowPlayingTrack = Pick< + Track, + 'title' | 'artist' | 'album' | 'artworkData' | 'duration' | 'sourceType' | 'sourceId' | 'artworkSourceId' +>; + +/** True when the track should use its remote server cover (no local cache to serve). */ +function isRemoteArt(track: CarNowPlayingTrack | null): boolean { + return Boolean( + track && track.sourceType && track.sourceType !== 'local' && track.sourceId != null && track.artworkSourceId, + ); +} + +/** Local artwork is a `file://…/artwork/` URI — recover the cached file name. */ +function localHashFromArtwork(artworkData: string | null | undefined): string | null { + if (typeof artworkData !== 'string' || !artworkData.startsWith('file://')) return null; + const name = artworkData.split('/').pop(); + if (!name) return null; + try { + return decodeURIComponent(name); + } catch { + return name; + } +} + +export function setCarNowPlaying( + track: CarNowPlayingTrack | null, + playbackState: PlaybackState, + duration?: number | null, + position?: number | null, +): void { + // Android Auto loads art only from content:// URIs, so we pass structured identity + // (local hash or remote source+id) and let the native module build the content URI. + const remote = isRemoteArt(track); + AstraCar.setNowPlaying({ + title: track?.title ?? null, + artist: track?.artist ?? null, + album: track?.album ?? null, + artworkHash: remote ? null : localHashFromArtwork(track?.artworkData), + artworkSourceId: remote ? (track?.artworkSourceId ?? null) : null, + artworkSourceKey: remote ? (track?.sourceId ?? null) : null, + playbackState, + hasTrack: Boolean(track), + duration: duration ?? track?.duration ?? null, + position: position ?? null, + }); +} + +export function setCarNowPlayingFromRntpTrack( + track: RntpTrack | null | undefined, + playbackState: PlaybackState, + duration?: number | null, + position?: number | null, +): void { + setCarNowPlaying( + track + ? { + title: track.title ?? 'Unknown title', + artist: track.artist ?? 'Unknown artist', + album: track.album ?? '', + artworkData: typeof track.artwork === 'string' ? track.artwork : undefined, + duration: typeof track.duration === 'number' ? track.duration : 0, + sourceType: typeof track.sourceType === 'string' ? (track.sourceType as Track['sourceType']) : undefined, + sourceId: typeof track.sourceId === 'number' ? track.sourceId : undefined, + artworkSourceId: typeof track.artworkSourceId === 'string' ? track.artworkSourceId : undefined, + } + : null, + playbackState, + duration, + position, + ); +} + +export async function syncCarNowPlayingFromTrackPlayer(): Promise { + try { + const [activeTrack, playbackState, progress] = await Promise.all([ + TrackPlayer.getActiveTrack(), + TrackPlayer.getPlaybackState(), + TrackPlayer.getProgress(), + ]); + setCarNowPlayingFromRntpTrack( + activeTrack, + mapRntpState(playbackState.state), + progress.duration, + progress.position, + ); + } catch { + setCarNowPlaying(null, 'stopped'); + } +} diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 3894daa..8ec88b1 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -82,15 +82,28 @@ function shuffleArray(items: readonly T[]): T[] { * foreground. The stored repeat mode is re-applied after a (re)setup so a * deferred init keeps the user's choice. */ -async function ensurePlayerReady(): Promise { - await setupPlayer(); +async function ensurePlayerReady(options: { allowBackgroundSetup?: boolean } = {}): Promise { + await setupPlayer(options); await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat)); } /** Replace the queue with the given tracks and start playing at startIndex. */ export async function playTracks(tracks: Track[], startIndex = 0): Promise { + return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false }); +} + +/** Android Auto can request playback while the React UI is not foregrounded. */ +export async function playTracksForCar(tracks: Track[], startIndex = 0): Promise { + return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: true }); +} + +async function playTracksInternal( + tracks: Track[], + startIndex: number, + options: { allowBackgroundSetup: boolean }, +): Promise { if (tracks.length === 0) return; - await ensurePlayerReady(); + await ensurePlayerReady(options); const queueTracks = tracks.map(toRntpTrack); await TrackPlayer.setQueue(queueTracks); originalOrder = tracks.map((t) => t.id); @@ -141,6 +154,10 @@ export async function playSample(): Promise { } export const play = (): Promise => TrackPlayer.play(); +export async function playForCar(): Promise { + await ensurePlayerReady({ allowBackgroundSetup: true }); + await TrackPlayer.play(); +} export const pause = (): Promise => TrackPlayer.pause(); export const seekTo = (seconds: number): Promise => TrackPlayer.seekTo(seconds); diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index 47ae341..eab9612 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -1,5 +1,7 @@ import TrackPlayer, { Event } from 'react-native-track-player'; +import { syncCarNowPlayingFromTrackPlayer } from './carSync'; import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync'; +import { applyNormalizationForActiveTrack } from './applyNormalization'; /** * RNTP playback service — registered in `index.js`. Runs in a headless context @@ -7,32 +9,41 @@ import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync'; * controls to the player. Must not depend on React or the JS UI tree. */ export async function PlaybackService(): Promise { + const syncNowPlaying = () => + Promise.allSettled([ + syncWidgetNowPlayingFromTrackPlayer(), + syncCarNowPlayingFromTrackPlayer(), + ]); + TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, () => { - void syncWidgetNowPlayingFromTrackPlayer(); + void syncNowPlaying(); + // Apply normalization here too (not just in the UI hook) so playback started from + // Android Auto / Bluetooth with the app closed is still normalized. + void applyNormalizationForActiveTrack(); }); TrackPlayer.addEventListener(Event.PlaybackState, () => { - void syncWidgetNowPlayingFromTrackPlayer(); + void syncNowPlaying(); }); TrackPlayer.addEventListener(Event.RemotePlay, () => { - void TrackPlayer.play().finally(() => syncWidgetNowPlayingFromTrackPlayer()); + void TrackPlayer.play().finally(() => syncNowPlaying()); }); TrackPlayer.addEventListener(Event.RemotePause, () => { - void TrackPlayer.pause().finally(() => syncWidgetNowPlayingFromTrackPlayer()); + void TrackPlayer.pause().finally(() => syncNowPlaying()); }); TrackPlayer.addEventListener(Event.RemoteStop, () => { - void TrackPlayer.stop().finally(() => syncWidgetNowPlayingFromTrackPlayer()); + void TrackPlayer.stop().finally(() => syncNowPlaying()); }); TrackPlayer.addEventListener(Event.RemoteNext, () => { void TrackPlayer.skipToNext() .catch(() => {}) - .finally(() => syncWidgetNowPlayingFromTrackPlayer()); + .finally(() => syncNowPlaying()); }); TrackPlayer.addEventListener(Event.RemotePrevious, () => { void TrackPlayer.skipToPrevious() .catch(() => {}) - .finally(() => syncWidgetNowPlayingFromTrackPlayer()); + .finally(() => syncNowPlaying()); }); TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => - TrackPlayer.seekTo(position), + TrackPlayer.seekTo(position).finally(() => syncNowPlaying()), ); } diff --git a/src/audio/trackPlayer.ts b/src/audio/trackPlayer.ts index d149156..06d095b 100644 --- a/src/audio/trackPlayer.ts +++ b/src/audio/trackPlayer.ts @@ -11,9 +11,9 @@ import TrackPlayer, { */ let setupPromise: Promise | null = null; -export function setupPlayer(): Promise { +export function setupPlayer(options: { allowBackgroundSetup?: boolean } = {}): Promise { if (!setupPromise) { - setupPromise = doSetup().catch((err) => { + setupPromise = doSetup(options).catch((err) => { setupPromise = null; // allow a retry on a genuine failure throw err; }); @@ -21,9 +21,16 @@ export function setupPlayer(): Promise { return setupPromise; } -async function doSetup(): Promise { +async function doSetup(options: { allowBackgroundSetup?: boolean }): Promise { try { - await TrackPlayer.setupPlayer({ autoHandleInterruptions: true }); + await TrackPlayer.setupPlayer({ + autoHandleInterruptions: true, + ...(options.allowBackgroundSetup + ? { android: { allowBackgroundSetup: true } } + : {}), + } as Parameters[0] & { + android?: { allowBackgroundSetup?: boolean }; + }); } catch (err) { // setupPlayer rejects if the player was already initialized (e.g. across a // Fast Refresh). That case is safe to ignore; anything else should surface. diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index 4622b0d..1f35961 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -69,6 +69,13 @@ export function usePlaybackSync(): void { setPlaybackState(mappedPlaybackState); }, [mappedPlaybackState, setPlaybackState]); + // Push the widget now-playing (incl. the recents list) on track/state/recents change only + // — NOT on every 500ms progress tick. The widget shows no position, so per-tick updates + // were pure waste; with Android Auto connected the sibling car push fanned out to a full + // MediaSession setMetadata + host IPC at 2 Hz, whose spikes janked the Skia scopes + + // now-playing timeline. The car now-playing is owned by the headless PlaybackService, + // which re-syncs it (with a fresh position) on RNTP track/state events — so it isn't + // pushed from here at all (the MediaSession extrapolates position between those events). useEffect(() => { const track = activeTrack ? rntpToTrack(activeTrack) : null; setWidgetNowPlaying( diff --git a/src/car/carCommandService.ts b/src/car/carCommandService.ts new file mode 100644 index 0000000..d33c949 --- /dev/null +++ b/src/car/carCommandService.ts @@ -0,0 +1 @@ +export { handleAstraCarCommand } from './carPlayback'; diff --git a/src/car/carPlayback.ts b/src/car/carPlayback.ts new file mode 100644 index 0000000..8da1ef3 --- /dev/null +++ b/src/car/carPlayback.ts @@ -0,0 +1,315 @@ +import { + getAlbums, + getAllTracks, + getRecentlyPlayedTracks, + getTracksByAlbumKey, +} from '@/db/queries'; +import { + getFavoriteTracks, + getPlaylistEntries, + getPlaylists, + markPlaylistPlayed, +} from '@/db/playlistQueries'; +import { openLibraryDb, type LibraryDatabase } from '@/db/database'; +import { buildArtistList, filterTracksByArtist } from '@/library/artistGrouping'; +import { dbTrackToTrack } from '@/library/trackAdapter'; +import { playForCar, playTracksForCar, pause, seekTo, skipToNext, skipToPrevious } from '@/audio/playbackController'; +import { syncCarNowPlayingFromTrackPlayer } from '@/audio/carSync'; +import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; +import { useEQStore } from '@/stores/eqStore'; +import { useLibraryStore } from '@/stores/libraryStore'; +import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import type { DbTrack } from '@/types/library'; + +export interface CarMediaPayload { + kind?: string; + section?: string; + key?: string; + id?: number; + path?: string; + contextKind?: string; + contextSection?: string; + contextKey?: string; + contextId?: number; +} + +export interface CarCommandPayload { + command?: string; + media?: CarMediaPayload; + query?: string; + focus?: string; + title?: string; + artist?: string; + album?: string; + playlist?: string; + position?: number; +} + +let initPromise: Promise | null = null; + +async function initializeForCar(): Promise { + if (!initPromise) { + initPromise = (async () => { + await useSettingsStore.getState().load(); + await useLibraryStore.getState().initialize(); + await useRemoteSourcesStore.getState().init(); + await Promise.all([ + useEQStore.getState().load(), + useAudioSettingsStore.getState().load(), + ]); + })().catch((err) => { + initPromise = null; + throw err; + }); + } + return initPromise; +} + +export async function handleAstraCarCommand(payload: CarCommandPayload): Promise { + try { + await initializeForCar(); + + switch (payload.command) { + case 'playMediaId': + if (payload.media) await playMedia(payload.media); + break; + case 'playSearch': + await playSearch(payload); + break; + case 'play': + await playForCar(); + break; + case 'pause': + await pause(); + break; + case 'next': + await skipToNext(); + break; + case 'previous': + await skipToPrevious(); + break; + case 'seek': + if (typeof payload.position === 'number') await seekTo(payload.position); + break; + default: + break; + } + } catch (err) { + console.warn('[car] command failed', err); + } finally { + await syncCarNowPlayingFromTrackPlayer(); + } +} + +async function playMedia(media: CarMediaPayload): Promise { + const db = await openLibraryDb(); + const resolved = await resolveMediaTracks(db, media); + if (!resolved || resolved.tracks.length === 0) return; + await playTracksForCar(resolved.tracks.map(dbTrackToTrack), resolved.startIndex); + if (media.kind === 'playlist' && media.id != null) { + await markPlaylistPlayed(db, media.id); + } +} + +async function resolveMediaTracks( + db: LibraryDatabase, + media: CarMediaPayload, +): Promise<{ tracks: DbTrack[]; startIndex: number } | null> { + if (media.kind === 'track') { + const context = contextFromTrack(media); + const contextTracks = context ? await tracksForContext(db, context) : []; + const startIndex = contextTracks.findIndex((track) => track.path === media.path); + if (contextTracks.length > 0 && startIndex >= 0) { + return { tracks: contextTracks, startIndex }; + } + const track = media.path ? await getTrackByPath(db, media.path) : null; + return track ? { tracks: [track], startIndex: 0 } : null; + } + + const tracks = await tracksForContext(db, media); + return tracks.length > 0 ? { tracks, startIndex: 0 } : null; +} + +function contextFromTrack(media: CarMediaPayload): CarMediaPayload | null { + if (!media.contextKind) return null; + return { + kind: media.contextKind, + section: media.contextSection, + key: media.contextKey, + id: media.contextId, + }; +} + +async function tracksForContext(db: LibraryDatabase, media: CarMediaPayload): Promise { + switch (media.kind) { + case 'section': + if (media.section === 'recent') return getRecentlyPlayedTracks(db, 24); + if (media.section === 'favorites') return getFavoriteTracks(db); + return []; + case 'playlist': + if (media.id == null) return []; + return (await getPlaylistEntries(db, media.id)) + .map((entry) => entry.track) + .filter((track): track is DbTrack => Boolean(track)); + case 'album': + return media.key ? getTracksByAlbumKey(db, media.key) : []; + case 'artist': { + if (!media.key) return []; + const tracks = await getAllTracks(db); + return filterTracksByArtist( + tracks, + media.key, + useSettingsStore.getState().artistGroupingMode, + ); + } + default: + return []; + } +} + +async function getTrackByPath(db: LibraryDatabase, path: string): Promise { + return (await db.get('SELECT * FROM tracks WHERE path = ?', [path])) ?? null; +} + +async function playSearch(payload: CarCommandPayload): Promise { + const db = await openLibraryDb(); + const playlistTerm = cleanSearchTerm(payload.playlist) || focusedTerm(payload, 'playlist'); + if (playlistTerm) { + const playlist = bestMatch(await getPlaylists(db), playlistTerm, (entry) => [entry.name]); + if (playlist) return playMedia({ kind: 'playlist', id: playlist.id }); + } + + const albumTerm = cleanSearchTerm(payload.album) || focusedTerm(payload, 'album'); + if (albumTerm) { + const album = bestMatch(await getAlbums(db), albumTerm, (entry) => [entry.album, entry.artist]); + if (album) return playMedia({ kind: 'album', key: album.identity_key }); + } + + const artistTerm = cleanSearchTerm(payload.artist) || focusedTerm(payload, 'artist'); + if (artistTerm) { + const artistName = await bestArtistName(db, artistTerm); + if (artistName) return playMedia({ kind: 'artist', key: artistName }); + } + + const titleTerm = cleanSearchTerm(payload.title); + if (titleTerm) { + const track = bestMatch(await getAllTracks(db), titleTerm, (entry) => [entry.title]); + if (track) return playMedia({ kind: 'track', path: track.path }); + } + + const query = cleanSearchTerm(payload.query); + if (!query) { + await playForCar(); + return; + } + + const candidate = await bestGeneralSearchCandidate(db, query, payload.focus); + if (candidate) await playMedia(candidate); +} + +async function bestGeneralSearchCandidate( + db: LibraryDatabase, + query: string, + focus?: string, +): Promise { + const [tracks, albums, playlists] = await Promise.all([ + getAllTracks(db), + getAlbums(db), + getPlaylists(db), + ]); + const artistName = await bestArtistName(db, query); + + const candidates: { media: CarMediaPayload; score: number }[] = []; + const focused = cleanSearchTerm(focus); + + const track = bestMatchWithScore(tracks, query, (entry) => [entry.title, entry.artist, entry.album]); + if (track) candidates.push({ media: { kind: 'track', path: track.item.path }, score: track.score + categoryPenalty(focused, 'track') }); + + const album = bestMatchWithScore(albums, query, (entry) => [entry.album, entry.artist]); + if (album) candidates.push({ media: { kind: 'album', key: album.item.identity_key }, score: album.score + categoryPenalty(focused, 'album') }); + + const playlist = bestMatchWithScore(playlists, query, (entry) => [entry.name]); + if (playlist) candidates.push({ media: { kind: 'playlist', id: playlist.item.id }, score: playlist.score + categoryPenalty(focused, 'playlist') }); + + if (artistName) { + const score = scoreValue(artistName, query); + if (Number.isFinite(score)) { + candidates.push({ media: { kind: 'artist', key: artistName }, score: score + categoryPenalty(focused, 'artist') }); + } + } + + candidates.sort((a, b) => a.score - b.score); + return candidates[0]?.media ?? null; +} + +function categoryPenalty(focus: string | null, category: string): number { + if (!focus) { + if (category === 'track') return 0; + if (category === 'album') return 2; + if (category === 'artist') return 3; + return 4; + } + return focus === category ? -10 : 10; +} + +async function bestArtistName(db: LibraryDatabase, query: string): Promise { + const tracks = await getAllTracks(db); + const mode = useSettingsStore.getState().artistGroupingMode; + const artists = useLibraryStore.getState().artists.length + ? useLibraryStore.getState().artists + : buildArtistNamesFromTracks(tracks, mode); + return bestMatch(artists, query, (entry) => [entry.artist])?.artist ?? null; +} + +function buildArtistNamesFromTracks( + tracks: DbTrack[], + mode: ReturnType['artistGroupingMode'], +): { artist: string }[] { + return buildArtistList(tracks, mode).map((artist) => ({ artist: artist.artist })); +} + +function focusedTerm(payload: CarCommandPayload, focus: string): string | null { + return cleanSearchTerm(payload.focus) === focus ? cleanSearchTerm(payload.query) : null; +} + +function cleanSearchTerm(value: string | null | undefined): string | null { + const normalized = value?.replace(/\s+/g, ' ').trim(); + return normalized ? normalized : null; +} + +function bestMatch( + items: readonly T[], + query: string, + labels: (item: T) => readonly (string | null | undefined)[], +): T | null { + return bestMatchWithScore(items, query, labels)?.item ?? null; +} + +function bestMatchWithScore( + items: readonly T[], + query: string, + labels: (item: T) => readonly (string | null | undefined)[], +): { item: T; score: number } | null { + let best: { item: T; score: number } | null = null; + for (const item of items) { + const score = Math.min(...labels(item).map((label) => scoreValue(label, query))); + if (!Number.isFinite(score)) continue; + if (!best || score < best.score) best = { item, score }; + } + return best; +} + +function scoreValue(value: string | null | undefined, query: string): number { + const candidate = normalize(value); + const needle = normalize(query); + if (!candidate || !needle) return Number.POSITIVE_INFINITY; + if (candidate === needle) return 0; + if (candidate.startsWith(needle)) return 10; + if (candidate.includes(needle)) return 20; + return Number.POSITIVE_INFINITY; +} + +function normalize(value: string | null | undefined): string { + return value?.replace(/\s+/g, ' ').trim().toLocaleLowerCase() ?? ''; +} diff --git a/src/db/remoteSourceQueries.ts b/src/db/remoteSourceQueries.ts index d711924..1dfe32c 100644 --- a/src/db/remoteSourceQueries.ts +++ b/src/db/remoteSourceQueries.ts @@ -107,6 +107,22 @@ export async function setRemoteSourceSynced(db: LibraryDatabase, id: number): Pr ); } +/** + * Persist the cover-art URL template (with an `__ASTRA_ART_ID__` placeholder) that the + * native Android Auto artwork provider uses to fetch server art without JS/secret access. + */ +export async function setRemoteSourceArtAuth( + db: LibraryDatabase, + id: number, + artAuth: string | null +): Promise { + await db.run('UPDATE remote_sources SET art_auth = ?, updated_at = ? WHERE id = ?', [ + artAuth, + Date.now(), + id, + ]); +} + /** Cache Jellyfin auth (Subsonic derives a salted token per request, so it stays NULL). */ export async function setRemoteSourceAuth( db: LibraryDatabase, diff --git a/src/db/schema.ts b/src/db/schema.ts index fdf350d..985902b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,11 +14,13 @@ // sources (Subsonic/Jellyfin): a `remote_sources` table + remote-linkage columns on // `tracks`, and makes `folder_id` nullable (remote tracks have no SAF folder); v12 // marks playlists that mirror a server playlist (remote_source_id/remote_playlist_id) -// so remote playlist sync can upsert + reconcile them. +// so remote playlist sync can upsert + reconcile them; v13 adds `remote_sources.art_auth` +// — a self-contained cover-art URL template the native Android Auto artwork provider uses +// to fetch server art without a JS round-trip. import type { LibraryDatabase } from './database'; -export const SCHEMA_VERSION = 12; +export const SCHEMA_VERSION = 13; // One statement per entry — op-sqlite executes single statements. const MIGRATIONS: readonly (readonly string[])[] = [ @@ -252,6 +254,11 @@ const MIGRATIONS: readonly (readonly string[])[] = [ ON playlists(remote_source_id, remote_playlist_id) WHERE remote_source_id IS NOT NULL AND remote_playlist_id IS NOT NULL`, ], + // v12 -> v13 — cover-art URL template per remote source, read by the native Android + // Auto artwork provider (which has no JS/secret access) to fetch + cache server art. + // It embeds a fixed Subsonic salt+token / Jellyfin api_key with an `__ASTRA_ART_ID__` + // placeholder for the cover id; the password itself never leaves expo-secure-store. + [`ALTER TABLE remote_sources ADD COLUMN art_auth TEXT`], ]; export async function migrate(db: LibraryDatabase): Promise { diff --git a/src/services/remoteUrls.ts b/src/services/remoteUrls.ts index 11c1288..b9a17e3 100644 --- a/src/services/remoteUrls.ts +++ b/src/services/remoteUrls.ts @@ -33,6 +33,29 @@ export function streamUrlForTrack(track: Track): string | null { return null; } +/** Placeholder the native Android Auto artwork provider substitutes with the cover id. */ +const ART_ID_PLACEHOLDER = '__ASTRA_ART_ID__'; + +/** + * Build a self-contained cover-art URL with an `__ASTRA_ART_ID__` placeholder in place of + * the cover id, for the given remote source. Persisted to `remote_sources.art_auth` so the + * native Android Auto artwork provider (no JS/secret access) can url-encode a real id into + * it and download. Returns null when the source isn't loaded/authenticated yet. + */ +export function buildCoverArtUrlTemplate(sourceId: number): string | null { + const cfg = getResolvedRemoteConfig(sourceId); + if (!cfg) return null; + + if (cfg.type === 'subsonic') { + return buildSubsonicCoverArtUrl(connection(cfg), ART_ID_PLACEHOLDER); + } + if (cfg.type === 'jellyfin') { + if (!cfg.accessToken) return null; + return buildJellyfinCoverArtUrl(connection(cfg), ART_ID_PLACEHOLDER, cfg.accessToken); + } + return null; +} + /** Build the cover-art URL for a remote track, or null if unavailable. */ export function artworkUrlForTrack( track: Pick diff --git a/src/stores/remoteSourcesStore.ts b/src/stores/remoteSourcesStore.ts index 852b095..491adf2 100644 --- a/src/stores/remoteSourcesStore.ts +++ b/src/stores/remoteSourcesStore.ts @@ -17,11 +17,13 @@ import { getRemoteSource, getRemoteSources, insertRemoteSource, + setRemoteSourceArtAuth, setRemoteSourceAuth, setRemoteSourceStatus, setRemoteSourceSynced, updateRemoteSource, } from '@/db/remoteSourceQueries'; +import { buildCoverArtUrlTemplate } from '@/services/remoteUrls'; import { deleteRemoteSecret, getRemoteSecret, @@ -68,9 +70,24 @@ async function hydrateRegistry(source: RemoteSourceRow): Promise { + if (source.art_auth) return; + const template = buildCoverArtUrlTemplate(source.id); + if (!template) return; + const db = await openLibraryDb(); + await setRemoteSourceArtAuth(db, source.id, template); +} + /** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */ async function ensureJellyfinAuth( source: RemoteSourceRow, @@ -87,6 +104,9 @@ async function ensureJellyfinAuth( deviceId: buildJellyfinDeviceId(config), }); updateResolvedRemoteAuth(source.id, auth); + // Token (re)issued — refresh the native Auto cover-art template so it isn't stale. + const artTemplate = buildCoverArtUrlTemplate(source.id); + if (artTemplate) await setRemoteSourceArtAuth(db, source.id, artTemplate); return auth; } @@ -190,6 +210,7 @@ export const useRemoteSourcesStore = create((set, get) => ({ accessToken: auth?.accessToken, userId: auth?.userId, }); + await persistArtAuthIfNeeded(row); await get().refresh(); // Kick off the first sync in the background (don't block the add flow). @@ -214,9 +235,11 @@ export const useRemoteSourcesStore = create((set, get) => ({ const updated = await getRemoteSource(db, id); if (updated) { - // Connection details may have changed → drop cached token, re-hydrate registry. + // Connection details may have changed → drop cached token + cover-art template, + // re-hydrate registry (which regenerates the template from the new credentials). if (input.baseUrl || input.username || input.password) { await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null }); + await setRemoteSourceArtAuth(db, id, null); } const fresh = (await getRemoteSource(db, id)) ?? updated; await hydrateRegistry(fresh); diff --git a/src/types/remote.ts b/src/types/remote.ts index 98d7777..9244638 100644 --- a/src/types/remote.ts +++ b/src/types/remote.ts @@ -90,6 +90,11 @@ export interface RemoteSourceRow { access_token: string | null; user_id: string | null; device_id: string | null; + /** + * A self-contained cover-art URL template with an `__ASTRA_ART_ID__` id placeholder, + * read by the native Android Auto artwork provider to fetch server art without JS. + */ + art_auth: string | null; created_at: number; updated_at: number; }