mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
android auto support
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+134
@@ -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))
|
||||
+158
@@ -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)
|
||||
}
|
||||
}
|
||||
+135
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { handleAstraCarCommand } from './carPlayback';
|
||||
@@ -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() ?? '';
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user