persistence

This commit is contained in:
Boof2015
2026-07-13 15:09:59 -04:00
parent 971dea8d53
commit 4e98bd5b8e
13 changed files with 1200 additions and 30 deletions
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState } from 'react';
import { Linking } from 'react-native';
import {
useGlobalSearchParams,
usePathname,
useRootNavigationState,
useRouter,
useSegments,
} from 'expo-router';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
import { useSearchStore } from '@/stores/searchStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { buildArtistDetail } from '@/library/artistDetail';
import { dbTrackToTrack } from '@/library/trackAdapter';
import {
hasActiveNativePlaybackSession,
restorePlaybackSession,
} from '@/audio/playbackController';
import {
installMobileSessionPersistence,
readPersistedMobileSession,
rememberStableHref,
setInitialStableHref,
} from './sessionPersistence';
import {
normalizeStableHref,
resolvePlaybackSession,
shouldRestoreSavedRoute,
stableHrefForRoute,
validateRestoredHref,
} from './sessionState';
interface SessionLifecycleProps {
onReady: () => void;
}
function validateSavedHref(href: string): string {
const tracks = useLibraryStore.getState().tracks;
return validateRestoredHref(href, {
hasAlbum: (identityKey) => tracks.some((track) => track.album_identity_key === identityKey),
hasArtist: (name, credit) => {
const groupingMode = credit ? 'astra' : useSettingsStore.getState().artistGroupingMode;
return buildArtistDetail(tracks, name, groupingMode).tracks.length > 0;
},
hasPlaylist: (id) => usePlaylistStore.getState().playlists.some((playlist) => playlist.id === id),
});
}
/** Restores once, then owns stable-route tracking and session autosave. */
export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
const router = useRouter();
const pathname = usePathname();
const segments = useSegments();
const params = useGlobalSearchParams<{
key?: string | string[];
name?: string | string[];
id?: string | string[];
credit?: string | string[];
}>();
const rootNavigationState = useRootNavigationState();
const navigationKey = rootNavigationState?.key;
const initialPathname = useRef(pathname);
const started = useRef(false);
const uninstallPersistence = useRef<(() => void) | null>(null);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (!navigationKey || started.current) return;
started.current = true;
let cancelled = false;
void (async () => {
const snapshotRead = readPersistedMobileSession();
let snapshot: Awaited<typeof snapshotRead> = null;
try {
const [loadedSnapshot, initialUrl] = await Promise.all([
snapshotRead,
Linking.getInitialURL(),
(async () => {
await useLibraryStore.getState().initialize();
try {
await useRemoteSourcesStore.getState().init();
} catch (error) {
// Local queue/session recovery should still work when a remote
// source cannot hydrate during startup.
console.warn('[session] remote source hydration failed', error);
}
})(),
]);
snapshot = loadedSnapshot;
if (cancelled) return;
// Every relaunch begins at rest even when a React activity was rebuilt
// inside a still-live JS process.
usePlayerUiStore.setState({ playerOpen: false, everOpened: false });
useSearchStore.getState().closeQuickSearch();
const liveNativeSession = await hasActiveNativePlaybackSession();
if (!cancelled && snapshot?.playback && !liveNativeSession) {
const resolved = resolvePlaybackSession(
snapshot.playback,
useLibraryStore.getState().tracks
);
restorePlaybackSession(
resolved
? { ...resolved, tracks: resolved.tracks.map(dbTrackToTrack) }
: null
);
}
if (cancelled) return;
const stableHref = validateSavedHref(snapshot?.lastStableHref ?? '/');
setInitialStableHref(stableHref);
if (shouldRestoreSavedRoute(initialPathname.current, initialUrl) && stableHref !== '/') {
router.replace(stableHref as never);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
uninstallPersistence.current = installMobileSessionPersistence(
snapshot?.playback ?? null
);
setHydrated(true);
} catch (error) {
if (cancelled) return;
console.warn('[session] restore failed', error);
try {
snapshot ??= await snapshotRead;
} catch {
// The normal empty-session fallback below remains safe.
}
if (cancelled) return;
setInitialStableHref(
normalizeStableHref(snapshot?.lastStableHref)
?? normalizeStableHref(initialPathname.current)
?? '/'
);
uninstallPersistence.current = installMobileSessionPersistence(snapshot?.playback ?? null);
setHydrated(true);
} finally {
if (!cancelled) onReady();
}
})();
return () => {
cancelled = true;
started.current = false;
uninstallPersistence.current?.();
uninstallPersistence.current = null;
};
}, [navigationKey, onReady, router]);
useEffect(() => {
if (!hydrated) return;
rememberStableHref(stableHrefForRoute(segments, pathname, params));
}, [hydrated, params, pathname, segments]);
return null;
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { materializePlaybackQueue } from './playbackMaterialization.ts';
test('materializes in load-repeat-seek order without playing', async () => {
const calls: string[] = [];
await materializePlaybackQueue(
{ tracks: ['a', 'b'], activeIndex: 1, position: 37, repeat: 'all' },
{
loadQueue: async (tracks, index) => {
calls.push(`load:${tracks.join(',')}:${index}`);
},
setRepeat: async (repeat) => {
calls.push(`repeat:${repeat}`);
},
seek: async (position) => {
calls.push(`seek:${position}`);
},
}
);
assert.deepEqual(calls, ['load:a,b:1', 'repeat:all', 'seek:37']);
});
test('does not seek a restored session at the beginning', async () => {
let seeks = 0;
await materializePlaybackQueue(
{ tracks: ['a'], activeIndex: 0, position: 0, repeat: 'none' },
{
loadQueue: async () => {},
setRepeat: async () => {},
seek: async () => {
seeks += 1;
},
}
);
assert.equal(seeks, 0);
});
+24
View File
@@ -0,0 +1,24 @@
import type { SessionRepeatMode } from './sessionState.ts';
export interface PlaybackMaterialization<T> {
tracks: T[];
activeIndex: number;
position: number;
repeat: SessionRepeatMode;
}
export interface PlaybackMaterializationEngine<T> {
loadQueue: (tracks: T[], activeIndex: number) => Promise<void>;
setRepeat: (repeat: SessionRepeatMode) => Promise<void>;
seek: (position: number) => Promise<void>;
}
/** Loads and seeks a restored queue without ever issuing Play. */
export async function materializePlaybackQueue<T>(
session: PlaybackMaterialization<T>,
engine: PlaybackMaterializationEngine<T>
): Promise<void> {
await engine.loadQueue(session.tracks, session.activeIndex);
await engine.setRepeat(session.repeat);
if (session.position > 0) await engine.seek(session.position);
}
+170
View File
@@ -0,0 +1,170 @@
import { AppState } from 'react-native';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { getPlaybackSessionSnapshot } from '@/audio/playbackController';
import { usePlayerStore } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore';
import {
MOBILE_SESSION_KIND,
MOBILE_SESSION_SCHEMA_VERSION,
normalizeStableHref,
parseMobileSessionSnapshot,
stringifyMobileSessionSnapshot,
type MobileSessionSnapshotV1,
type PlaybackSessionSnapshotV1,
} from './sessionState';
const MOBILE_SESSION_SETTING_KEY = 'mobile_session_state_v1';
const STRUCTURAL_SAVE_DEBOUNCE_MS = 250;
const POSITION_SAVE_THROTTLE_MS = 2000;
let lastStableHref = '/';
let scheduleStructuralSave: (() => void) | null = null;
let writeChain: Promise<void> = Promise.resolve();
export async function readPersistedMobileSession(): Promise<MobileSessionSnapshotV1 | null> {
const db = await openLibraryDb();
return parseMobileSessionSnapshot(await getSetting(db, MOBILE_SESSION_SETTING_KEY));
}
async function writePersistedMobileSession(snapshot: MobileSessionSnapshotV1): Promise<void> {
const db = await openLibraryDb();
await setSetting(db, MOBILE_SESSION_SETTING_KEY, stringifyMobileSessionSnapshot(snapshot));
}
function enqueueSnapshotWrite(snapshot: MobileSessionSnapshotV1): Promise<void> {
writeChain = writeChain
.catch(() => {
// A failed save must not poison every later queued write.
})
.then(() => writePersistedMobileSession(snapshot));
return writeChain;
}
export function setInitialStableHref(href: string): void {
lastStableHref = normalizeStableHref(href) ?? '/';
}
export function rememberStableHref(href: string): void {
const normalized = normalizeStableHref(href);
if (!normalized || normalized === lastStableHref) return;
lastStableHref = normalized;
scheduleStructuralSave?.();
}
function currentSnapshot(
lastKnownPlayback: PlaybackSessionSnapshotV1 | null
): { snapshot: MobileSessionSnapshotV1; playback: PlaybackSessionSnapshotV1 | null } {
let playback = getPlaybackSessionSnapshot();
const queue = useQueueStore.getState();
// A headless/native session can become visible before its queue mirror has
// crossed the bridge. Keep the last complete disk snapshot until that read
// settles rather than briefly overwriting it with an empty queue.
if (!playback && !queue.hasSnapshot) playback = lastKnownPlayback;
return {
snapshot: {
kind: MOBILE_SESSION_KIND,
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
savedAt: Date.now(),
lastStableHref,
playback,
},
playback,
};
}
function didPlayerStructureChange(
state: ReturnType<typeof usePlayerStore.getState>,
previous: ReturnType<typeof usePlayerStore.getState>
): boolean {
return state.currentTrack?.path !== previous.currentTrack?.path
|| state.shuffle !== previous.shuffle
|| state.repeat !== previous.repeat;
}
export function installMobileSessionPersistence(
initialPlayback: PlaybackSessionSnapshotV1 | null
): () => void {
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveDueAt = 0;
let lastSaveAt = 0;
let lastKnownPlayback = initialPlayback;
const clearSaveTimer = () => {
if (saveTimer !== null) {
clearTimeout(saveTimer);
saveTimer = null;
}
saveDueAt = 0;
};
const saveNow = () => {
clearSaveTimer();
lastSaveAt = Date.now();
const current = currentSnapshot(lastKnownPlayback);
lastKnownPlayback = current.playback;
void enqueueSnapshotWrite(current.snapshot).catch((error) => {
console.warn('[session] save failed', error);
});
};
const scheduleSave = (delayMs: number) => {
const dueAt = Date.now() + delayMs;
if (saveTimer !== null && saveDueAt <= dueAt) return;
clearSaveTimer();
saveDueAt = dueAt;
saveTimer = setTimeout(saveNow, delayMs);
};
const scheduleDebouncedSave = () => {
scheduleSave(STRUCTURAL_SAVE_DEBOUNCE_MS);
};
const schedulePositionSave = () => {
const elapsed = Date.now() - lastSaveAt;
scheduleSave(Math.max(0, POSITION_SAVE_THROTTLE_MS - elapsed));
};
scheduleStructuralSave = scheduleDebouncedSave;
const unsubscribePlayer = usePlayerStore.subscribe((state, previous) => {
if (
state.playbackState !== previous.playbackState
&& (state.playbackState === 'paused' || state.playbackState === 'stopped')
) {
saveNow();
return;
}
if (didPlayerStructureChange(state, previous)) {
scheduleDebouncedSave();
return;
}
if (state.currentTime !== previous.currentTime) schedulePositionSave();
});
const unsubscribeQueue = useQueueStore.subscribe((state, previous) => {
if (
state.tracks !== previous.tracks
|| state.activeIndex !== previous.activeIndex
|| state.hasSnapshot !== previous.hasSnapshot
) {
scheduleDebouncedSave();
}
});
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'inactive' || state === 'background') saveNow();
});
// Persist route validation and queue normalization from hydration. The
// snapshot fallback above gives a live native queue time to populate first.
scheduleDebouncedSave();
return () => {
if (scheduleStructuralSave === scheduleDebouncedSave) scheduleStructuralSave = null;
clearSaveTimer();
unsubscribePlayer();
unsubscribeQueue();
appStateSubscription.remove();
saveNow();
};
}
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MOBILE_SESSION_KIND,
MOBILE_SESSION_SCHEMA_VERSION,
hasExplicitLaunchDestination,
normalizeMobileSessionSnapshot,
normalizeStableHref,
parseMobileSessionSnapshot,
resolvePlaybackSession,
shouldRestoreSavedRoute,
stringifyMobileSessionSnapshot,
stableHrefForRoute,
validateRestoredHref,
type MobileSessionSnapshotV1,
} from './sessionState.ts';
const tracks = [
{ path: 'file:///a.flac', duration: 100, title: 'A' },
{ path: 'file:///b.flac', duration: 200, title: 'B' },
{ path: 'file:///c.flac', duration: 300, title: 'C' },
];
test('normalizes stable routes and rejects transient or unsafe routes', () => {
assert.equal(normalizeStableHref('/library/album/album%3Aone'), '/library/album/album%3Aone');
assert.equal(normalizeStableHref('/library/artist/Artist?credit=1&ignored=yes'), '/library/artist/Artist?credit=1');
assert.equal(normalizeStableHref('/settings/audio?ignored=yes'), '/settings/audio');
assert.equal(normalizeStableHref('/library/playlist/edit-dynamic?id=4'), null);
assert.equal(normalizeStableHref('/eq/scan'), null);
assert.equal(normalizeStableHref('/notification.click'), null);
assert.equal(normalizeStableHref('/library/artist/AC%2FDC'), '/library/artist/AC%2FDC');
assert.equal(normalizeStableHref('/library/album/%2E%2E'), null);
assert.equal(normalizeStableHref('/unknown'), null);
});
test('validates saved detail targets and falls back to Library when they disappeared', () => {
const context = {
hasAlbum: (key: string) => key === 'kept/album',
hasArtist: (name: string, credit: boolean) => name === 'AC/DC' && credit,
hasPlaylist: (id: number) => id === 7,
};
assert.equal(validateRestoredHref('/library/album/kept%2Falbum', context), '/library/album/kept%2Falbum');
assert.equal(validateRestoredHref('/library/album/deleted', context), '/library');
assert.equal(validateRestoredHref('/library/artist/AC%2FDC?credit=1', context), '/library/artist/AC%2FDC?credit=1');
assert.equal(validateRestoredHref('/library/playlist/7', context), '/library/playlist/7');
assert.equal(validateRestoredHref('/library/playlist/8', context), '/library');
assert.equal(validateRestoredHref('/library/playlist/favorites', context), '/library/playlist/favorites');
});
test('builds encoded stable hrefs from Expo Router file segments', () => {
assert.equal(
stableHrefForRoute(['(tabs)', 'library', 'album', '[key]'], '/library/album/a/b', { key: 'a/b' }),
'/library/album/a%2Fb'
);
assert.equal(
stableHrefForRoute(
['(tabs)', 'library', 'artist', '[name]', 'songs'],
'/library/artist/AC/DC/songs',
{ name: 'AC/DC', credit: '1' }
),
'/library/artist/AC%2FDC/songs?credit=1'
);
assert.equal(
stableHrefForRoute(['(tabs)', 'library', 'playlist', '[id]'], '/library/playlist/7', { id: '7' }),
'/library/playlist/7'
);
});
test('distinguishes launcher opens from explicit deep links', () => {
assert.equal(hasExplicitLaunchDestination(null), false);
assert.equal(hasExplicitLaunchDestination('astra://'), false);
assert.equal(hasExplicitLaunchDestination('astra://library/album/key'), true);
assert.equal(hasExplicitLaunchDestination('https://example.test/--/notification.click'), true);
assert.equal(hasExplicitLaunchDestination('content://shared/eq-preset'), true);
});
test('lets external destinations win while restoring over transient navigation state', () => {
assert.equal(shouldRestoreSavedRoute('/', null), true);
assert.equal(shouldRestoreSavedRoute('/eq/scan', null), true);
assert.equal(shouldRestoreSavedRoute('/lastfm/edit', null), true);
assert.equal(shouldRestoreSavedRoute('/recently-played', null), false);
assert.equal(shouldRestoreSavedRoute('/notification.click', null), false);
assert.equal(shouldRestoreSavedRoute('/', 'astra://library/playlist/7'), false);
});
test('round trips a normalized versioned snapshot', () => {
const snapshot: MobileSessionSnapshotV1 = {
kind: MOBILE_SESSION_KIND,
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
savedAt: 123,
lastStableHref: '/library/playlist/7',
playback: {
queuePaths: ['file:///a.flac', 'file:///b.flac'],
activeIndex: 1,
position: 80,
shuffle: true,
repeat: 'all',
originalOrderPaths: ['file:///b.flac', 'file:///a.flac'],
},
};
assert.deepEqual(parseMobileSessionSnapshot(stringifyMobileSessionSnapshot(snapshot)), snapshot);
});
test('rejects unknown versions and safely defaults corrupt fields', () => {
assert.equal(normalizeMobileSessionSnapshot({ kind: MOBILE_SESSION_KIND, schemaVersion: 99 }), null);
assert.equal(parseMobileSessionSnapshot('{broken'), null);
const normalized = normalizeMobileSessionSnapshot({
kind: MOBILE_SESSION_KIND,
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
savedAt: -5,
lastStableHref: '/eq/import?data=large',
playback: {
queuePaths: ['file:///a.flac', 'file:///b.flac'],
activeIndex: 999,
position: -10,
shuffle: 'yes',
repeat: 'invalid',
originalOrderPaths: ['file:///a.flac'],
},
});
assert.equal(normalized?.savedAt, 0);
assert.equal(normalized?.lastStableHref, '/');
assert.deepEqual(normalized?.playback, {
queuePaths: ['file:///a.flac', 'file:///b.flac'],
activeIndex: 1,
position: 0,
shuffle: false,
repeat: 'none',
originalOrderPaths: ['file:///a.flac', 'file:///b.flac'],
});
});
test('restores duplicates and clamps position to the current duration', () => {
const resolved = resolvePlaybackSession(
{
queuePaths: ['file:///a.flac', 'file:///b.flac', 'file:///a.flac'],
activeIndex: 2,
position: 500,
shuffle: true,
repeat: 'one',
originalOrderPaths: ['file:///a.flac', 'file:///a.flac', 'file:///b.flac'],
},
tracks
);
assert.deepEqual(resolved?.tracks.map((track) => track.title), ['A', 'B', 'A']);
assert.equal(resolved?.activeIndex, 2);
assert.equal(resolved?.position, 100);
assert.deepEqual(resolved?.originalOrderPaths, ['file:///a.flac', 'file:///a.flac', 'file:///b.flac']);
});
test('chooses the next survivor when the active track disappeared, then the previous', () => {
const next = resolvePlaybackSession(
{
queuePaths: ['file:///a.flac', 'file:///missing.flac', 'file:///c.flac'],
activeIndex: 1,
position: 42,
shuffle: false,
repeat: 'none',
originalOrderPaths: ['file:///a.flac', 'file:///missing.flac', 'file:///c.flac'],
},
tracks
);
assert.equal(next?.tracks[next.activeIndex].title, 'C');
assert.equal(next?.position, 0);
const previous = resolvePlaybackSession(
{
queuePaths: ['file:///a.flac', 'file:///missing.flac'],
activeIndex: 1,
position: 42,
shuffle: false,
repeat: 'none',
originalOrderPaths: ['file:///a.flac', 'file:///missing.flac'],
},
tracks
);
assert.equal(previous?.tracks[previous.activeIndex].title, 'A');
assert.equal(previous?.position, 0);
});
test('returns null when no queued path still exists', () => {
assert.equal(
resolvePlaybackSession(
{
queuePaths: ['file:///missing.flac'],
activeIndex: 0,
position: 10,
shuffle: false,
repeat: 'none',
originalOrderPaths: ['file:///missing.flac'],
},
tracks
),
null
);
});
+359
View File
@@ -0,0 +1,359 @@
export const MOBILE_SESSION_KIND = 'astra-mobile-session';
export const MOBILE_SESSION_SCHEMA_VERSION = 1;
const MAX_QUEUE_ITEMS = 100_000;
const MAX_PATH_LENGTH = 8192;
const MAX_HREF_LENGTH = 4096;
const MAX_POSITION_SECONDS = 30 * 24 * 60 * 60;
export type SessionRepeatMode = 'none' | 'one' | 'all';
export interface PlaybackSessionSnapshotV1 {
queuePaths: string[];
activeIndex: number;
position: number;
shuffle: boolean;
repeat: SessionRepeatMode;
originalOrderPaths: string[];
}
export interface MobileSessionSnapshotV1 {
kind: typeof MOBILE_SESSION_KIND;
schemaVersion: typeof MOBILE_SESSION_SCHEMA_VERSION;
savedAt: number;
lastStableHref: string;
playback: PlaybackSessionSnapshotV1 | null;
}
export interface SessionTrackLike {
path: string;
duration: number;
}
export interface ResolvedPlaybackSession<T extends SessionTrackLike> {
tracks: T[];
activeIndex: number;
position: number;
shuffle: boolean;
repeat: SessionRepeatMode;
originalOrderPaths: string[];
}
export interface StableRouteValidationContext {
hasAlbum: (identityKey: string) => boolean;
hasArtist: (name: string, credit: boolean) => boolean;
hasPlaylist: (id: number) => boolean;
}
const STATIC_STABLE_PATHS = new Set([
'/',
'/library',
'/eq',
'/settings',
'/recently-played',
'/settings/appearance',
'/settings/library',
'/settings/audio',
'/settings/services',
'/settings/experimental',
'/settings/info',
'/settings/haptics-lab',
'/sources',
'/lastfm',
'/desktop-remote',
'/desktop-sync',
]);
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function nonEmptyString(value: unknown, maxLength: number): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > maxLength) return null;
return trimmed;
}
function normalizePathArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const paths: string[] = [];
for (const entry of value.slice(0, MAX_QUEUE_ITEMS)) {
const path = nonEmptyString(entry, MAX_PATH_LENGTH);
if (path) paths.push(path);
}
return paths;
}
function samePathMultiset(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false;
const counts = new Map<string, number>();
for (const path of a) counts.set(path, (counts.get(path) ?? 0) + 1);
for (const path of b) {
const count = counts.get(path) ?? 0;
if (count <= 0) return false;
if (count === 1) counts.delete(path);
else counts.set(path, count - 1);
}
return counts.size === 0;
}
function normalizeRepeat(value: unknown): SessionRepeatMode {
return value === 'one' || value === 'all' ? value : 'none';
}
function decodeRoutePart(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
/**
* Canonicalizes routes that are safe to restore after a cold launch. Transient
* editors, scanners, import/redirect routes, and unknown future routes return
* null so the previously remembered stable page remains authoritative.
*/
export function normalizeStableHref(value: unknown): string | null {
const href = nonEmptyString(value, MAX_HREF_LENGTH);
if (!href || !href.startsWith('/') || href.startsWith('//') || href.includes('\\')) return null;
const hashless = href.split('#', 1)[0] ?? '';
const queryIndex = hashless.indexOf('?');
const rawPath = queryIndex >= 0 ? hashless.slice(0, queryIndex) : hashless;
const rawQuery = queryIndex >= 0 ? hashless.slice(queryIndex + 1) : '';
const path = rawPath.length > 1 ? rawPath.replace(/\/+$/, '') : rawPath;
if (!path || path.includes('//') || /%5c/i.test(path)) return null;
const decodedSegments = path.split('/').slice(1).map(decodeRoutePart);
if (decodedSegments.some(
(segment) => segment === null || segment === '.' || segment === '..' || segment.includes('\\')
)) {
return null;
}
if (STATIC_STABLE_PATHS.has(path)) return path;
if (/^\/library\/album\/[^/]+$/.test(path)) return path;
if (/^\/library\/playlist\/(?:favorites|\d+)$/.test(path)) return path;
if (/^\/library\/artist\/[^/]+(?:\/(?:albums|songs|appearances))?$/.test(path)) {
const hasCredit = rawQuery
.split('&')
.map((part) => part.split('=', 2).map(decodeRoutePart))
.some(([key, entry]) => key === 'credit' && entry === '1');
return hasCredit ? `${path}?credit=1` : path;
}
return null;
}
export function validateRestoredHref(
href: string,
context: StableRouteValidationContext
): string {
const normalized = normalizeStableHref(href) ?? '/';
const [pathname, query = ''] = normalized.split('?', 2);
const albumMatch = pathname.match(/^\/library\/album\/([^/]+)$/);
if (albumMatch) {
const key = decodeRoutePart(albumMatch[1]);
return key && context.hasAlbum(key) ? normalized : '/library';
}
const artistMatch = pathname.match(
/^\/library\/artist\/([^/]+)(?:\/(?:albums|songs|appearances))?$/
);
if (artistMatch) {
const name = decodeRoutePart(artistMatch[1]);
const credit = new URLSearchParams(query).get('credit') === '1';
return name && context.hasArtist(name, credit) ? normalized : '/library';
}
const playlistMatch = pathname.match(/^\/library\/playlist\/(favorites|\d+)$/);
if (playlistMatch) {
if (playlistMatch[1] === 'favorites') return normalized;
return context.hasPlaylist(Number(playlistMatch[1])) ? normalized : '/library';
}
return normalized;
}
function firstRouteParam(value: unknown): string | null {
const entry = Array.isArray(value) ? value[0] : value;
return typeof entry === 'string' && entry.length > 0 ? entry : null;
}
/** Builds an encoded href from Expo Router's file segments and decoded params. */
export function stableHrefForRoute(
segments: readonly string[],
pathname: string,
params: Record<string, unknown>
): string {
const routeSegments = segments.filter(
(segment) => !(segment.startsWith('(') && segment.endsWith(')'))
);
const key = firstRouteParam(params.key);
if (routeSegments.join('/') === 'library/album/[key]' && key) {
return `/library/album/${encodeURIComponent(key)}`;
}
const name = firstRouteParam(params.name);
if (
name
&& routeSegments[0] === 'library'
&& routeSegments[1] === 'artist'
&& routeSegments[2] === '[name]'
) {
const subpage = routeSegments[3];
const suffix = subpage === 'albums' || subpage === 'songs' || subpage === 'appearances'
? `/${subpage}`
: '';
const credit = firstRouteParam(params.credit) === '1' ? '?credit=1' : '';
return `/library/artist/${encodeURIComponent(name)}${suffix}${credit}`;
}
const id = firstRouteParam(params.id);
if (routeSegments.join('/') === 'library/playlist/[id]' && id) {
return `/library/playlist/${encodeURIComponent(id)}`;
}
return pathname;
}
/** Whether an initial OS URL names a destination that must beat disk restore. */
export function hasExplicitLaunchDestination(value: string | null): boolean {
if (!value) return false;
try {
const url = new URL(value);
let path = url.pathname || '';
if (url.protocol === 'astra:' && url.hostname) path = `/${url.hostname}${path}`;
const expoMarker = path.indexOf('/--/');
if (expoMarker >= 0) path = path.slice(expoMarker + 3);
return path !== '' && path !== '/';
} catch {
// A non-empty URL we cannot parse is still an explicit external launch.
return true;
}
}
/** Whether restart navigation may replace the router's initial destination. */
export function shouldRestoreSavedRoute(
initialPathname: string,
initialUrl: string | null
): boolean {
if (hasExplicitLaunchDestination(initialUrl)) return false;
if (initialPathname === '/') return true;
if (initialPathname === '/notification.click' || initialPathname === '/eq/import') {
return false;
}
// Stable non-root routes are initial deep-link/widget destinations when the
// URL has already been consumed by Expo Router. Transient editor/scanner
// state is never allowed to beat the last stable disk route.
return normalizeStableHref(initialPathname) === null;
}
export function normalizePlaybackSession(value: unknown): PlaybackSessionSnapshotV1 | null {
if (!isPlainRecord(value)) return null;
const queuePaths = normalizePathArray(value.queuePaths);
if (queuePaths.length === 0) return null;
const rawIndex = Math.trunc(finiteNumber(value.activeIndex));
const activeIndex = Math.max(0, Math.min(queuePaths.length - 1, rawIndex));
const position = Math.max(0, Math.min(MAX_POSITION_SECONDS, finiteNumber(value.position)));
const candidateOriginalOrder = normalizePathArray(value.originalOrderPaths);
const originalOrderPaths = samePathMultiset(candidateOriginalOrder, queuePaths)
? candidateOriginalOrder
: [...queuePaths];
return {
queuePaths,
activeIndex,
position,
shuffle: value.shuffle === true,
repeat: normalizeRepeat(value.repeat),
originalOrderPaths,
};
}
export function normalizeMobileSessionSnapshot(value: unknown): MobileSessionSnapshotV1 | null {
if (!isPlainRecord(value)) return null;
if (value.kind !== MOBILE_SESSION_KIND || value.schemaVersion !== MOBILE_SESSION_SCHEMA_VERSION) {
return null;
}
return {
kind: MOBILE_SESSION_KIND,
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
savedAt: Math.max(0, finiteNumber(value.savedAt)),
lastStableHref: normalizeStableHref(value.lastStableHref) ?? '/',
playback: normalizePlaybackSession(value.playback),
};
}
export function parseMobileSessionSnapshot(raw: string | null): MobileSessionSnapshotV1 | null {
if (!raw) return null;
try {
return normalizeMobileSessionSnapshot(JSON.parse(raw));
} catch {
return null;
}
}
export function stringifyMobileSessionSnapshot(snapshot: MobileSessionSnapshotV1): string {
return JSON.stringify(snapshot);
}
/**
* Re-resolves a saved path-only queue against today's library rows. Duplicate
* queue entries remain duplicates; deleted paths are removed occurrence-wise.
*/
export function resolvePlaybackSession<T extends SessionTrackLike>(
snapshot: PlaybackSessionSnapshotV1,
libraryTracks: readonly T[]
): ResolvedPlaybackSession<T> | null {
const byPath = new Map(libraryTracks.map((track) => [track.path, track]));
const resolvedEntries = snapshot.queuePaths.flatMap((path, originalIndex) => {
const track = byPath.get(path);
return track ? [{ track, originalIndex }] : [];
});
if (resolvedEntries.length === 0) return null;
let resolvedActiveIndex = resolvedEntries.findIndex(
(entry) => entry.originalIndex === snapshot.activeIndex
);
const activeSurvived = resolvedActiveIndex >= 0;
if (!activeSurvived) {
resolvedActiveIndex = resolvedEntries.findIndex(
(entry) => entry.originalIndex > snapshot.activeIndex
);
if (resolvedActiveIndex < 0) resolvedActiveIndex = resolvedEntries.length - 1;
}
const activeTrack = resolvedEntries[resolvedActiveIndex].track;
const duration = Number.isFinite(activeTrack.duration) && activeTrack.duration > 0
? activeTrack.duration
: 0;
const position = activeSurvived
? Math.max(0, duration > 0 ? Math.min(snapshot.position, duration) : 0)
: 0;
const survivingPaths = new Set(byPath.keys());
const originalOrderPaths = snapshot.originalOrderPaths.filter((path) => survivingPaths.has(path));
const resolvedQueuePaths = resolvedEntries.map((entry) => entry.track.path);
return {
tracks: resolvedEntries.map((entry) => entry.track),
activeIndex: resolvedActiveIndex,
position,
shuffle: snapshot.shuffle,
repeat: snapshot.repeat,
originalOrderPaths: samePathMultiset(originalOrderPaths, resolvedQueuePaths)
? originalOrderPaths
: resolvedQueuePaths,
};
}