From 4e98bd5b8ee77de322f64aa34a3f1a8a83e141eb Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:09:59 -0400 Subject: [PATCH] persistence --- package.json | 1 + src/app/_layout.tsx | 16 +- src/audio/playbackController.ts | 170 ++++++++- src/audio/usePlaybackSync.ts | 46 ++- src/components/queue/useQueue.ts | 6 +- src/session/SessionLifecycle.tsx | 161 +++++++++ src/session/playbackMaterialization.test.mts | 39 ++ src/session/playbackMaterialization.ts | 24 ++ src/session/sessionPersistence.ts | 170 +++++++++ src/session/sessionState.test.mts | 200 +++++++++++ src/session/sessionState.ts | 359 +++++++++++++++++++ src/stores/playerStore.ts | 6 + src/stores/remoteSourcesStore.ts | 32 +- 13 files changed, 1200 insertions(+), 30 deletions(-) create mode 100644 src/session/SessionLifecycle.tsx create mode 100644 src/session/playbackMaterialization.test.mts create mode 100644 src/session/playbackMaterialization.ts create mode 100644 src/session/sessionPersistence.ts create mode 100644 src/session/sessionState.test.mts create mode 100644 src/session/sessionState.ts diff --git a/package.json b/package.json index f34e565..0397641 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", + "test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index ebcd798..81449e6 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { AppState, StyleSheet, View } from 'react-native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; @@ -45,6 +45,7 @@ import { useThemeStore } from '@/stores/themeStore'; import { useOnboardingStore } from '@/stores/onboardingStore'; import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow'; import { useTheme } from '@/theme/themed'; +import { SessionLifecycle } from '@/session/SessionLifecycle'; // Anchor the root stack at the tabs so a deep link straight to a top-level route // (the widget's `recently-played`, the notification-click redirect) builds @@ -288,13 +289,17 @@ export default function RootLayout() { const onboardingLoaded = useOnboardingStore((s) => s.loaded); const onboardingComplete = useOnboardingStore((s) => s.onboardingComplete); const theme = useTheme(); - const ready = (fontsLoaded && themeLoaded && onboardingLoaded) || splashTimedOut; + const renderReady = (fontsLoaded && themeLoaded && onboardingLoaded) || splashTimedOut; + const [sessionReady, setSessionReady] = useState(false); + const handleSessionReady = useCallback(() => setSessionReady(true), []); + const splashReady = renderReady + && (!onboardingComplete || sessionReady || splashTimedOut); useEffect(() => { - if (ready) { + if (splashReady) { void SplashScreen.hideAsync().catch(() => {}); } - }, [ready]); + }, [splashReady]); // Eager library init: SQLite open + initial reads are tens of ms, and the // Library tab + playback adapters get data immediately. EQ + audio settings load @@ -331,7 +336,7 @@ export default function RootLayout() { .catch((err) => console.error('[lastfm] init failed', err)); }, []); - if (!ready) return null; + if (!renderReady) return null; return ( @@ -361,6 +366,7 @@ export default function RootLayout() { > + {onboardingComplete ? : null} {onboardingComplete ? ( <> {/* Always-mounted player overlay (store-gated); open/close is a pure diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 617bdf6..676efce 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -7,6 +7,11 @@ import type { PlaybackState, Track } from '@/types/audio'; import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore'; import { useQueueStore } from '@/stores/queueStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; +import type { + PlaybackSessionSnapshotV1, + ResolvedPlaybackSession, +} from '@/session/sessionState'; +import { materializePlaybackQueue } from '@/session/playbackMaterialization'; import { setupPlayer } from './trackPlayer'; import { SAMPLE_TRACKS, rntpToTrack, toRntpTrack } from './sampleTracks'; import { @@ -33,6 +38,7 @@ setQueueLoadErrorHandler(() => { // off and the upcoming tail restored to its original sequence (mirrors desktop's // autoQueue + shuffledAutoIndices split, but over RNTP's flat native queue). let originalOrder: string[] | null = null; +let restoredMaterializationPromise: Promise | null = null; const NEXT_REPEAT: Record = { none: 'all', @@ -70,6 +76,10 @@ function rntpTrackId(track: RntpTrack): string { return String(track.id ?? track.url); } +function rntpTrackPath(track: RntpTrack): string { + return typeof track.astraPath === 'string' ? track.astraPath : String(track.url); +} + function setOptimisticTrack(track: RntpTrack | undefined, playbackState?: PlaybackState): void { if (!track) return; const current = rntpToTrack(track); @@ -101,6 +111,9 @@ async function getQueueSnapshot(): Promise<{ queue: RntpTrack[]; activeIndex: nu const store = useQueueStore.getState(); if (store.hasSnapshot) { + if (usePlayerStore.getState().restoredSessionPending) { + return { queue: store.tracks, activeIndex: store.activeIndex }; + } await store.refreshActiveIndex(); const { tracks, activeIndex } = useQueueStore.getState(); return { queue: tracks, activeIndex }; @@ -121,6 +134,22 @@ function syncOriginalOrderFromMirrorIfUnshuffled(): void { if (hasSnapshot) originalOrder = tracks.map(rntpTrackId); } +function pruneOriginalOrderToMirror(): void { + if (!originalOrder) return; + const remaining = new Map(); + for (const track of useQueueStore.getState().tracks) { + const id = rntpTrackId(track); + remaining.set(id, (remaining.get(id) ?? 0) + 1); + } + originalOrder = originalOrder.filter((id) => { + const count = remaining.get(id) ?? 0; + if (count <= 0) return false; + if (count === 1) remaining.delete(id); + else remaining.set(id, count - 1); + return true; + }); +} + function selectPhonePlaybackTarget(): void { void usePlaybackTargetStore.getState().setTarget('phone'); } @@ -142,11 +171,129 @@ function shuffleArray(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(options: { allowBackgroundSetup?: boolean } = {}): Promise { +async function materializeRestoredSession(): Promise { + if (!usePlayerStore.getState().restoredSessionPending) return; + if (restoredMaterializationPromise) return restoredMaterializationPromise; + + restoredMaterializationPromise = (async () => { + const queue = useQueueStore.getState(); + if (queue.tracks.length === 0 || queue.activeIndex < 0) { + usePlayerStore.getState().setRestoredSessionPending(false); + return; + } + + const player = usePlayerStore.getState(); + // Remote stream URLs can expire or the server can move between relaunch and + // Play. Rebuild every RNTP row from its stable Astra identity at the lazy + // materialization boundary so URL resolution is fresh. + const materializedTracks = queue.tracks.map((track) => toRntpTrack(rntpToTrack(track))); + useQueueStore.getState().setSnapshot(materializedTracks, queue.activeIndex); + if (player.currentTime > 0) player.setPendingSeek(player.currentTime); + await materializePlaybackQueue( + { + tracks: materializedTracks, + activeIndex: queue.activeIndex, + position: player.currentTime, + repeat: player.repeat, + }, + { + loadQueue: loadQueueChunked, + setRepeat: async (repeat) => { + await TrackPlayer.setRepeatMode(toRntpRepeat(repeat)); + }, + seek: (position) => TrackPlayer.seekTo(position), + } + ); + usePlayerStore.getState().setRestoredSessionPending(false); + })(); + + try { + await restoredMaterializationPromise; + } finally { + restoredMaterializationPromise = null; + } +} + +async function ensurePlayerReady( + options: { allowBackgroundSetup?: boolean; materializeRestored?: boolean } = {} +): Promise { await setupPlayer(options); + if (options.materializeRestored !== false) await materializeRestoredSession(); await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat)); } +function discardPendingRestoredSession(): void { + usePlayerStore.getState().setRestoredSessionPending(false); +} + +export function getPlaybackSessionSnapshot(): PlaybackSessionSnapshotV1 | null { + const queue = useQueueStore.getState(); + if (!queue.hasSnapshot || queue.tracks.length === 0) return null; + + const player = usePlayerStore.getState(); + const queuePaths = queue.tracks.map(rntpTrackPath); + let activeIndex = queue.activeIndex; + if (activeIndex < 0 || activeIndex >= queuePaths.length) { + const currentPath = player.currentTrack?.path; + activeIndex = currentPath ? queuePaths.indexOf(currentPath) : -1; + if (activeIndex < 0) activeIndex = 0; + } + + const pathById = new Map(queue.tracks.map((track) => [rntpTrackId(track), rntpTrackPath(track)])); + const originalOrderPaths = originalOrder + ?.map((id) => pathById.get(id)) + .filter((path): path is string => Boolean(path)); + + return { + queuePaths, + activeIndex, + position: player.currentTime, + shuffle: player.shuffle, + repeat: player.repeat, + originalOrderPaths: originalOrderPaths?.length === queuePaths.length + ? originalOrderPaths + : [...queuePaths], + }; +} + +export function restorePlaybackSession( + session: ResolvedPlaybackSession | null +): void { + const player = usePlayerStore.getState(); + if (!session || session.tracks.length === 0) { + originalOrder = null; + useQueueStore.getState().setSnapshot([], -1); + player.reset(); + player.setShuffle(false); + player.setRepeat('none'); + return; + } + + const queueTracks = session.tracks.map(toRntpTrack); + const activeTrack = session.tracks[session.activeIndex]; + const idByPath = new Map(session.tracks.map((track) => [track.path, track.id])); + originalOrder = session.originalOrderPaths + .map((path) => idByPath.get(path)) + .filter((id): id is string => Boolean(id)); + useQueueStore.getState().setSnapshot(queueTracks, session.activeIndex); + player.setCurrentTrack(activeTrack); + player.setProgress(session.position, activeTrack.duration); + player.clearPendingSeek(); + player.setShuffle(session.shuffle); + player.setRepeat(session.repeat); + player.setPlaybackState('paused'); + player.setRestoredSessionPending(true); +} + +/** A live RNTP session (for example Android Auto) wins over an older disk snapshot. */ +export async function hasActiveNativePlaybackSession(): Promise { + try { + return Boolean(await TrackPlayer.getActiveTrack()); + } catch { + return false; + } +} + /** Replace the queue with the given tracks and start playing at startIndex. */ export async function playTracks(tracks: Track[], startIndex = 0): Promise { return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false }); @@ -164,7 +311,8 @@ async function playTracksInternal( ): Promise { if (tracks.length === 0) return; selectPhonePlaybackTarget(); - await ensurePlayerReady(options); + discardPendingRestoredSession(); + await ensurePlayerReady({ ...options, materializeRestored: false }); originalOrder = tracks.map((t) => t.id); // Honor an already-on shuffle by scrambling the upcoming tail of the new // context up front, so the whole queue is loaded natively in a single pass. @@ -189,7 +337,8 @@ async function playTracksInternal( export async function shuffleTracks(tracks: Track[]): Promise { if (tracks.length === 0) return; selectPhonePlaybackTarget(); - await ensurePlayerReady(); + discardPendingRestoredSession(); + await ensurePlayerReady({ materializeRestored: false }); originalOrder = tracks.map((t) => t.id); usePlayerStore.getState().setShuffle(true); const queueTracks = shuffleArray(tracks).map(toRntpTrack); @@ -208,7 +357,8 @@ export async function shuffleTracks(tracks: Track[]): Promise { /** M0 demo entry point: load the streamed sample queue if nothing is queued. */ export async function playSample(): Promise { selectPhonePlaybackTarget(); - await ensurePlayerReady(); + discardPendingRestoredSession(); + await ensurePlayerReady({ materializeRestored: false }); await queueLoadSettled(); const queue = await TrackPlayer.getQueue(); if (queue.length === 0) { @@ -256,6 +406,7 @@ export async function pause(): Promise { } export async function seekTo(seconds: number): Promise { + await ensurePlayerReady(); const duration = usePlayerStore.getState().duration; usePlayerStore.getState().setPendingSeek(seconds); usePlayerStore.getState().setProgress(seconds, duration); @@ -279,6 +430,7 @@ export async function togglePlay(): Promise { } export async function skipToNext(): Promise { + await ensurePlayerReady(); const { tracks, activeIndex } = useQueueStore.getState(); const nextIndex = activeIndex >= 0 ? activeIndex + 1 : -1; if (nextIndex >= 0 && nextIndex < tracks.length) { @@ -295,6 +447,7 @@ export async function skipToNext(): Promise { } export async function skipToPrevious(): Promise { + await ensurePlayerReady(); const { tracks, activeIndex } = useQueueStore.getState(); const previousIndex = activeIndex > 0 ? activeIndex - 1 : -1; if (previousIndex >= 0 && previousIndex < tracks.length) { @@ -445,6 +598,7 @@ interface QueueRemoveOptions { /** Replace everything after the current track with `upcoming` (in order). */ export async function setUpcoming(upcoming: RntpTrack[]): Promise { + await ensurePlayerReady(); await queueLoadSettled(); await TrackPlayer.removeUpcomingTracks(); useQueueStore.getState().replaceUpcoming(upcoming); @@ -458,6 +612,7 @@ export async function setUpcoming(upcoming: RntpTrack[]): Promise { /** Move a queued item by absolute RNTP queue index. */ export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex: number): Promise { if (fromAbsoluteIndex === toAbsoluteIndex) return; + await ensurePlayerReady(); await queueLoadSettled(); await TrackPlayer.move(fromAbsoluteIndex, toAbsoluteIndex); useQueueStore.getState().moveItem(fromAbsoluteIndex, toAbsoluteIndex); @@ -467,6 +622,7 @@ export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex: /** Jump to (and play) an absolute queue index. */ export async function jumpToQueueIndex(index: number): Promise { selectPhonePlaybackTarget(); + await ensurePlayerReady(); // Mid-fill, the tapped row may not be in the native queue yet (or may sit at // a shifted native index while the head is still prepending) — translate, // waiting out the fill only when the target isn't loaded. @@ -519,12 +675,13 @@ export async function removeFromQueue( absoluteIndex: number, options: QueueRemoveOptions = {} ): Promise { + await ensurePlayerReady(); await queueLoadSettled(); await TrackPlayer.remove(absoluteIndex); if (options.updateMirror !== false) { useQueueStore.getState().removeIndices([absoluteIndex]); } - syncOriginalOrderFromMirrorIfUnshuffled(); + pruneOriginalOrderToMirror(); } /** Remove a group of tracks at absolute queue indices. */ @@ -533,10 +690,11 @@ export async function removeManyFromQueue( options: QueueRemoveOptions = {} ): Promise { if (absoluteIndices.length === 0) return; + await ensurePlayerReady(); await queueLoadSettled(); await TrackPlayer.remove(absoluteIndices); if (options.updateMirror !== false) { useQueueStore.getState().removeIndices(absoluteIndices); } - syncOriginalOrderFromMirrorIfUnshuffled(); + pruneOriginalOrderToMirror(); } diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index bca52f2..a426ad9 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -7,6 +7,7 @@ import { } from 'react-native-track-player'; import { usePlayerStore } from '@/stores/playerStore'; import { useLibraryStore } from '@/stores/libraryStore'; +import { useQueueStore } from '@/stores/queueStore'; import type { PlaybackState, Track } from '@/types/audio'; import { rntpToTrack } from './sampleTracks'; import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync'; @@ -103,7 +104,10 @@ export function usePlaybackSync(): void { path: null, state: 'stopped', }); + const restoredPlaybackSyncHeld = useRef(false); const rawPlaybackState = mapState(playbackState.state); + const restoredSessionPending = usePlayerStore((s) => s.restoredSessionPending); + const restoredTrack = usePlayerStore((s) => s.currentTrack); const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack); const setProgress = usePlayerStore((s) => s.setProgress); @@ -112,6 +116,7 @@ export function usePlaybackSync(): void { const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); useEffect(() => { + if (restoredSessionPending && !activeTrack) return; const nextTrack = activeTrack ? rntpToTrack(activeTrack) : null; const prevTrack = usePlayerStore.getState().currentTrack; if (prevTrack?.path !== nextTrack?.path) { @@ -122,9 +127,10 @@ export function usePlaybackSync(): void { // currentTrack subscriber) when nothing actually changed. if (sameTrack(prevTrack, nextTrack)) return; setCurrentTrack(nextTrack); - }, [activeTrack, setCurrentTrack]); + }, [activeTrack, restoredSessionPending, setCurrentTrack]); useEffect(() => { + if (restoredSessionPending) return; const pendingSeek = usePlayerStore.getState().pendingSeek; if (pendingSeek) { const acknowledged = Math.abs(progress.position - pendingSeek.target) <= SEEK_ACK_EPS; @@ -133,9 +139,20 @@ export function usePlaybackSync(): void { usePlayerStore.getState().clearPendingSeek(); } setProgress(progress.position, progress.duration); - }, [progress.position, progress.duration, setProgress]); + }, [progress.position, progress.duration, restoredSessionPending, setProgress]); useEffect(() => { + if (restoredSessionPending) { + restoredPlaybackSyncHeld.current = true; + return; + } + // The Zustand pending flag can clear one render before RNTP's Ready event + // reaches the playback hook. Ignore that one stale native snapshot so it + // cannot turn the freshly restored paused session into stopped. + if (restoredPlaybackSyncHeld.current) { + restoredPlaybackSyncHeld.current = false; + return; + } const activeTrackPath = activeTrack ? rntpToTrack(activeTrack).path : null; const mappedPlaybackState = resolveTransientLoading( rawPlaybackState, @@ -161,7 +178,17 @@ export function usePlaybackSync(): void { state: mappedPlaybackState, }; } - }, [activeTrack, rawPlaybackState, setPlaybackState]); + }, [activeTrack, rawPlaybackState, restoredSessionPending, setPlaybackState]); + + // Natural advances and headless Android Auto playback do not pass through the + // UI controller helpers. Reconcile the mirror's active index (and cold queue) + // whenever RNTP reports a new active track so the next session save is exact. + useEffect(() => { + if (!activeTrack || restoredSessionPending) return; + const queue = useQueueStore.getState(); + if (queue.hasSnapshot) void queue.refreshActiveIndex(); + else void queue.refreshFromNative(); + }, [activeTrack, restoredSessionPending]); // 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 @@ -171,9 +198,13 @@ export function usePlaybackSync(): void { // 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; + const track = activeTrack + ? rntpToTrack(activeTrack) + : restoredSessionPending + ? restoredTrack + : null; const mappedPlaybackState = resolveTransientLoading( - rawPlaybackState, + restoredSessionPending ? 'paused' : rawPlaybackState, track?.path ?? null, stablePlayback.current ); @@ -182,9 +213,10 @@ export function usePlaybackSync(): void { mappedPlaybackState, buildWidgetRecentItems(recentlyPlayedTracks, track?.path), ); - }, [activeTrack, rawPlaybackState, recentlyPlayedTracks]); + }, [activeTrack, rawPlaybackState, recentlyPlayedTracks, restoredSessionPending, restoredTrack]); useEffect(() => { + if (restoredSessionPending) return; // Use the identity path (subsonic://|jellyfin:// for remote; the file URI for // local) so history matches `tracks.path` — activeTrack.url is the stream URL. const path = activeTrack ? rntpToTrack(activeTrack).path : null; @@ -232,5 +264,5 @@ export function usePlaybackSync(): void { void recordTrackPlayed(path).catch((err) => { console.warn('[library] playback history update failed', err); }); - }, [activeTrack, rawPlaybackState, progress.position, recordTrackPlayed]); + }, [activeTrack, rawPlaybackState, progress.position, recordTrackPlayed, restoredSessionPending]); } diff --git a/src/components/queue/useQueue.ts b/src/components/queue/useQueue.ts index 9930cc1..2b717da 100644 --- a/src/components/queue/useQueue.ts +++ b/src/components/queue/useQueue.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player'; import { useQueueStore } from '@/stores/queueStore'; +import { usePlayerStore } from '@/stores/playerStore'; import { nativeIndexToAbsolute } from '@/audio/queueLoader'; export interface QueueSnapshot { @@ -21,15 +22,18 @@ export function useQueue(active: boolean): QueueSnapshot { const refresh = useQueueStore((s) => s.refreshFromNative); const refreshActiveIndex = useQueueStore((s) => s.refreshActiveIndex); const setActiveIndex = useQueueStore((s) => s.setActiveIndex); + const restoredSessionPending = usePlayerStore((s) => s.restoredSessionPending); useEffect(() => { if (!active) return; + if (restoredSessionPending) return; if (hasSnapshot) void refreshActiveIndex(); else void refresh(); - }, [active, hasSnapshot, refresh, refreshActiveIndex]); + }, [active, hasSnapshot, refresh, refreshActiveIndex, restoredSessionPending]); useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => { if (!active) return; + if (restoredSessionPending) return; if (hasSnapshot) { // Event indices are native — shifted while a chunked load is prepending the head. setActiveIndex(event.index != null ? nativeIndexToAbsolute(event.index) : -1); diff --git a/src/session/SessionLifecycle.tsx b/src/session/SessionLifecycle.tsx new file mode 100644 index 0000000..206babf --- /dev/null +++ b/src/session/SessionLifecycle.tsx @@ -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 = 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((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; +} diff --git a/src/session/playbackMaterialization.test.mts b/src/session/playbackMaterialization.test.mts new file mode 100644 index 0000000..79fe921 --- /dev/null +++ b/src/session/playbackMaterialization.test.mts @@ -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); +}); + diff --git a/src/session/playbackMaterialization.ts b/src/session/playbackMaterialization.ts new file mode 100644 index 0000000..3c8dec0 --- /dev/null +++ b/src/session/playbackMaterialization.ts @@ -0,0 +1,24 @@ +import type { SessionRepeatMode } from './sessionState.ts'; + +export interface PlaybackMaterialization { + tracks: T[]; + activeIndex: number; + position: number; + repeat: SessionRepeatMode; +} + +export interface PlaybackMaterializationEngine { + loadQueue: (tracks: T[], activeIndex: number) => Promise; + setRepeat: (repeat: SessionRepeatMode) => Promise; + seek: (position: number) => Promise; +} + +/** Loads and seeks a restored queue without ever issuing Play. */ +export async function materializePlaybackQueue( + session: PlaybackMaterialization, + engine: PlaybackMaterializationEngine +): Promise { + await engine.loadQueue(session.tracks, session.activeIndex); + await engine.setRepeat(session.repeat); + if (session.position > 0) await engine.seek(session.position); +} diff --git a/src/session/sessionPersistence.ts b/src/session/sessionPersistence.ts new file mode 100644 index 0000000..a193943 --- /dev/null +++ b/src/session/sessionPersistence.ts @@ -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 = Promise.resolve(); + +export async function readPersistedMobileSession(): Promise { + const db = await openLibraryDb(); + return parseMobileSessionSnapshot(await getSetting(db, MOBILE_SESSION_SETTING_KEY)); +} + +async function writePersistedMobileSession(snapshot: MobileSessionSnapshotV1): Promise { + const db = await openLibraryDb(); + await setSetting(db, MOBILE_SESSION_SETTING_KEY, stringifyMobileSessionSnapshot(snapshot)); +} + +function enqueueSnapshotWrite(snapshot: MobileSessionSnapshotV1): Promise { + 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, + previous: ReturnType +): 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 | 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(); + }; +} diff --git a/src/session/sessionState.test.mts b/src/session/sessionState.test.mts new file mode 100644 index 0000000..31a6d0b --- /dev/null +++ b/src/session/sessionState.test.mts @@ -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 + ); +}); diff --git a/src/session/sessionState.ts b/src/session/sessionState.ts new file mode 100644 index 0000000..94b9b36 --- /dev/null +++ b/src/session/sessionState.ts @@ -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 { + 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 { + 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(); + 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 { + 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( + snapshot: PlaybackSessionSnapshotV1, + libraryTracks: readonly T[] +): ResolvedPlaybackSession | 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, + }; +} diff --git a/src/stores/playerStore.ts b/src/stores/playerStore.ts index e048509..78d52c6 100644 --- a/src/stores/playerStore.ts +++ b/src/stores/playerStore.ts @@ -25,6 +25,8 @@ interface PlayerStore { // Field names mirror desktop playerStore so queue/transport logic stays consistent. shuffle: boolean; repeat: RepeatMode; + /** Restored JS queue exists but has not been loaded into RNTP yet. */ + restoredSessionPending: boolean; setCurrentTrack: (track: Track | null) => void; setPlaybackState: (state: PlaybackState) => void; @@ -35,6 +37,7 @@ interface PlayerStore { setMuted: (isMuted: boolean) => void; setShuffle: (shuffle: boolean) => void; setRepeat: (repeat: RepeatMode) => void; + setRestoredSessionPending: (pending: boolean) => void; reset: () => void; } @@ -48,6 +51,7 @@ export const usePlayerStore = create((set) => ({ isMuted: false, shuffle: false, repeat: 'none', + restoredSessionPending: false, setCurrentTrack: (currentTrack) => set({ currentTrack }), setPlaybackState: (playbackState) => set({ playbackState }), @@ -58,6 +62,7 @@ export const usePlayerStore = create((set) => ({ setMuted: (isMuted) => set({ isMuted }), setShuffle: (shuffle) => set({ shuffle }), setRepeat: (repeat) => set({ repeat }), + setRestoredSessionPending: (restoredSessionPending) => set({ restoredSessionPending }), reset: () => set({ currentTrack: null, @@ -65,5 +70,6 @@ export const usePlayerStore = create((set) => ({ currentTime: 0, duration: 0, pendingSeek: null, + restoredSessionPending: false, }), })); diff --git a/src/stores/remoteSourcesStore.ts b/src/stores/remoteSourcesStore.ts index 953042f..dc636f7 100644 --- a/src/stores/remoteSourcesStore.ts +++ b/src/stores/remoteSourcesStore.ts @@ -127,23 +127,33 @@ interface RemoteSourcesStore { syncAll: () => Promise; } +let initPromise: Promise | null = null; + export const useRemoteSourcesStore = create((set, get) => ({ sources: [], initialized: false, progressById: {}, - init: async () => { - if (get().initialized) return; - const db = await openLibraryDb(); - const sources = await getRemoteSources(db); - // Populate the URL registry from cached config/token (no network on launch). - await Promise.all(sources.filter((s) => s.enabled).map((s) => hydrateRegistry(s))); - set({ sources, initialized: true }); - // The library's initial refresh may have run before the registry was hydrated, - // leaving remote artwork URLs unresolved — refresh once more now that it's ready. - if (sources.length > 0) { - await useLibraryStore.getState().refresh(); + init: () => { + if (get().initialized) return Promise.resolve(); + if (!initPromise) { + initPromise = (async () => { + const db = await openLibraryDb(); + const sources = await getRemoteSources(db); + // Populate the URL registry from cached config/token (no network on launch). + await Promise.all(sources.filter((s) => s.enabled).map((s) => hydrateRegistry(s))); + set({ sources, initialized: true }); + // The library's initial refresh may have run before the registry was hydrated, + // leaving remote artwork URLs unresolved — refresh once more now that it's ready. + if (sources.length > 0) { + await useLibraryStore.getState().refresh(); + } + })().catch((error) => { + initPromise = null; + throw error; + }); } + return initPromise; }, refresh: async () => {