android auto support

This commit is contained in:
Boof2015
2026-06-29 14:07:58 -04:00
parent 3558ea30e4
commit de7ad39d62
31 changed files with 2259 additions and 39 deletions
+3
View File
@@ -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);
+23
View File
@@ -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"
}
@@ -0,0 +1,30 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<service
android:name="expo.modules.astracar.AstraCarMediaService"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<service
android:name="expo.modules.astracar.AstraCarCommandService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<provider
android:name="expo.modules.astracar.AstraCarArtworkProvider"
android:authorities="${applicationId}.astracar.artwork"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>
@@ -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/<hash>` (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))
}
@@ -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://<authority>/local/<hash> -> cached scanner file
* content://<authority>/remote/<sourceId>/<artworkSourceId> -> 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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
@@ -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<MediaItem> {
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<MediaItem> =
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<MediaItem> =
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<MediaItem>, options: Bundle?): List<MediaItem> {
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<String> = emptyArray(),
): List<TrackRow> =
db.rawQuery(sql, args).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.toTrackRow())
}
}
private fun getRecentlyPlayed(db: SQLiteDatabase): List<TrackRow> =
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<TrackRow> =
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<TrackRow> =
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<TrackRow> =
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<TrackRow> =
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<TrackRow> {
val mode = getArtistGroupingMode(db)
return getAllTracks(db).filter { trackMatchesBrowseArtist(it, normalizeKey(artist), mode) }
}
private fun getAlbums(db: SQLiteDatabase): List<AlbumRow> =
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<PlaylistRow> =
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<ArtistRow> =
buildArtistList(getAllTracks(db), getArtistGroupingMode(db))
private fun buildArtistList(tracks: List<TrackRow>, mode: String): List<ArtistRow> {
val byKey = linkedMapOf<String, ArtistAggregate>()
for (track in tracks) {
val names =
if (mode == "fileTags") listOf(resolveStrictBrowseArtist(track))
else getCanonicalArtistIndexNames(track)
val seen = mutableSetOf<String>()
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<String> {
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<String> {
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<String>): List<String> {
val unique = linkedMapOf<String, String>()
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<String> {
val unique = linkedMapOf<String, String>()
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))
@@ -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
}
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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<MutableList<MediaItem>>) {
loadChildren(parentId, null, result)
}
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaItem>>,
options: Bundle,
) {
loadChildren(parentId, options, result)
}
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
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<MutableList<MediaItem>>,
) {
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()
}
}
@@ -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()
}
@@ -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<AstraCarMediaService>? = 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"
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.astracar.AstraCarModule"]
}
}
+29
View File
@@ -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<AstraCarModuleType>('AstraCar');
export const AstraCar = (native ?? {
setNowPlaying: () => {},
}) as AstraCarModuleType;
+37 -15
View File
@@ -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))
}
+25 -5
View File
@@ -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 (
<GestureHandlerRootView style={styles.root}>
+8 -1
View File
@@ -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() {
+62
View File
@@ -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<void> {
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);
}
+109
View File
@@ -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/<hash>` 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<void> {
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');
}
}
+20 -3
View File
@@ -82,15 +82,28 @@ function shuffleArray<T>(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<void> {
await setupPlayer();
async function ensurePlayerReady(options: { allowBackgroundSetup?: boolean } = {}): Promise<void> {
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<void> {
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<void> {
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: true });
}
async function playTracksInternal(
tracks: Track[],
startIndex: number,
options: { allowBackgroundSetup: boolean },
): Promise<void> {
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<void> {
}
export const play = (): Promise<void> => TrackPlayer.play();
export async function playForCar(): Promise<void> {
await ensurePlayerReady({ allowBackgroundSetup: true });
await TrackPlayer.play();
}
export const pause = (): Promise<void> => TrackPlayer.pause();
export const seekTo = (seconds: number): Promise<void> => TrackPlayer.seekTo(seconds);
+19 -8
View File
@@ -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<void> {
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()),
);
}
+11 -4
View File
@@ -11,9 +11,9 @@ import TrackPlayer, {
*/
let setupPromise: Promise<void> | null = null;
export function setupPlayer(): Promise<void> {
export function setupPlayer(options: { allowBackgroundSetup?: boolean } = {}): Promise<void> {
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<void> {
return setupPromise;
}
async function doSetup(): Promise<void> {
async function doSetup(options: { allowBackgroundSetup?: boolean }): Promise<void> {
try {
await TrackPlayer.setupPlayer({ autoHandleInterruptions: true });
await TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
...(options.allowBackgroundSetup
? { android: { allowBackgroundSetup: true } }
: {}),
} as Parameters<typeof TrackPlayer.setupPlayer>[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.
+7
View File
@@ -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(
+1
View File
@@ -0,0 +1 @@
export { handleAstraCarCommand } from './carPlayback';
+315
View File
@@ -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<void> | null = null;
async function initializeForCar(): Promise<void> {
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<void> {
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<void> {
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<DbTrack[]> {
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<DbTrack | null> {
return (await db.get<DbTrack>('SELECT * FROM tracks WHERE path = ?', [path])) ?? null;
}
async function playSearch(payload: CarCommandPayload): Promise<void> {
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<CarMediaPayload | null> {
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<string | null> {
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<typeof useSettingsStore.getState>['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<T>(
items: readonly T[],
query: string,
labels: (item: T) => readonly (string | null | undefined)[],
): T | null {
return bestMatchWithScore(items, query, labels)?.item ?? null;
}
function bestMatchWithScore<T>(
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() ?? '';
}
+16
View File
@@ -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<void> {
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,
+9 -2
View File
@@ -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<void> {
+23
View File
@@ -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<Track, 'sourceType' | 'sourceId' | 'artworkSourceId'>
+24 -1
View File
@@ -17,11 +17,13 @@ import {
getRemoteSource,
getRemoteSources,
insertRemoteSource,
setRemoteSourceArtAuth,
setRemoteSourceAuth,
setRemoteSourceStatus,
setRemoteSourceSynced,
updateRemoteSource,
} from '@/db/remoteSourceQueries';
import { buildCoverArtUrlTemplate } from '@/services/remoteUrls';
import {
deleteRemoteSecret,
getRemoteSecret,
@@ -68,9 +70,24 @@ async function hydrateRegistry(source: RemoteSourceRow): Promise<RemoteConnectio
accessToken: source.access_token ?? undefined,
userId: source.user_id ?? undefined,
});
await persistArtAuthIfNeeded(source);
return { baseUrl: source.base_url, username: source.username, password };
}
/**
* Generate + persist the cover-art URL template the native Android Auto artwork provider
* reads. Done once per source (stable Subsonic salt; Jellyfin token); regenerated when
* credentials change (updateSource clears it) or a Jellyfin token is refreshed.
* Requires the source's config to already be in the registry.
*/
async function persistArtAuthIfNeeded(source: RemoteSourceRow): Promise<void> {
if (source.art_auth) return;
const template = buildCoverArtUrlTemplate(source.id);
if (!template) return;
const db = await openLibraryDb();
await setRemoteSourceArtAuth(db, source.id, template);
}
/** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */
async function ensureJellyfinAuth(
source: RemoteSourceRow,
@@ -87,6 +104,9 @@ async function ensureJellyfinAuth(
deviceId: buildJellyfinDeviceId(config),
});
updateResolvedRemoteAuth(source.id, auth);
// Token (re)issued — refresh the native Auto cover-art template so it isn't stale.
const artTemplate = buildCoverArtUrlTemplate(source.id);
if (artTemplate) await setRemoteSourceArtAuth(db, source.id, artTemplate);
return auth;
}
@@ -190,6 +210,7 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
accessToken: auth?.accessToken,
userId: auth?.userId,
});
await persistArtAuthIfNeeded(row);
await get().refresh();
// Kick off the first sync in the background (don't block the add flow).
@@ -214,9 +235,11 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
const updated = await getRemoteSource(db, id);
if (updated) {
// Connection details may have changed → drop cached token, re-hydrate registry.
// Connection details may have changed → drop cached token + cover-art template,
// re-hydrate registry (which regenerates the template from the new credentials).
if (input.baseUrl || input.username || input.password) {
await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null });
await setRemoteSourceArtAuth(db, id, null);
}
const fresh = (await getRemoteSource(db, id)) ?? updated;
await hydrateRegistry(fresh);
+5
View File
@@ -90,6 +90,11 @@ export interface RemoteSourceRow {
access_token: string | null;
user_id: string | null;
device_id: string | null;
/**
* A self-contained cover-art URL template with an `__ASTRA_ART_ID__` id placeholder,
* read by the native Android Auto artwork provider to fetch server art without JS.
*/
art_auth: string | null;
created_at: number;
updated_at: number;
}