mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
persistence
This commit is contained in:
@@ -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: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: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: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",
|
"typecheck": "tsc --noEmit",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-5
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { AppState, StyleSheet, View } from 'react-native';
|
import { AppState, StyleSheet, View } from 'react-native';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
@@ -45,6 +45,7 @@ import { useThemeStore } from '@/stores/themeStore';
|
|||||||
import { useOnboardingStore } from '@/stores/onboardingStore';
|
import { useOnboardingStore } from '@/stores/onboardingStore';
|
||||||
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
||||||
import { useTheme } from '@/theme/themed';
|
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
|
// 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
|
// (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 onboardingLoaded = useOnboardingStore((s) => s.loaded);
|
||||||
const onboardingComplete = useOnboardingStore((s) => s.onboardingComplete);
|
const onboardingComplete = useOnboardingStore((s) => s.onboardingComplete);
|
||||||
const theme = useTheme();
|
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(() => {
|
useEffect(() => {
|
||||||
if (ready) {
|
if (splashReady) {
|
||||||
void SplashScreen.hideAsync().catch(() => {});
|
void SplashScreen.hideAsync().catch(() => {});
|
||||||
}
|
}
|
||||||
}, [ready]);
|
}, [splashReady]);
|
||||||
|
|
||||||
// Eager library init: SQLite open + initial reads are tens of ms, and the
|
// Eager library init: SQLite open + initial reads are tens of ms, and the
|
||||||
// Library tab + playback adapters get data immediately. EQ + audio settings load
|
// 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));
|
.catch((err) => console.error('[lastfm] init failed', err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!ready) return null;
|
if (!renderReady) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
|
||||||
@@ -361,6 +366,7 @@ export default function RootLayout() {
|
|||||||
>
|
>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{onboardingComplete ? <SessionLifecycle onReady={handleSessionReady} /> : null}
|
||||||
{onboardingComplete ? (
|
{onboardingComplete ? (
|
||||||
<>
|
<>
|
||||||
{/* Always-mounted player overlay (store-gated); open/close is a pure
|
{/* Always-mounted player overlay (store-gated); open/close is a pure
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type { PlaybackState, Track } from '@/types/audio';
|
|||||||
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
|
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
|
||||||
import { useQueueStore } from '@/stores/queueStore';
|
import { useQueueStore } from '@/stores/queueStore';
|
||||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
|
import type {
|
||||||
|
PlaybackSessionSnapshotV1,
|
||||||
|
ResolvedPlaybackSession,
|
||||||
|
} from '@/session/sessionState';
|
||||||
|
import { materializePlaybackQueue } from '@/session/playbackMaterialization';
|
||||||
import { setupPlayer } from './trackPlayer';
|
import { setupPlayer } from './trackPlayer';
|
||||||
import { SAMPLE_TRACKS, rntpToTrack, toRntpTrack } from './sampleTracks';
|
import { SAMPLE_TRACKS, rntpToTrack, toRntpTrack } from './sampleTracks';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +38,7 @@ setQueueLoadErrorHandler(() => {
|
|||||||
// off and the upcoming tail restored to its original sequence (mirrors desktop's
|
// off and the upcoming tail restored to its original sequence (mirrors desktop's
|
||||||
// autoQueue + shuffledAutoIndices split, but over RNTP's flat native queue).
|
// autoQueue + shuffledAutoIndices split, but over RNTP's flat native queue).
|
||||||
let originalOrder: string[] | null = null;
|
let originalOrder: string[] | null = null;
|
||||||
|
let restoredMaterializationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
const NEXT_REPEAT: Record<RepeatModeStr, RepeatModeStr> = {
|
const NEXT_REPEAT: Record<RepeatModeStr, RepeatModeStr> = {
|
||||||
none: 'all',
|
none: 'all',
|
||||||
@@ -70,6 +76,10 @@ function rntpTrackId(track: RntpTrack): string {
|
|||||||
return String(track.id ?? track.url);
|
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 {
|
function setOptimisticTrack(track: RntpTrack | undefined, playbackState?: PlaybackState): void {
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
const current = rntpToTrack(track);
|
const current = rntpToTrack(track);
|
||||||
@@ -101,6 +111,9 @@ async function getQueueSnapshot(): Promise<{ queue: RntpTrack[]; activeIndex: nu
|
|||||||
const store = useQueueStore.getState();
|
const store = useQueueStore.getState();
|
||||||
|
|
||||||
if (store.hasSnapshot) {
|
if (store.hasSnapshot) {
|
||||||
|
if (usePlayerStore.getState().restoredSessionPending) {
|
||||||
|
return { queue: store.tracks, activeIndex: store.activeIndex };
|
||||||
|
}
|
||||||
await store.refreshActiveIndex();
|
await store.refreshActiveIndex();
|
||||||
const { tracks, activeIndex } = useQueueStore.getState();
|
const { tracks, activeIndex } = useQueueStore.getState();
|
||||||
return { queue: tracks, activeIndex };
|
return { queue: tracks, activeIndex };
|
||||||
@@ -121,6 +134,22 @@ function syncOriginalOrderFromMirrorIfUnshuffled(): void {
|
|||||||
if (hasSnapshot) originalOrder = tracks.map(rntpTrackId);
|
if (hasSnapshot) originalOrder = tracks.map(rntpTrackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneOriginalOrderToMirror(): void {
|
||||||
|
if (!originalOrder) return;
|
||||||
|
const remaining = new Map<string, number>();
|
||||||
|
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 {
|
function selectPhonePlaybackTarget(): void {
|
||||||
void usePlaybackTargetStore.getState().setTarget('phone');
|
void usePlaybackTargetStore.getState().setTarget('phone');
|
||||||
}
|
}
|
||||||
@@ -142,11 +171,129 @@ function shuffleArray<T>(items: readonly T[]): T[] {
|
|||||||
* foreground. The stored repeat mode is re-applied after a (re)setup so a
|
* foreground. The stored repeat mode is re-applied after a (re)setup so a
|
||||||
* deferred init keeps the user's choice.
|
* deferred init keeps the user's choice.
|
||||||
*/
|
*/
|
||||||
async function ensurePlayerReady(options: { allowBackgroundSetup?: boolean } = {}): Promise<void> {
|
async function materializeRestoredSession(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
await setupPlayer(options);
|
await setupPlayer(options);
|
||||||
|
if (options.materializeRestored !== false) await materializeRestoredSession();
|
||||||
await TrackPlayer.setRepeatMode(toRntpRepeat(usePlayerStore.getState().repeat));
|
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<Track> | 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<boolean> {
|
||||||
|
try {
|
||||||
|
return Boolean(await TrackPlayer.getActiveTrack());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Replace the queue with the given tracks and start playing at startIndex. */
|
/** Replace the queue with the given tracks and start playing at startIndex. */
|
||||||
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
|
export async function playTracks(tracks: Track[], startIndex = 0): Promise<void> {
|
||||||
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false });
|
return playTracksInternal(tracks, startIndex, { allowBackgroundSetup: false });
|
||||||
@@ -164,7 +311,8 @@ async function playTracksInternal(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
selectPhonePlaybackTarget();
|
selectPhonePlaybackTarget();
|
||||||
await ensurePlayerReady(options);
|
discardPendingRestoredSession();
|
||||||
|
await ensurePlayerReady({ ...options, materializeRestored: false });
|
||||||
originalOrder = tracks.map((t) => t.id);
|
originalOrder = tracks.map((t) => t.id);
|
||||||
// Honor an already-on shuffle by scrambling the upcoming tail of the new
|
// 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.
|
// 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<void> {
|
export async function shuffleTracks(tracks: Track[]): Promise<void> {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
selectPhonePlaybackTarget();
|
selectPhonePlaybackTarget();
|
||||||
await ensurePlayerReady();
|
discardPendingRestoredSession();
|
||||||
|
await ensurePlayerReady({ materializeRestored: false });
|
||||||
originalOrder = tracks.map((t) => t.id);
|
originalOrder = tracks.map((t) => t.id);
|
||||||
usePlayerStore.getState().setShuffle(true);
|
usePlayerStore.getState().setShuffle(true);
|
||||||
const queueTracks = shuffleArray(tracks).map(toRntpTrack);
|
const queueTracks = shuffleArray(tracks).map(toRntpTrack);
|
||||||
@@ -208,7 +357,8 @@ export async function shuffleTracks(tracks: Track[]): Promise<void> {
|
|||||||
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
|
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
|
||||||
export async function playSample(): Promise<void> {
|
export async function playSample(): Promise<void> {
|
||||||
selectPhonePlaybackTarget();
|
selectPhonePlaybackTarget();
|
||||||
await ensurePlayerReady();
|
discardPendingRestoredSession();
|
||||||
|
await ensurePlayerReady({ materializeRestored: false });
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
const queue = await TrackPlayer.getQueue();
|
const queue = await TrackPlayer.getQueue();
|
||||||
if (queue.length === 0) {
|
if (queue.length === 0) {
|
||||||
@@ -256,6 +406,7 @@ export async function pause(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function seekTo(seconds: number): Promise<void> {
|
export async function seekTo(seconds: number): Promise<void> {
|
||||||
|
await ensurePlayerReady();
|
||||||
const duration = usePlayerStore.getState().duration;
|
const duration = usePlayerStore.getState().duration;
|
||||||
usePlayerStore.getState().setPendingSeek(seconds);
|
usePlayerStore.getState().setPendingSeek(seconds);
|
||||||
usePlayerStore.getState().setProgress(seconds, duration);
|
usePlayerStore.getState().setProgress(seconds, duration);
|
||||||
@@ -279,6 +430,7 @@ export async function togglePlay(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function skipToNext(): Promise<void> {
|
export async function skipToNext(): Promise<void> {
|
||||||
|
await ensurePlayerReady();
|
||||||
const { tracks, activeIndex } = useQueueStore.getState();
|
const { tracks, activeIndex } = useQueueStore.getState();
|
||||||
const nextIndex = activeIndex >= 0 ? activeIndex + 1 : -1;
|
const nextIndex = activeIndex >= 0 ? activeIndex + 1 : -1;
|
||||||
if (nextIndex >= 0 && nextIndex < tracks.length) {
|
if (nextIndex >= 0 && nextIndex < tracks.length) {
|
||||||
@@ -295,6 +447,7 @@ export async function skipToNext(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function skipToPrevious(): Promise<void> {
|
export async function skipToPrevious(): Promise<void> {
|
||||||
|
await ensurePlayerReady();
|
||||||
const { tracks, activeIndex } = useQueueStore.getState();
|
const { tracks, activeIndex } = useQueueStore.getState();
|
||||||
const previousIndex = activeIndex > 0 ? activeIndex - 1 : -1;
|
const previousIndex = activeIndex > 0 ? activeIndex - 1 : -1;
|
||||||
if (previousIndex >= 0 && previousIndex < tracks.length) {
|
if (previousIndex >= 0 && previousIndex < tracks.length) {
|
||||||
@@ -445,6 +598,7 @@ interface QueueRemoveOptions {
|
|||||||
|
|
||||||
/** Replace everything after the current track with `upcoming` (in order). */
|
/** Replace everything after the current track with `upcoming` (in order). */
|
||||||
export async function setUpcoming(upcoming: RntpTrack[]): Promise<void> {
|
export async function setUpcoming(upcoming: RntpTrack[]): Promise<void> {
|
||||||
|
await ensurePlayerReady();
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
await TrackPlayer.removeUpcomingTracks();
|
await TrackPlayer.removeUpcomingTracks();
|
||||||
useQueueStore.getState().replaceUpcoming(upcoming);
|
useQueueStore.getState().replaceUpcoming(upcoming);
|
||||||
@@ -458,6 +612,7 @@ export async function setUpcoming(upcoming: RntpTrack[]): Promise<void> {
|
|||||||
/** Move a queued item by absolute RNTP queue index. */
|
/** Move a queued item by absolute RNTP queue index. */
|
||||||
export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex: number): Promise<void> {
|
export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex: number): Promise<void> {
|
||||||
if (fromAbsoluteIndex === toAbsoluteIndex) return;
|
if (fromAbsoluteIndex === toAbsoluteIndex) return;
|
||||||
|
await ensurePlayerReady();
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
await TrackPlayer.move(fromAbsoluteIndex, toAbsoluteIndex);
|
await TrackPlayer.move(fromAbsoluteIndex, toAbsoluteIndex);
|
||||||
useQueueStore.getState().moveItem(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. */
|
/** Jump to (and play) an absolute queue index. */
|
||||||
export async function jumpToQueueIndex(index: number): Promise<void> {
|
export async function jumpToQueueIndex(index: number): Promise<void> {
|
||||||
selectPhonePlaybackTarget();
|
selectPhonePlaybackTarget();
|
||||||
|
await ensurePlayerReady();
|
||||||
// Mid-fill, the tapped row may not be in the native queue yet (or may sit at
|
// 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,
|
// a shifted native index while the head is still prepending) — translate,
|
||||||
// waiting out the fill only when the target isn't loaded.
|
// waiting out the fill only when the target isn't loaded.
|
||||||
@@ -519,12 +675,13 @@ export async function removeFromQueue(
|
|||||||
absoluteIndex: number,
|
absoluteIndex: number,
|
||||||
options: QueueRemoveOptions = {}
|
options: QueueRemoveOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await ensurePlayerReady();
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
await TrackPlayer.remove(absoluteIndex);
|
await TrackPlayer.remove(absoluteIndex);
|
||||||
if (options.updateMirror !== false) {
|
if (options.updateMirror !== false) {
|
||||||
useQueueStore.getState().removeIndices([absoluteIndex]);
|
useQueueStore.getState().removeIndices([absoluteIndex]);
|
||||||
}
|
}
|
||||||
syncOriginalOrderFromMirrorIfUnshuffled();
|
pruneOriginalOrderToMirror();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a group of tracks at absolute queue indices. */
|
/** Remove a group of tracks at absolute queue indices. */
|
||||||
@@ -533,10 +690,11 @@ export async function removeManyFromQueue(
|
|||||||
options: QueueRemoveOptions = {}
|
options: QueueRemoveOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (absoluteIndices.length === 0) return;
|
if (absoluteIndices.length === 0) return;
|
||||||
|
await ensurePlayerReady();
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
await TrackPlayer.remove(absoluteIndices);
|
await TrackPlayer.remove(absoluteIndices);
|
||||||
if (options.updateMirror !== false) {
|
if (options.updateMirror !== false) {
|
||||||
useQueueStore.getState().removeIndices(absoluteIndices);
|
useQueueStore.getState().removeIndices(absoluteIndices);
|
||||||
}
|
}
|
||||||
syncOriginalOrderFromMirrorIfUnshuffled();
|
pruneOriginalOrderToMirror();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from 'react-native-track-player';
|
} from 'react-native-track-player';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
|
import { useQueueStore } from '@/stores/queueStore';
|
||||||
import type { PlaybackState, Track } from '@/types/audio';
|
import type { PlaybackState, Track } from '@/types/audio';
|
||||||
import { rntpToTrack } from './sampleTracks';
|
import { rntpToTrack } from './sampleTracks';
|
||||||
import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync';
|
import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync';
|
||||||
@@ -103,7 +104,10 @@ export function usePlaybackSync(): void {
|
|||||||
path: null,
|
path: null,
|
||||||
state: 'stopped',
|
state: 'stopped',
|
||||||
});
|
});
|
||||||
|
const restoredPlaybackSyncHeld = useRef(false);
|
||||||
const rawPlaybackState = mapState(playbackState.state);
|
const rawPlaybackState = mapState(playbackState.state);
|
||||||
|
const restoredSessionPending = usePlayerStore((s) => s.restoredSessionPending);
|
||||||
|
const restoredTrack = usePlayerStore((s) => s.currentTrack);
|
||||||
|
|
||||||
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
|
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
|
||||||
const setProgress = usePlayerStore((s) => s.setProgress);
|
const setProgress = usePlayerStore((s) => s.setProgress);
|
||||||
@@ -112,6 +116,7 @@ export function usePlaybackSync(): void {
|
|||||||
const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
|
const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (restoredSessionPending && !activeTrack) return;
|
||||||
const nextTrack = activeTrack ? rntpToTrack(activeTrack) : null;
|
const nextTrack = activeTrack ? rntpToTrack(activeTrack) : null;
|
||||||
const prevTrack = usePlayerStore.getState().currentTrack;
|
const prevTrack = usePlayerStore.getState().currentTrack;
|
||||||
if (prevTrack?.path !== nextTrack?.path) {
|
if (prevTrack?.path !== nextTrack?.path) {
|
||||||
@@ -122,9 +127,10 @@ export function usePlaybackSync(): void {
|
|||||||
// currentTrack subscriber) when nothing actually changed.
|
// currentTrack subscriber) when nothing actually changed.
|
||||||
if (sameTrack(prevTrack, nextTrack)) return;
|
if (sameTrack(prevTrack, nextTrack)) return;
|
||||||
setCurrentTrack(nextTrack);
|
setCurrentTrack(nextTrack);
|
||||||
}, [activeTrack, setCurrentTrack]);
|
}, [activeTrack, restoredSessionPending, setCurrentTrack]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (restoredSessionPending) return;
|
||||||
const pendingSeek = usePlayerStore.getState().pendingSeek;
|
const pendingSeek = usePlayerStore.getState().pendingSeek;
|
||||||
if (pendingSeek) {
|
if (pendingSeek) {
|
||||||
const acknowledged = Math.abs(progress.position - pendingSeek.target) <= SEEK_ACK_EPS;
|
const acknowledged = Math.abs(progress.position - pendingSeek.target) <= SEEK_ACK_EPS;
|
||||||
@@ -133,9 +139,20 @@ export function usePlaybackSync(): void {
|
|||||||
usePlayerStore.getState().clearPendingSeek();
|
usePlayerStore.getState().clearPendingSeek();
|
||||||
}
|
}
|
||||||
setProgress(progress.position, progress.duration);
|
setProgress(progress.position, progress.duration);
|
||||||
}, [progress.position, progress.duration, setProgress]);
|
}, [progress.position, progress.duration, restoredSessionPending, setProgress]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 activeTrackPath = activeTrack ? rntpToTrack(activeTrack).path : null;
|
||||||
const mappedPlaybackState = resolveTransientLoading(
|
const mappedPlaybackState = resolveTransientLoading(
|
||||||
rawPlaybackState,
|
rawPlaybackState,
|
||||||
@@ -161,7 +178,17 @@ export function usePlaybackSync(): void {
|
|||||||
state: mappedPlaybackState,
|
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
|
// 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
|
// — 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
|
// 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).
|
// pushed from here at all (the MediaSession extrapolates position between those events).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const track = activeTrack ? rntpToTrack(activeTrack) : null;
|
const track = activeTrack
|
||||||
|
? rntpToTrack(activeTrack)
|
||||||
|
: restoredSessionPending
|
||||||
|
? restoredTrack
|
||||||
|
: null;
|
||||||
const mappedPlaybackState = resolveTransientLoading(
|
const mappedPlaybackState = resolveTransientLoading(
|
||||||
rawPlaybackState,
|
restoredSessionPending ? 'paused' : rawPlaybackState,
|
||||||
track?.path ?? null,
|
track?.path ?? null,
|
||||||
stablePlayback.current
|
stablePlayback.current
|
||||||
);
|
);
|
||||||
@@ -182,9 +213,10 @@ export function usePlaybackSync(): void {
|
|||||||
mappedPlaybackState,
|
mappedPlaybackState,
|
||||||
buildWidgetRecentItems(recentlyPlayedTracks, track?.path),
|
buildWidgetRecentItems(recentlyPlayedTracks, track?.path),
|
||||||
);
|
);
|
||||||
}, [activeTrack, rawPlaybackState, recentlyPlayedTracks]);
|
}, [activeTrack, rawPlaybackState, recentlyPlayedTracks, restoredSessionPending, restoredTrack]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (restoredSessionPending) return;
|
||||||
// Use the identity path (subsonic://|jellyfin:// for remote; the file URI for
|
// Use the identity path (subsonic://|jellyfin:// for remote; the file URI for
|
||||||
// local) so history matches `tracks.path` — activeTrack.url is the stream URL.
|
// local) so history matches `tracks.path` — activeTrack.url is the stream URL.
|
||||||
const path = activeTrack ? rntpToTrack(activeTrack).path : null;
|
const path = activeTrack ? rntpToTrack(activeTrack).path : null;
|
||||||
@@ -232,5 +264,5 @@ export function usePlaybackSync(): void {
|
|||||||
void recordTrackPlayed(path).catch((err) => {
|
void recordTrackPlayed(path).catch((err) => {
|
||||||
console.warn('[library] playback history update failed', err);
|
console.warn('[library] playback history update failed', err);
|
||||||
});
|
});
|
||||||
}, [activeTrack, rawPlaybackState, progress.position, recordTrackPlayed]);
|
}, [activeTrack, rawPlaybackState, progress.position, recordTrackPlayed, restoredSessionPending]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player';
|
import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player';
|
||||||
import { useQueueStore } from '@/stores/queueStore';
|
import { useQueueStore } from '@/stores/queueStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { nativeIndexToAbsolute } from '@/audio/queueLoader';
|
import { nativeIndexToAbsolute } from '@/audio/queueLoader';
|
||||||
|
|
||||||
export interface QueueSnapshot {
|
export interface QueueSnapshot {
|
||||||
@@ -21,15 +22,18 @@ export function useQueue(active: boolean): QueueSnapshot {
|
|||||||
const refresh = useQueueStore((s) => s.refreshFromNative);
|
const refresh = useQueueStore((s) => s.refreshFromNative);
|
||||||
const refreshActiveIndex = useQueueStore((s) => s.refreshActiveIndex);
|
const refreshActiveIndex = useQueueStore((s) => s.refreshActiveIndex);
|
||||||
const setActiveIndex = useQueueStore((s) => s.setActiveIndex);
|
const setActiveIndex = useQueueStore((s) => s.setActiveIndex);
|
||||||
|
const restoredSessionPending = usePlayerStore((s) => s.restoredSessionPending);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
if (restoredSessionPending) return;
|
||||||
if (hasSnapshot) void refreshActiveIndex();
|
if (hasSnapshot) void refreshActiveIndex();
|
||||||
else void refresh();
|
else void refresh();
|
||||||
}, [active, hasSnapshot, refresh, refreshActiveIndex]);
|
}, [active, hasSnapshot, refresh, refreshActiveIndex, restoredSessionPending]);
|
||||||
|
|
||||||
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => {
|
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
if (restoredSessionPending) return;
|
||||||
if (hasSnapshot) {
|
if (hasSnapshot) {
|
||||||
// Event indices are native — shifted while a chunked load is prepending the head.
|
// Event indices are native — shifted while a chunked load is prepending the head.
|
||||||
setActiveIndex(event.index != null ? nativeIndexToAbsolute(event.index) : -1);
|
setActiveIndex(event.index != null ? nativeIndexToAbsolute(event.index) : -1);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ interface PlayerStore {
|
|||||||
// Field names mirror desktop playerStore so queue/transport logic stays consistent.
|
// Field names mirror desktop playerStore so queue/transport logic stays consistent.
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
repeat: RepeatMode;
|
repeat: RepeatMode;
|
||||||
|
/** Restored JS queue exists but has not been loaded into RNTP yet. */
|
||||||
|
restoredSessionPending: boolean;
|
||||||
|
|
||||||
setCurrentTrack: (track: Track | null) => void;
|
setCurrentTrack: (track: Track | null) => void;
|
||||||
setPlaybackState: (state: PlaybackState) => void;
|
setPlaybackState: (state: PlaybackState) => void;
|
||||||
@@ -35,6 +37,7 @@ interface PlayerStore {
|
|||||||
setMuted: (isMuted: boolean) => void;
|
setMuted: (isMuted: boolean) => void;
|
||||||
setShuffle: (shuffle: boolean) => void;
|
setShuffle: (shuffle: boolean) => void;
|
||||||
setRepeat: (repeat: RepeatMode) => void;
|
setRepeat: (repeat: RepeatMode) => void;
|
||||||
|
setRestoredSessionPending: (pending: boolean) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ export const usePlayerStore = create<PlayerStore>((set) => ({
|
|||||||
isMuted: false,
|
isMuted: false,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
repeat: 'none',
|
repeat: 'none',
|
||||||
|
restoredSessionPending: false,
|
||||||
|
|
||||||
setCurrentTrack: (currentTrack) => set({ currentTrack }),
|
setCurrentTrack: (currentTrack) => set({ currentTrack }),
|
||||||
setPlaybackState: (playbackState) => set({ playbackState }),
|
setPlaybackState: (playbackState) => set({ playbackState }),
|
||||||
@@ -58,6 +62,7 @@ export const usePlayerStore = create<PlayerStore>((set) => ({
|
|||||||
setMuted: (isMuted) => set({ isMuted }),
|
setMuted: (isMuted) => set({ isMuted }),
|
||||||
setShuffle: (shuffle) => set({ shuffle }),
|
setShuffle: (shuffle) => set({ shuffle }),
|
||||||
setRepeat: (repeat) => set({ repeat }),
|
setRepeat: (repeat) => set({ repeat }),
|
||||||
|
setRestoredSessionPending: (restoredSessionPending) => set({ restoredSessionPending }),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
@@ -65,5 +70,6 @@ export const usePlayerStore = create<PlayerStore>((set) => ({
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
pendingSeek: null,
|
pendingSeek: null,
|
||||||
|
restoredSessionPending: false,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -127,23 +127,33 @@ interface RemoteSourcesStore {
|
|||||||
syncAll: () => Promise<void>;
|
syncAll: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
|
export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
|
||||||
sources: [],
|
sources: [],
|
||||||
initialized: false,
|
initialized: false,
|
||||||
progressById: {},
|
progressById: {},
|
||||||
|
|
||||||
init: async () => {
|
init: () => {
|
||||||
if (get().initialized) return;
|
if (get().initialized) return Promise.resolve();
|
||||||
const db = await openLibraryDb();
|
if (!initPromise) {
|
||||||
const sources = await getRemoteSources(db);
|
initPromise = (async () => {
|
||||||
// Populate the URL registry from cached config/token (no network on launch).
|
const db = await openLibraryDb();
|
||||||
await Promise.all(sources.filter((s) => s.enabled).map((s) => hydrateRegistry(s)));
|
const sources = await getRemoteSources(db);
|
||||||
set({ sources, initialized: true });
|
// Populate the URL registry from cached config/token (no network on launch).
|
||||||
// The library's initial refresh may have run before the registry was hydrated,
|
await Promise.all(sources.filter((s) => s.enabled).map((s) => hydrateRegistry(s)));
|
||||||
// leaving remote artwork URLs unresolved — refresh once more now that it's ready.
|
set({ sources, initialized: true });
|
||||||
if (sources.length > 0) {
|
// The library's initial refresh may have run before the registry was hydrated,
|
||||||
await useLibraryStore.getState().refresh();
|
// 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 () => {
|
refresh: async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user