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
+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() ?? '';
}