From de7ad39d62802c98e0c9e34eaba65ec9d5dbc2a8 Mon Sep 17 00:00:00 2001
From: Boof2015 <75185879+Boof2015@users.noreply.github.com>
Date: Mon, 29 Jun 2026 14:07:58 -0400
Subject: [PATCH] android auto support
---
index.js | 3 +
modules/astra-car/android/build.gradle | 23 +
.../android/src/main/AndroidManifest.xml | 30 +
.../expo/modules/astracar/AstraCarArtwork.kt | 52 ++
.../astracar/AstraCarArtworkProvider.kt | 134 ++++
.../expo/modules/astracar/AstraCarCatalog.kt | 604 ++++++++++++++++++
.../astracar/AstraCarCommandService.kt | 158 +++++
.../java/expo/modules/astracar/AstraCarDb.kt | 30 +
.../expo/modules/astracar/AstraCarMediaId.kt | 125 ++++
.../modules/astracar/AstraCarMediaService.kt | 135 ++++
.../expo/modules/astracar/AstraCarModule.kt | 78 +++
.../modules/astracar/AstraCarNowPlaying.kt | 157 +++++
.../src/main/res/xml/automotive_app_desc.xml | 4 +
modules/astra-car/expo-module.config.json | 6 +
modules/astra-car/index.ts | 29 +
patches/react-native-track-player+4.1.2.patch | 52 +-
src/app/_layout.tsx | 30 +-
src/app/now-playing.tsx | 9 +-
src/audio/applyNormalization.ts | 62 ++
src/audio/carSync.ts | 109 ++++
src/audio/playbackController.ts | 23 +-
src/audio/playbackService.ts | 27 +-
src/audio/trackPlayer.ts | 15 +-
src/audio/usePlaybackSync.ts | 7 +
src/car/carCommandService.ts | 1 +
src/car/carPlayback.ts | 315 +++++++++
src/db/remoteSourceQueries.ts | 16 +
src/db/schema.ts | 11 +-
src/services/remoteUrls.ts | 23 +
src/stores/remoteSourcesStore.ts | 25 +-
src/types/remote.ts | 5 +
31 files changed, 2259 insertions(+), 39 deletions(-)
create mode 100644 modules/astra-car/android/build.gradle
create mode 100644 modules/astra-car/android/src/main/AndroidManifest.xml
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtwork.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarDb.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaId.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt
create mode 100644 modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt
create mode 100644 modules/astra-car/android/src/main/res/xml/automotive_app_desc.xml
create mode 100644 modules/astra-car/expo-module.config.json
create mode 100644 modules/astra-car/index.ts
create mode 100644 src/audio/applyNormalization.ts
create mode 100644 src/audio/carSync.ts
create mode 100644 src/car/carCommandService.ts
create mode 100644 src/car/carPlayback.ts
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