android auto support

This commit is contained in:
Boof2015
2026-06-29 14:07:58 -04:00
parent 3558ea30e4
commit de7ad39d62
31 changed files with 2259 additions and 39 deletions
+25 -5
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -27,6 +27,14 @@ import { useNormalizationSync } from '@/audio/useNormalizationSync';
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
import { colors } from '@/theme';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
// widget/notification opening `now-playing`, or `recently-played`) builds `[(tabs), route]`
// instead of just `[route]`. Without this, dismissing the now-playing modal pops to an empty
// stack → blank screen. Only affects deep-link/launch ordering; normal nav is unchanged.
export const unstable_settings = {
initialRouteName: '(tabs)',
};
SplashScreen.preventAutoHideAsync();
/** Mirrors RNTP state into the player store. Renders nothing. */
@@ -63,11 +71,23 @@ export default function RootLayout() {
JetBrainsMono_500Medium,
});
// Failsafe so the splash can never hang the UI blank. `preventAutoHideAsync` runs at
// module scope — including in the headless JS context Android Auto spins up — so when
// the process is started from the car first and the app is opened later, the normal
// "hide once fonts load" path can get stuck. Render (and hide the splash) anyway after
// a short timeout even if fonts haven't reported in.
const [splashTimedOut, setSplashTimedOut] = useState(false);
useEffect(() => {
if (fontsLoaded) {
void SplashScreen.hideAsync();
const timer = setTimeout(() => setSplashTimedOut(true), 2000);
return () => clearTimeout(timer);
}, []);
const ready = fontsLoaded || splashTimedOut;
useEffect(() => {
if (ready) {
void SplashScreen.hideAsync().catch(() => {});
}
}, [fontsLoaded]);
}, [ready]);
// Eager library init: SQLite open + initial reads are tens of ms, and the
// Library tab + playback adapters get data immediately. EQ + audio settings load
@@ -99,7 +119,7 @@ export default function RootLayout() {
.catch((err) => console.error('[lastfm] init failed', err));
}, []);
if (!fontsLoaded) return null;
if (!ready) return null;
return (
<GestureHandlerRootView style={styles.root}>
+8 -1
View File
@@ -265,7 +265,14 @@ export default function NowPlayingScreen() {
// a second native modal animation after release.
const translateY = useSharedValue(0);
const menuProgress = useSharedValue(0);
const dismiss = () => router.back();
// Belt-and-suspenders for deep-link entry (widget/notification → now-playing with no
// history): `(tabs)` is the stack anchor (see root _layout unstable_settings), so back()
// returns there; if somehow there's nothing to go back to, replace to the tabs home so
// dismissing can never land on a blank screen.
const dismiss = () => {
if (router.canGoBack()) router.back();
else router.replace('/');
};
const finishCloseMenu = () => setMenuOpen(false);
function openMenu() {
+62
View File
@@ -0,0 +1,62 @@
// Headless-safe per-track normalization. `useNormalizationSync` (the richer version with
// upcoming-track prefetch + oscilloscope gain) is a React hook that only runs while the UI
// is mounted — so playback started from Android Auto / Bluetooth with the app closed never
// got normalized. This applies the current track's gain from the headless PlaybackService.
import TrackPlayer from 'react-native-track-player';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { resolveNormalizationGain, type LoudnessFacts } from '@/audio/normalization';
import { ensureTrackLoudness } from '@/audio/trackAnalysis';
import {
activateTrackGainNative,
setNormalizationGainNative,
setTrackGainNative,
} from '@/audio/eqNative';
const EMPTY_FACTS: LoudnessFacts = {
loudnessLufs: null,
samplePeak: null,
replayGainTrackDb: null,
replayGainAlbumDb: null,
replayGainTrackPeak: null,
replayGainAlbumPeak: null,
};
/**
* Resolve + apply the active RNTP track's normalization gain natively. Idempotent and safe
* to call alongside `useNormalizationSync` (both compute the same gain). Remote tracks get
* unity (no local file / synced facts, and decoding would download the stream).
*/
export async function applyNormalizationForActiveTrack(): Promise<void> {
const track = await TrackPlayer.getActiveTrack();
const url = typeof track?.url === 'string' ? track.url : null;
if (!url) {
setNormalizationGainNative(1);
return;
}
const sourceType = typeof track?.sourceType === 'string' ? track.sourceType : undefined;
if (sourceType && sourceType !== 'local') {
setNormalizationGainNative(1);
return;
}
await useAudioSettingsStore.getState().load();
const settings = useAudioSettingsStore.getState().asNormalizationSettings();
let facts = EMPTY_FACTS;
try {
facts = await ensureTrackLoudness(url);
// Track advanced while we were analyzing — let the newer change win.
const now = await TrackPlayer.getActiveTrack();
if (typeof now?.url !== 'string' || now.url !== url) return;
} catch {
/* fall back to unity via EMPTY_FACTS */
}
const resolved = resolveNormalizationGain(facts, settings);
// Register by URL (the key the native player swaps on at the media transition) and
// activate it now, since no transition fires for the already-current track.
setTrackGainNative(url, resolved.linearGain);
activateTrackGainNative(url);
}
+109
View File
@@ -0,0 +1,109 @@
import TrackPlayer, { State, type Track as RntpTrack } from 'react-native-track-player';
import type { PlaybackState, Track } from '@/types/audio';
import { AstraCar } from '../../modules/astra-car';
function mapRntpState(state?: State): PlaybackState {
switch (state) {
case State.Playing:
return 'playing';
case State.Buffering:
case State.Loading:
return 'loading';
case State.Paused:
case State.Ready:
return 'paused';
default:
return 'stopped';
}
}
type CarNowPlayingTrack = Pick<
Track,
'title' | 'artist' | 'album' | 'artworkData' | 'duration' | 'sourceType' | 'sourceId' | 'artworkSourceId'
>;
/** True when the track should use its remote server cover (no local cache to serve). */
function isRemoteArt(track: CarNowPlayingTrack | null): boolean {
return Boolean(
track && track.sourceType && track.sourceType !== 'local' && track.sourceId != null && track.artworkSourceId,
);
}
/** Local artwork is a `file://…/artwork/<hash>` URI — recover the cached file name. */
function localHashFromArtwork(artworkData: string | null | undefined): string | null {
if (typeof artworkData !== 'string' || !artworkData.startsWith('file://')) return null;
const name = artworkData.split('/').pop();
if (!name) return null;
try {
return decodeURIComponent(name);
} catch {
return name;
}
}
export function setCarNowPlaying(
track: CarNowPlayingTrack | null,
playbackState: PlaybackState,
duration?: number | null,
position?: number | null,
): void {
// Android Auto loads art only from content:// URIs, so we pass structured identity
// (local hash or remote source+id) and let the native module build the content URI.
const remote = isRemoteArt(track);
AstraCar.setNowPlaying({
title: track?.title ?? null,
artist: track?.artist ?? null,
album: track?.album ?? null,
artworkHash: remote ? null : localHashFromArtwork(track?.artworkData),
artworkSourceId: remote ? (track?.artworkSourceId ?? null) : null,
artworkSourceKey: remote ? (track?.sourceId ?? null) : null,
playbackState,
hasTrack: Boolean(track),
duration: duration ?? track?.duration ?? null,
position: position ?? null,
});
}
export function setCarNowPlayingFromRntpTrack(
track: RntpTrack | null | undefined,
playbackState: PlaybackState,
duration?: number | null,
position?: number | null,
): void {
setCarNowPlaying(
track
? {
title: track.title ?? 'Unknown title',
artist: track.artist ?? 'Unknown artist',
album: track.album ?? '',
artworkData: typeof track.artwork === 'string' ? track.artwork : undefined,
duration: typeof track.duration === 'number' ? track.duration : 0,
sourceType: typeof track.sourceType === 'string' ? (track.sourceType as Track['sourceType']) : undefined,
sourceId: typeof track.sourceId === 'number' ? track.sourceId : undefined,
artworkSourceId: typeof track.artworkSourceId === 'string' ? track.artworkSourceId : undefined,
}
: null,
playbackState,
duration,
position,
);
}
export async function syncCarNowPlayingFromTrackPlayer(): Promise<void> {
try {
const [activeTrack, playbackState, progress] = await Promise.all([
TrackPlayer.getActiveTrack(),
TrackPlayer.getPlaybackState(),
TrackPlayer.getProgress(),
]);
setCarNowPlayingFromRntpTrack(
activeTrack,
mapRntpState(playbackState.state),
progress.duration,
progress.position,
);
} catch {
setCarNowPlaying(null, 'stopped');
}
}
+20 -3
View File
@@ -82,15 +82,28 @@ function shuffleArray<T>(items: readonly T[]): T[] {
* foreground. The stored repeat mode is re-applied after a (re)setup so a
* deferred init keeps the user's choice.
*/
async function ensurePlayerReady(): Promise<void> {
await setupPlayer();
async function ensurePlayerReady(options: { allowBackgroundSetup?: boolean } = {}): Promise<void> {
await setupPlayer(options);
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
}
/** Replace the queue with the given tracks and start playing at startIndex. */
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false });
}
/** Android Auto can request playback while the React UI is not foregrounded. */
export async function playTracksForCar(tracks: Track[], startIndex = 0): Promise<void> {
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: true });
}
async function playTracksInternal(
tracks: Track[],
startIndex: number,
options: { allowBackgroundSetup: boolean },
): Promise<void> {
if (tracks.length === 0) return;
await ensurePlayerReady();
await ensurePlayerReady(options);
const queueTracks = tracks.map(toRntpTrack);
await TrackPlayer.setQueue(queueTracks);
originalOrder = tracks.map((t) => t.id);
@@ -141,6 +154,10 @@ export async function playSample(): Promise<void> {
}
export const play = (): Promise<void> => TrackPlayer.play();
export async function playForCar(): Promise<void> {
await ensurePlayerReady({ allowBackgroundSetup: true });
await TrackPlayer.play();
}
export const pause = (): Promise<void> => TrackPlayer.pause();
export const seekTo = (seconds: number): Promise<void> => TrackPlayer.seekTo(seconds);
+19 -8
View File
@@ -1,5 +1,7 @@
import TrackPlayer, { Event } from 'react-native-track-player';
import { syncCarNowPlayingFromTrackPlayer } from './carSync';
import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
import { applyNormalizationForActiveTrack } from './applyNormalization';
/**
* RNTP playback service — registered in `index.js`. Runs in a headless context
@@ -7,32 +9,41 @@ import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync';
* controls to the player. Must not depend on React or the JS UI tree.
*/
export async function PlaybackService(): Promise<void> {
const syncNowPlaying = () =>
Promise.allSettled([
syncWidgetNowPlayingFromTrackPlayer(),
syncCarNowPlayingFromTrackPlayer(),
]);
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, () => {
void syncWidgetNowPlayingFromTrackPlayer();
void syncNowPlaying();
// Apply normalization here too (not just in the UI hook) so playback started from
// Android Auto / Bluetooth with the app closed is still normalized.
void applyNormalizationForActiveTrack();
});
TrackPlayer.addEventListener(Event.PlaybackState, () => {
void syncWidgetNowPlayingFromTrackPlayer();
void syncNowPlaying();
});
TrackPlayer.addEventListener(Event.RemotePlay, () => {
void TrackPlayer.play().finally(() => syncWidgetNowPlayingFromTrackPlayer());
void TrackPlayer.play().finally(() => syncNowPlaying());
});
TrackPlayer.addEventListener(Event.RemotePause, () => {
void TrackPlayer.pause().finally(() => syncWidgetNowPlayingFromTrackPlayer());
void TrackPlayer.pause().finally(() => syncNowPlaying());
});
TrackPlayer.addEventListener(Event.RemoteStop, () => {
void TrackPlayer.stop().finally(() => syncWidgetNowPlayingFromTrackPlayer());
void TrackPlayer.stop().finally(() => syncNowPlaying());
});
TrackPlayer.addEventListener(Event.RemoteNext, () => {
void TrackPlayer.skipToNext()
.catch(() => {})
.finally(() => syncWidgetNowPlayingFromTrackPlayer());
.finally(() => syncNowPlaying());
});
TrackPlayer.addEventListener(Event.RemotePrevious, () => {
void TrackPlayer.skipToPrevious()
.catch(() => {})
.finally(() => syncWidgetNowPlayingFromTrackPlayer());
.finally(() => syncNowPlaying());
});
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
TrackPlayer.seekTo(position),
TrackPlayer.seekTo(position).finally(() => syncNowPlaying()),
);
}
+11 -4
View File
@@ -11,9 +11,9 @@ import TrackPlayer, {
*/
let setupPromise: Promise<void> | null = null;
export function setupPlayer(): Promise<void> {
export function setupPlayer(options: { allowBackgroundSetup?: boolean } = {}): Promise<void> {
if (!setupPromise) {
setupPromise = doSetup().catch((err) => {
setupPromise = doSetup(options).catch((err) => {
setupPromise = null; // allow a retry on a genuine failure
throw err;
});
@@ -21,9 +21,16 @@ export function setupPlayer(): Promise<void> {
return setupPromise;
}
async function doSetup(): Promise<void> {
async function doSetup(options: { allowBackgroundSetup?: boolean }): Promise<void> {
try {
await TrackPlayer.setupPlayer({ autoHandleInterruptions: true });
await TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
...(options.allowBackgroundSetup
? { android: { allowBackgroundSetup: true } }
: {}),
} as Parameters<typeof TrackPlayer.setupPlayer>[0] & {
android?: { allowBackgroundSetup?: boolean };
});
} catch (err) {
// setupPlayer rejects if the player was already initialized (e.g. across a
// Fast Refresh). That case is safe to ignore; anything else should surface.
+7
View File
@@ -69,6 +69,13 @@ export function usePlaybackSync(): void {
setPlaybackState(mappedPlaybackState);
}, [mappedPlaybackState, setPlaybackState]);
// Push the widget now-playing (incl. the recents list) on track/state/recents change only
// — NOT on every 500ms progress tick. The widget shows no position, so per-tick updates
// were pure waste; with Android Auto connected the sibling car push fanned out to a full
// MediaSession setMetadata + host IPC at 2 Hz, whose spikes janked the Skia scopes +
// now-playing timeline. The car now-playing is owned by the headless PlaybackService,
// which re-syncs it (with a fresh position) on RNTP track/state events — so it isn't
// pushed from here at all (the MediaSession extrapolates position between those events).
useEffect(() => {
const track = activeTrack ? rntpToTrack(activeTrack) : null;
setWidgetNowPlaying(
+1
View File
@@ -0,0 +1 @@
export { handleAstraCarCommand } from './carPlayback';
+315
View File
@@ -0,0 +1,315 @@
import {
getAlbums,
getAllTracks,
getRecentlyPlayedTracks,
getTracksByAlbumKey,
} from '@/db/queries';
import {
getFavoriteTracks,
getPlaylistEntries,
getPlaylists,
markPlaylistPlayed,
} from '@/db/playlistQueries';
import { openLibraryDb, type LibraryDatabase } from '@/db/database';
import { buildArtistList, filterTracksByArtist } from '@/library/artistGrouping';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { playForCar, playTracksForCar, pause, seekTo, skipToNext, skipToPrevious } from '@/audio/playbackController';
import { syncCarNowPlayingFromTrackPlayer } from '@/audio/carSync';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useEQStore } from '@/stores/eqStore';
import { useLibraryStore } from '@/stores/libraryStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
export interface CarMediaPayload {
kind?: string;
section?: string;
key?: string;
id?: number;
path?: string;
contextKind?: string;
contextSection?: string;
contextKey?: string;
contextId?: number;
}
export interface CarCommandPayload {
command?: string;
media?: CarMediaPayload;
query?: string;
focus?: string;
title?: string;
artist?: string;
album?: string;
playlist?: string;
position?: number;
}
let initPromise: Promise<void> | null = null;
async function initializeForCar(): Promise<void> {
if (!initPromise) {
initPromise = (async () => {
await useSettingsStore.getState().load();
await useLibraryStore.getState().initialize();
await useRemoteSourcesStore.getState().init();
await Promise.all([
useEQStore.getState().load(),
useAudioSettingsStore.getState().load(),
]);
})().catch((err) => {
initPromise = null;
throw err;
});
}
return initPromise;
}
export async function handleAstraCarCommand(payload: CarCommandPayload): Promise<void> {
try {
await initializeForCar();
switch (payload.command) {
case 'playMediaId':
if (payload.media) await playMedia(payload.media);
break;
case 'playSearch':
await playSearch(payload);
break;
case 'play':
await playForCar();
break;
case 'pause':
await pause();
break;
case 'next':
await skipToNext();
break;
case 'previous':
await skipToPrevious();
break;
case 'seek':
if (typeof payload.position === 'number') await seekTo(payload.position);
break;
default:
break;
}
} catch (err) {
console.warn('[car] command failed', err);
} finally {
await syncCarNowPlayingFromTrackPlayer();
}
}
async function playMedia(media: CarMediaPayload): Promise<void> {
const db = await openLibraryDb();
const resolved = await resolveMediaTracks(db, media);
if (!resolved || resolved.tracks.length === 0) return;
await playTracksForCar(resolved.tracks.map(dbTrackToTrack), resolved.startIndex);
if (media.kind === 'playlist' && media.id != null) {
await markPlaylistPlayed(db, media.id);
}
}
async function resolveMediaTracks(
db: LibraryDatabase,
media: CarMediaPayload,
): Promise<{ tracks: DbTrack[]; startIndex: number } | null> {
if (media.kind === 'track') {
const context = contextFromTrack(media);
const contextTracks = context ? await tracksForContext(db, context) : [];
const startIndex = contextTracks.findIndex((track) => track.path === media.path);
if (contextTracks.length > 0 && startIndex >= 0) {
return { tracks: contextTracks, startIndex };
}
const track = media.path ? await getTrackByPath(db, media.path) : null;
return track ? { tracks: [track], startIndex: 0 } : null;
}
const tracks = await tracksForContext(db, media);
return tracks.length > 0 ? { tracks, startIndex: 0 } : null;
}
function contextFromTrack(media: CarMediaPayload): CarMediaPayload | null {
if (!media.contextKind) return null;
return {
kind: media.contextKind,
section: media.contextSection,
key: media.contextKey,
id: media.contextId,
};
}
async function tracksForContext(db: LibraryDatabase, media: CarMediaPayload): Promise<DbTrack[]> {
switch (media.kind) {
case 'section':
if (media.section === 'recent') return getRecentlyPlayedTracks(db, 24);
if (media.section === 'favorites') return getFavoriteTracks(db);
return [];
case 'playlist':
if (media.id == null) return [];
return (await getPlaylistEntries(db, media.id))
.map((entry) => entry.track)
.filter((track): track is DbTrack => Boolean(track));
case 'album':
return media.key ? getTracksByAlbumKey(db, media.key) : [];
case 'artist': {
if (!media.key) return [];
const tracks = await getAllTracks(db);
return filterTracksByArtist(
tracks,
media.key,
useSettingsStore.getState().artistGroupingMode,
);
}
default:
return [];
}
}
async function getTrackByPath(db: LibraryDatabase, path: string): Promise<DbTrack | null> {
return (await db.get<DbTrack>('SELECT * FROM tracks WHERE path = ?', [path])) ?? null;
}
async function playSearch(payload: CarCommandPayload): Promise<void> {
const db = await openLibraryDb();
const playlistTerm = cleanSearchTerm(payload.playlist) || focusedTerm(payload, 'playlist');
if (playlistTerm) {
const playlist = bestMatch(await getPlaylists(db), playlistTerm, (entry) => [entry.name]);
if (playlist) return playMedia({ kind: 'playlist', id: playlist.id });
}
const albumTerm = cleanSearchTerm(payload.album) || focusedTerm(payload, 'album');
if (albumTerm) {
const album = bestMatch(await getAlbums(db), albumTerm, (entry) => [entry.album, entry.artist]);
if (album) return playMedia({ kind: 'album', key: album.identity_key });
}
const artistTerm = cleanSearchTerm(payload.artist) || focusedTerm(payload, 'artist');
if (artistTerm) {
const artistName = await bestArtistName(db, artistTerm);
if (artistName) return playMedia({ kind: 'artist', key: artistName });
}
const titleTerm = cleanSearchTerm(payload.title);
if (titleTerm) {
const track = bestMatch(await getAllTracks(db), titleTerm, (entry) => [entry.title]);
if (track) return playMedia({ kind: 'track', path: track.path });
}
const query = cleanSearchTerm(payload.query);
if (!query) {
await playForCar();
return;
}
const candidate = await bestGeneralSearchCandidate(db, query, payload.focus);
if (candidate) await playMedia(candidate);
}
async function bestGeneralSearchCandidate(
db: LibraryDatabase,
query: string,
focus?: string,
): Promise<CarMediaPayload | null> {
const [tracks, albums, playlists] = await Promise.all([
getAllTracks(db),
getAlbums(db),
getPlaylists(db),
]);
const artistName = await bestArtistName(db, query);
const candidates: { media: CarMediaPayload; score: number }[] = [];
const focused = cleanSearchTerm(focus);
const track = bestMatchWithScore(tracks, query, (entry) => [entry.title, entry.artist, entry.album]);
if (track) candidates.push({ media: { kind: 'track', path: track.item.path }, score: track.score + categoryPenalty(focused, 'track') });
const album = bestMatchWithScore(albums, query, (entry) => [entry.album, entry.artist]);
if (album) candidates.push({ media: { kind: 'album', key: album.item.identity_key }, score: album.score + categoryPenalty(focused, 'album') });
const playlist = bestMatchWithScore(playlists, query, (entry) => [entry.name]);
if (playlist) candidates.push({ media: { kind: 'playlist', id: playlist.item.id }, score: playlist.score + categoryPenalty(focused, 'playlist') });
if (artistName) {
const score = scoreValue(artistName, query);
if (Number.isFinite(score)) {
candidates.push({ media: { kind: 'artist', key: artistName }, score: score + categoryPenalty(focused, 'artist') });
}
}
candidates.sort((a, b) => a.score - b.score);
return candidates[0]?.media ?? null;
}
function categoryPenalty(focus: string | null, category: string): number {
if (!focus) {
if (category === 'track') return 0;
if (category === 'album') return 2;
if (category === 'artist') return 3;
return 4;
}
return focus === category ? -10 : 10;
}
async function bestArtistName(db: LibraryDatabase, query: string): Promise<string | null> {
const tracks = await getAllTracks(db);
const mode = useSettingsStore.getState().artistGroupingMode;
const artists = useLibraryStore.getState().artists.length
? useLibraryStore.getState().artists
: buildArtistNamesFromTracks(tracks, mode);
return bestMatch(artists, query, (entry) => [entry.artist])?.artist ?? null;
}
function buildArtistNamesFromTracks(
tracks: DbTrack[],
mode: ReturnType<typeof useSettingsStore.getState>['artistGroupingMode'],
): { artist: string }[] {
return buildArtistList(tracks, mode).map((artist) => ({ artist: artist.artist }));
}
function focusedTerm(payload: CarCommandPayload, focus: string): string | null {
return cleanSearchTerm(payload.focus) === focus ? cleanSearchTerm(payload.query) : null;
}
function cleanSearchTerm(value: string | null | undefined): string | null {
const normalized = value?.replace(/\s+/g, ' ').trim();
return normalized ? normalized : null;
}
function bestMatch<T>(
items: readonly T[],
query: string,
labels: (item: T) => readonly (string | null | undefined)[],
): T | null {
return bestMatchWithScore(items, query, labels)?.item ?? null;
}
function bestMatchWithScore<T>(
items: readonly T[],
query: string,
labels: (item: T) => readonly (string | null | undefined)[],
): { item: T; score: number } | null {
let best: { item: T; score: number } | null = null;
for (const item of items) {
const score = Math.min(...labels(item).map((label) => scoreValue(label, query)));
if (!Number.isFinite(score)) continue;
if (!best || score < best.score) best = { item, score };
}
return best;
}
function scoreValue(value: string | null | undefined, query: string): number {
const candidate = normalize(value);
const needle = normalize(query);
if (!candidate || !needle) return Number.POSITIVE_INFINITY;
if (candidate === needle) return 0;
if (candidate.startsWith(needle)) return 10;
if (candidate.includes(needle)) return 20;
return Number.POSITIVE_INFINITY;
}
function normalize(value: string | null | undefined): string {
return value?.replace(/\s+/g, ' ').trim().toLocaleLowerCase() ?? '';
}
+16
View File
@@ -107,6 +107,22 @@ export async function setRemoteSourceSynced(db: LibraryDatabase, id: number): Pr
);
}
/**
* Persist the cover-art URL template (with an `__ASTRA_ART_ID__` placeholder) that the
* native Android Auto artwork provider uses to fetch server art without JS/secret access.
*/
export async function setRemoteSourceArtAuth(
db: LibraryDatabase,
id: number,
artAuth: string | null
): Promise<void> {
await db.run('UPDATE remote_sources SET art_auth = ?, updated_at = ? WHERE id = ?', [
artAuth,
Date.now(),
id,
]);
}
/** Cache Jellyfin auth (Subsonic derives a salted token per request, so it stays NULL). */
export async function setRemoteSourceAuth(
db: LibraryDatabase,
+9 -2
View File
@@ -14,11 +14,13 @@
// sources (Subsonic/Jellyfin): a `remote_sources` table + remote-linkage columns on
// `tracks`, and makes `folder_id` nullable (remote tracks have no SAF folder); v12
// marks playlists that mirror a server playlist (remote_source_id/remote_playlist_id)
// so remote playlist sync can upsert + reconcile them.
// so remote playlist sync can upsert + reconcile them; v13 adds `remote_sources.art_auth`
// — a self-contained cover-art URL template the native Android Auto artwork provider uses
// to fetch server art without a JS round-trip.
import type { LibraryDatabase } from './database';
export const SCHEMA_VERSION = 12;
export const SCHEMA_VERSION = 13;
// One statement per entry — op-sqlite executes single statements.
const MIGRATIONS: readonly (readonly string[])[] = [
@@ -252,6 +254,11 @@ const MIGRATIONS: readonly (readonly string[])[] = [
ON playlists(remote_source_id, remote_playlist_id)
WHERE remote_source_id IS NOT NULL AND remote_playlist_id IS NOT NULL`,
],
// v12 -> v13 — cover-art URL template per remote source, read by the native Android
// Auto artwork provider (which has no JS/secret access) to fetch + cache server art.
// It embeds a fixed Subsonic salt+token / Jellyfin api_key with an `__ASTRA_ART_ID__`
// placeholder for the cover id; the password itself never leaves expo-secure-store.
[`ALTER TABLE remote_sources ADD COLUMN art_auth TEXT`],
];
export async function migrate(db: LibraryDatabase): Promise<void> {
+23
View File
@@ -33,6 +33,29 @@ export function streamUrlForTrack(track: Track): string | null {
return null;
}
/** Placeholder the native Android Auto artwork provider substitutes with the cover id. */
const ART_ID_PLACEHOLDER = '__ASTRA_ART_ID__';
/**
* Build a self-contained cover-art URL with an `__ASTRA_ART_ID__` placeholder in place of
* the cover id, for the given remote source. Persisted to `remote_sources.art_auth` so the
* native Android Auto artwork provider (no JS/secret access) can url-encode a real id into
* it and download. Returns null when the source isn't loaded/authenticated yet.
*/
export function buildCoverArtUrlTemplate(sourceId: number): string | null {
const cfg = getResolvedRemoteConfig(sourceId);
if (!cfg) return null;
if (cfg.type === 'subsonic') {
return buildSubsonicCoverArtUrl(connection(cfg), ART_ID_PLACEHOLDER);
}
if (cfg.type === 'jellyfin') {
if (!cfg.accessToken) return null;
return buildJellyfinCoverArtUrl(connection(cfg), ART_ID_PLACEHOLDER, cfg.accessToken);
}
return null;
}
/** Build the cover-art URL for a remote track, or null if unavailable. */
export function artworkUrlForTrack(
track: Pick<Track, 'sourceType' | 'sourceId' | 'artworkSourceId'>
+24 -1
View File
@@ -17,11 +17,13 @@ import {
getRemoteSource,
getRemoteSources,
insertRemoteSource,
setRemoteSourceArtAuth,
setRemoteSourceAuth,
setRemoteSourceStatus,
setRemoteSourceSynced,
updateRemoteSource,
} from '@/db/remoteSourceQueries';
import { buildCoverArtUrlTemplate } from '@/services/remoteUrls';
import {
deleteRemoteSecret,
getRemoteSecret,
@@ -68,9 +70,24 @@ async function hydrateRegistry(source: RemoteSourceRow): Promise<RemoteConnectio
accessToken: source.access_token ?? undefined,
userId: source.user_id ?? undefined,
});
await persistArtAuthIfNeeded(source);
return { baseUrl: source.base_url, username: source.username, password };
}
/**
* Generate + persist the cover-art URL template the native Android Auto artwork provider
* reads. Done once per source (stable Subsonic salt; Jellyfin token); regenerated when
* credentials change (updateSource clears it) or a Jellyfin token is refreshed.
* Requires the source's config to already be in the registry.
*/
async function persistArtAuthIfNeeded(source: RemoteSourceRow): Promise<void> {
if (source.art_auth) return;
const template = buildCoverArtUrlTemplate(source.id);
if (!template) return;
const db = await openLibraryDb();
await setRemoteSourceArtAuth(db, source.id, template);
}
/** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */
async function ensureJellyfinAuth(
source: RemoteSourceRow,
@@ -87,6 +104,9 @@ async function ensureJellyfinAuth(
deviceId: buildJellyfinDeviceId(config),
});
updateResolvedRemoteAuth(source.id, auth);
// Token (re)issued — refresh the native Auto cover-art template so it isn't stale.
const artTemplate = buildCoverArtUrlTemplate(source.id);
if (artTemplate) await setRemoteSourceArtAuth(db, source.id, artTemplate);
return auth;
}
@@ -190,6 +210,7 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
accessToken: auth?.accessToken,
userId: auth?.userId,
});
await persistArtAuthIfNeeded(row);
await get().refresh();
// Kick off the first sync in the background (don't block the add flow).
@@ -214,9 +235,11 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
const updated = await getRemoteSource(db, id);
if (updated) {
// Connection details may have changed → drop cached token, re-hydrate registry.
// Connection details may have changed → drop cached token + cover-art template,
// re-hydrate registry (which regenerates the template from the new credentials).
if (input.baseUrl || input.username || input.password) {
await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null });
await setRemoteSourceArtAuth(db, id, null);
}
const fresh = (await getRemoteSource(db, id)) ?? updated;
await hydrateRegistry(fresh);
+5
View File
@@ -90,6 +90,11 @@ export interface RemoteSourceRow {
access_token: string | null;
user_id: string | null;
device_id: string | null;
/**
* A self-contained cover-art URL template with an `__ASTRA_ART_ID__` id placeholder,
* read by the native Android Auto artwork provider to fetch server art without JS.
*/
art_auth: string | null;
created_at: number;
updated_at: number;
}