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
|
// MediaSession / lock-screen / Bluetooth remote controls work even when the
|
||||||
// JS UI isn't mounted (headless).
|
// JS UI isn't mounted (headless).
|
||||||
import 'expo-router/entry';
|
import 'expo-router/entry';
|
||||||
|
import { AppRegistry } from 'react-native';
|
||||||
import TrackPlayer from 'react-native-track-player';
|
import TrackPlayer from 'react-native-track-player';
|
||||||
|
import { handleAstraCarCommand } from './src/car/carCommandService';
|
||||||
import { PlaybackService } from './src/audio/playbackService';
|
import { PlaybackService } from './src/audio/playbackService';
|
||||||
|
|
||||||
TrackPlayer.registerPlaybackService(() => 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
|
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
|
--- 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
|
+++ 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
|
@ReactMethod
|
||||||
@@ -11,7 +33,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
val options = Arguments.toBundle(data)
|
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)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -23,7 +45,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -283,9 +284,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -283,9 +287,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
rejectWithException(callback, exception)
|
rejectWithException(callback, exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +57,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
callback.resolve(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")
|
callback.reject("invalid_track_object", "Track was not a dictionary type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +78,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
val inputIndexes = Arguments.toList(data)
|
val inputIndexes = Arguments.toList(data)
|
||||||
if (inputIndexes != null) {
|
if (inputIndexes != null) {
|
||||||
@@ -329,9 +333,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -329,9 +336,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
}
|
}
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -68,7 +90,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
if (verifyServiceBoundOrReject(callback)) return@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)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +102,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
if (musicService.tracks.isEmpty())
|
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)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -92,7 +114,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
if (musicService.tracks.isEmpty())
|
if (musicService.tracks.isEmpty())
|
||||||
@@ -373,17 +380,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -373,17 +383,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
musicService.clearNotificationMetadata()
|
musicService.clearNotificationMetadata()
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -114,7 +136,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
musicService.skip(index)
|
musicService.skip(index)
|
||||||
@@ -394,9 +403,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -394,9 +406,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
|
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -126,7 +148,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
musicService.skipToNext()
|
musicService.skipToNext()
|
||||||
@@ -407,9 +417,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -407,9 +420,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
|
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -138,7 +160,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
musicService.skipToPrevious()
|
musicService.skipToPrevious()
|
||||||
@@ -420,9 +431,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -420,9 +434,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
|
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -150,7 +172,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
musicService.stop()
|
musicService.stop()
|
||||||
@@ -431,135 +443,152 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -431,135 +446,152 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
|
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -321,7 +343,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -570,49 +599,54 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -570,49 +602,54 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
rejectWithException(callback, exception)
|
rejectWithException(callback, exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,7 +407,7 @@ index b2409a0..fb5d5a5 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
var bundle = Bundle()
|
var bundle = Bundle()
|
||||||
bundle.putDouble("duration", musicService.getDurationInSeconds());
|
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());
|
bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds());
|
||||||
callback.resolve(Arguments.fromBundle(bundle))
|
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 { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
@@ -27,6 +27,14 @@ import { useNormalizationSync } from '@/audio/useNormalizationSync';
|
|||||||
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
|
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
|
||||||
import { colors } from '@/theme';
|
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();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
/** Mirrors RNTP state into the player store. Renders nothing. */
|
/** Mirrors RNTP state into the player store. Renders nothing. */
|
||||||
@@ -63,11 +71,23 @@ export default function RootLayout() {
|
|||||||
JetBrainsMono_500Medium,
|
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(() => {
|
useEffect(() => {
|
||||||
if (fontsLoaded) {
|
const timer = setTimeout(() => setSplashTimedOut(true), 2000);
|
||||||
void SplashScreen.hideAsync();
|
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
|
// Eager library init: SQLite open + initial reads are tens of ms, and the
|
||||||
// Library tab + playback adapters get data immediately. EQ + audio settings load
|
// 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));
|
.catch((err) => console.error('[lastfm] init failed', err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!fontsLoaded) return null;
|
if (!ready) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={styles.root}>
|
<GestureHandlerRootView style={styles.root}>
|
||||||
|
|||||||
@@ -265,7 +265,14 @@ export default function NowPlayingScreen() {
|
|||||||
// a second native modal animation after release.
|
// a second native modal animation after release.
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const menuProgress = 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);
|
const finishCloseMenu = () => setMenuOpen(false);
|
||||||
|
|
||||||
function openMenu() {
|
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
|
* foreground. The stored repeat mode is re-applied after a (re)setup so a
|
||||||
* deferred init keeps the user's choice.
|
* deferred init keeps the user's choice.
|
||||||
*/
|
*/
|
||||||
async function ensurePlayerReady(): Promise<void> {
|
async function ensurePlayerReady(options: { allowBackgroundSetup?: boolean } = {}): Promise<void> {
|
||||||
await setupPlayer();
|
await setupPlayer(options);
|
||||||
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
|
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the queue with the given tracks and start playing at startIndex. */
|
/** Replace the queue with the given tracks and start playing at startIndex. */
|
||||||
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
|
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;
|
if (tracks.length === 0) return;
|
||||||
await ensurePlayerReady();
|
await ensurePlayerReady(options);
|
||||||
const queueTracks = tracks.map(toRntpTrack);
|
const queueTracks = tracks.map(toRntpTrack);
|
||||||
await TrackPlayer.setQueue(queueTracks);
|
await TrackPlayer.setQueue(queueTracks);
|
||||||
originalOrder = tracks.map((t) => t.id);
|
originalOrder = tracks.map((t) => t.id);
|
||||||
@@ -141,6 +154,10 @@ export async function playSample(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const play = (): Promise<void> => TrackPlayer.play();
|
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 pause = (): Promise<void> => TrackPlayer.pause();
|
||||||
export const seekTo = (seconds: number): Promise<void> => TrackPlayer.seekTo(seconds);
|
export const seekTo = (seconds: number): Promise<void> => TrackPlayer.seekTo(seconds);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import TrackPlayer, { Event } from 'react-native-track-player';
|
import TrackPlayer, { Event } from 'react-native-track-player';
|
||||||
|
import { syncCarNowPlayingFromTrackPlayer } from './carSync';
|
||||||
import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
|
import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
|
||||||
|
import { applyNormalizationForActiveTrack } from './applyNormalization';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RNTP playback service — registered in `index.js`. Runs in a headless context
|
* 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.
|
* controls to the player. Must not depend on React or the JS UI tree.
|
||||||
*/
|
*/
|
||||||
export async function PlaybackService(): Promise<void> {
|
export async function PlaybackService(): Promise<void> {
|
||||||
|
const syncNowPlaying = () =>
|
||||||
|
Promise.allSettled([
|
||||||
|
syncWidgetNowPlayingFromTrackPlayer(),
|
||||||
|
syncCarNowPlayingFromTrackPlayer(),
|
||||||
|
]);
|
||||||
|
|
||||||
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, () => {
|
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, () => {
|
TrackPlayer.addEventListener(Event.PlaybackState, () => {
|
||||||
void syncWidgetNowPlayingFromTrackPlayer();
|
void syncNowPlaying();
|
||||||
});
|
});
|
||||||
TrackPlayer.addEventListener(Event.RemotePlay, () => {
|
TrackPlayer.addEventListener(Event.RemotePlay, () => {
|
||||||
void TrackPlayer.play().finally(() => syncWidgetNowPlayingFromTrackPlayer());
|
void TrackPlayer.play().finally(() => syncNowPlaying());
|
||||||
});
|
});
|
||||||
TrackPlayer.addEventListener(Event.RemotePause, () => {
|
TrackPlayer.addEventListener(Event.RemotePause, () => {
|
||||||
void TrackPlayer.pause().finally(() => syncWidgetNowPlayingFromTrackPlayer());
|
void TrackPlayer.pause().finally(() => syncNowPlaying());
|
||||||
});
|
});
|
||||||
TrackPlayer.addEventListener(Event.RemoteStop, () => {
|
TrackPlayer.addEventListener(Event.RemoteStop, () => {
|
||||||
void TrackPlayer.stop().finally(() => syncWidgetNowPlayingFromTrackPlayer());
|
void TrackPlayer.stop().finally(() => syncNowPlaying());
|
||||||
});
|
});
|
||||||
TrackPlayer.addEventListener(Event.RemoteNext, () => {
|
TrackPlayer.addEventListener(Event.RemoteNext, () => {
|
||||||
void TrackPlayer.skipToNext()
|
void TrackPlayer.skipToNext()
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => syncWidgetNowPlayingFromTrackPlayer());
|
.finally(() => syncNowPlaying());
|
||||||
});
|
});
|
||||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => {
|
TrackPlayer.addEventListener(Event.RemotePrevious, () => {
|
||||||
void TrackPlayer.skipToPrevious()
|
void TrackPlayer.skipToPrevious()
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => syncWidgetNowPlayingFromTrackPlayer());
|
.finally(() => syncNowPlaying());
|
||||||
});
|
});
|
||||||
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
|
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;
|
let setupPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export function setupPlayer(): Promise<void> {
|
export function setupPlayer(options: { allowBackgroundSetup?: boolean } = {}): Promise<void> {
|
||||||
if (!setupPromise) {
|
if (!setupPromise) {
|
||||||
setupPromise = doSetup().catch((err) => {
|
setupPromise = doSetup(options).catch((err) => {
|
||||||
setupPromise = null; // allow a retry on a genuine failure
|
setupPromise = null; // allow a retry on a genuine failure
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
@@ -21,9 +21,16 @@ export function setupPlayer(): Promise<void> {
|
|||||||
return setupPromise;
|
return setupPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSetup(): Promise<void> {
|
async function doSetup(options: { allowBackgroundSetup?: boolean }): Promise<void> {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
// setupPlayer rejects if the player was already initialized (e.g. across a
|
// setupPlayer rejects if the player was already initialized (e.g. across a
|
||||||
// Fast Refresh). That case is safe to ignore; anything else should surface.
|
// Fast Refresh). That case is safe to ignore; anything else should surface.
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export function usePlaybackSync(): void {
|
|||||||
setPlaybackState(mappedPlaybackState);
|
setPlaybackState(mappedPlaybackState);
|
||||||
}, [mappedPlaybackState, setPlaybackState]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const track = activeTrack ? rntpToTrack(activeTrack) : null;
|
const track = activeTrack ? rntpToTrack(activeTrack) : null;
|
||||||
setWidgetNowPlaying(
|
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). */
|
/** Cache Jellyfin auth (Subsonic derives a salted token per request, so it stays NULL). */
|
||||||
export async function setRemoteSourceAuth(
|
export async function setRemoteSourceAuth(
|
||||||
db: LibraryDatabase,
|
db: LibraryDatabase,
|
||||||
|
|||||||
+9
-2
@@ -14,11 +14,13 @@
|
|||||||
// sources (Subsonic/Jellyfin): a `remote_sources` table + remote-linkage columns on
|
// sources (Subsonic/Jellyfin): a `remote_sources` table + remote-linkage columns on
|
||||||
// `tracks`, and makes `folder_id` nullable (remote tracks have no SAF folder); v12
|
// `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)
|
// 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';
|
import type { LibraryDatabase } from './database';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 12;
|
export const SCHEMA_VERSION = 13;
|
||||||
|
|
||||||
// One statement per entry — op-sqlite executes single statements.
|
// One statement per entry — op-sqlite executes single statements.
|
||||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
const MIGRATIONS: readonly (readonly string[])[] = [
|
||||||
@@ -252,6 +254,11 @@ const MIGRATIONS: readonly (readonly string[])[] = [
|
|||||||
ON playlists(remote_source_id, remote_playlist_id)
|
ON playlists(remote_source_id, remote_playlist_id)
|
||||||
WHERE remote_source_id IS NOT NULL AND remote_playlist_id IS NOT NULL`,
|
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> {
|
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||||
|
|||||||
@@ -33,6 +33,29 @@ export function streamUrlForTrack(track: Track): string | null {
|
|||||||
return 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. */
|
/** Build the cover-art URL for a remote track, or null if unavailable. */
|
||||||
export function artworkUrlForTrack(
|
export function artworkUrlForTrack(
|
||||||
track: Pick<Track, 'sourceType' | 'sourceId' | 'artworkSourceId'>
|
track: Pick<Track, 'sourceType' | 'sourceId' | 'artworkSourceId'>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
getRemoteSource,
|
getRemoteSource,
|
||||||
getRemoteSources,
|
getRemoteSources,
|
||||||
insertRemoteSource,
|
insertRemoteSource,
|
||||||
|
setRemoteSourceArtAuth,
|
||||||
setRemoteSourceAuth,
|
setRemoteSourceAuth,
|
||||||
setRemoteSourceStatus,
|
setRemoteSourceStatus,
|
||||||
setRemoteSourceSynced,
|
setRemoteSourceSynced,
|
||||||
updateRemoteSource,
|
updateRemoteSource,
|
||||||
} from '@/db/remoteSourceQueries';
|
} from '@/db/remoteSourceQueries';
|
||||||
|
import { buildCoverArtUrlTemplate } from '@/services/remoteUrls';
|
||||||
import {
|
import {
|
||||||
deleteRemoteSecret,
|
deleteRemoteSecret,
|
||||||
getRemoteSecret,
|
getRemoteSecret,
|
||||||
@@ -68,9 +70,24 @@ async function hydrateRegistry(source: RemoteSourceRow): Promise<RemoteConnectio
|
|||||||
accessToken: source.access_token ?? undefined,
|
accessToken: source.access_token ?? undefined,
|
||||||
userId: source.user_id ?? undefined,
|
userId: source.user_id ?? undefined,
|
||||||
});
|
});
|
||||||
|
await persistArtAuthIfNeeded(source);
|
||||||
return { baseUrl: source.base_url, username: source.username, password };
|
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. */
|
/** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */
|
||||||
async function ensureJellyfinAuth(
|
async function ensureJellyfinAuth(
|
||||||
source: RemoteSourceRow,
|
source: RemoteSourceRow,
|
||||||
@@ -87,6 +104,9 @@ async function ensureJellyfinAuth(
|
|||||||
deviceId: buildJellyfinDeviceId(config),
|
deviceId: buildJellyfinDeviceId(config),
|
||||||
});
|
});
|
||||||
updateResolvedRemoteAuth(source.id, auth);
|
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;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +210,7 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
|
|||||||
accessToken: auth?.accessToken,
|
accessToken: auth?.accessToken,
|
||||||
userId: auth?.userId,
|
userId: auth?.userId,
|
||||||
});
|
});
|
||||||
|
await persistArtAuthIfNeeded(row);
|
||||||
|
|
||||||
await get().refresh();
|
await get().refresh();
|
||||||
// Kick off the first sync in the background (don't block the add flow).
|
// 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);
|
const updated = await getRemoteSource(db, id);
|
||||||
if (updated) {
|
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) {
|
if (input.baseUrl || input.username || input.password) {
|
||||||
await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null });
|
await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null });
|
||||||
|
await setRemoteSourceArtAuth(db, id, null);
|
||||||
}
|
}
|
||||||
const fresh = (await getRemoteSource(db, id)) ?? updated;
|
const fresh = (await getRemoteSource(db, id)) ?? updated;
|
||||||
await hydrateRegistry(fresh);
|
await hydrateRegistry(fresh);
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export interface RemoteSourceRow {
|
|||||||
access_token: string | null;
|
access_token: string | null;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
device_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;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user