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