mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-28 17:11:19 +02:00
remove unnecessary persistence
This commit is contained in:
@@ -57,4 +57,5 @@ vendor/kotlinaudio/kotlin-audio/.cxx/
|
|||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
DESIGN.md
|
DESIGN.md
|
||||||
docs/release/android.md
|
docs/release/android.md
|
||||||
|
docs/release/checklist.md
|
||||||
Untitled-1.md
|
Untitled-1.md
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Linking } from 'react-native';
|
import { useRootNavigationState } from 'expo-router';
|
||||||
import {
|
|
||||||
useGlobalSearchParams,
|
|
||||||
usePathname,
|
|
||||||
useRootNavigationState,
|
|
||||||
useSegments,
|
|
||||||
} from 'expo-router';
|
|
||||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
|
||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
|
||||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||||
import { useSearchStore } from '@/stores/searchStore';
|
import { useSearchStore } from '@/stores/searchStore';
|
||||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||||
@@ -24,78 +15,29 @@ import type { DbTrack } from '@/types/library';
|
|||||||
import {
|
import {
|
||||||
installMobileSessionPersistence,
|
installMobileSessionPersistence,
|
||||||
readPersistedMobileSession,
|
readPersistedMobileSession,
|
||||||
rememberStableHref,
|
|
||||||
setInitialStableHref,
|
|
||||||
} from './sessionPersistence';
|
} from './sessionPersistence';
|
||||||
import {
|
import { resolvePlaybackSession } from './sessionState';
|
||||||
normalizeStableHref,
|
|
||||||
resolvePlaybackSession,
|
|
||||||
shouldRestoreSavedRoute,
|
|
||||||
stableHrefForRoute,
|
|
||||||
} from './sessionState';
|
|
||||||
|
|
||||||
interface SessionLifecycleProps {
|
interface SessionLifecycleProps {
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateSavedHref(href: string): Promise<string> {
|
/**
|
||||||
const normalized = normalizeStableHref(href) ?? '/';
|
* Restores the playback session once, then owns session autosave.
|
||||||
const [pathname, query = ''] = normalized.split('?', 2);
|
*
|
||||||
const albumMatch = pathname.match(/^\/library\/album\/([^/]+)$/);
|
* Deliberately does *not* restore the route. Closing an app on mobile means
|
||||||
if (albumMatch) {
|
* "start me fresh"; leaving it in recents keeps the task alive, and React
|
||||||
const key = decodeURIComponent(albumMatch[1]);
|
* Navigation already holds that state in memory without our help. So a cold
|
||||||
const result = await AstraLibraryData.getAlbumDetail<DbTrack, Record<string, unknown>>(
|
* launch falls through to the router's own initial route (Home) and only the
|
||||||
key,
|
* queue, current track, and position come back off disk.
|
||||||
null,
|
*/
|
||||||
1
|
|
||||||
);
|
|
||||||
return result.summary ? normalized : '/library';
|
|
||||||
}
|
|
||||||
const artistMatch = pathname.match(
|
|
||||||
/^\/library\/artist\/([^/]+)(?:\/(?:albums|songs|appearances))?$/
|
|
||||||
);
|
|
||||||
if (artistMatch) {
|
|
||||||
const name = decodeURIComponent(artistMatch[1]);
|
|
||||||
const groupingMode = new URLSearchParams(query).get('credit') === '1'
|
|
||||||
? 'astra'
|
|
||||||
: useSettingsStore.getState().artistGroupingMode;
|
|
||||||
const result = await AstraLibraryData.getArtistDetail<DbTrack, Record<string, unknown>>(
|
|
||||||
name,
|
|
||||||
groupingMode,
|
|
||||||
'all',
|
|
||||||
null,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
return result.summary ? normalized : '/library';
|
|
||||||
}
|
|
||||||
const playlistMatch = pathname.match(/^\/library\/playlist\/(favorites|\d+)$/);
|
|
||||||
if (
|
|
||||||
playlistMatch &&
|
|
||||||
playlistMatch[1] !== 'favorites' &&
|
|
||||||
!usePlaylistStore.getState().playlists.some((playlist) => playlist.id === Number(playlistMatch[1]))
|
|
||||||
) {
|
|
||||||
return '/library';
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restores once, then owns stable-route tracking and session autosave. */
|
|
||||||
export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
||||||
const returnToTabs = useReturnToTabs();
|
// The navigator does not gate anything we restore, but the effect resets the
|
||||||
const pathname = usePathname();
|
// player overlay, so let the router mount its first screen before we run.
|
||||||
const segments = useSegments();
|
|
||||||
const params = useGlobalSearchParams<{
|
|
||||||
key?: string | string[];
|
|
||||||
name?: string | string[];
|
|
||||||
id?: string | string[];
|
|
||||||
credit?: string | string[];
|
|
||||||
}>();
|
|
||||||
const rootNavigationState = useRootNavigationState();
|
const rootNavigationState = useRootNavigationState();
|
||||||
const navigationKey = rootNavigationState?.key;
|
const navigationKey = rootNavigationState?.key;
|
||||||
const initialPathname = useRef(pathname);
|
|
||||||
const started = useRef(false);
|
const started = useRef(false);
|
||||||
const uninstallPersistence = useRef<(() => void) | null>(null);
|
const uninstallPersistence = useRef<(() => void) | null>(null);
|
||||||
const [hydrated, setHydrated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navigationKey || started.current) return;
|
if (!navigationKey || started.current) return;
|
||||||
@@ -106,9 +48,8 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
|||||||
const snapshotRead = readPersistedMobileSession();
|
const snapshotRead = readPersistedMobileSession();
|
||||||
let snapshot: Awaited<typeof snapshotRead> = null;
|
let snapshot: Awaited<typeof snapshotRead> = null;
|
||||||
try {
|
try {
|
||||||
const [loadedSnapshot, initialUrl] = await Promise.all([
|
const [loadedSnapshot] = await Promise.all([
|
||||||
snapshotRead,
|
snapshotRead,
|
||||||
Linking.getInitialURL(),
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await useLibraryStore.getState().initialize();
|
await useLibraryStore.getState().initialize();
|
||||||
try {
|
try {
|
||||||
@@ -151,21 +92,9 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
|||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const stableHref = await validateSavedHref(snapshot?.lastStableHref ?? '/');
|
|
||||||
setInitialStableHref(stableHref);
|
|
||||||
if (shouldRestoreSavedRoute(initialPathname.current, initialUrl) && stableHref !== '/') {
|
|
||||||
// Never `replace` here. A root-level saved route (`/settings/audio`,
|
|
||||||
// `/sources`, …) would overwrite `(tabs)` at index 0 and destroy the
|
|
||||||
// anchor the root stack is built around, so back would exit the app;
|
|
||||||
// an in-tab saved route would mint a second `(tabs)` instead.
|
|
||||||
returnToTabs(stableHref as never);
|
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
||||||
}
|
|
||||||
|
|
||||||
uninstallPersistence.current = installMobileSessionPersistence(
|
uninstallPersistence.current = installMobileSessionPersistence(
|
||||||
snapshot?.playback ?? null
|
snapshot?.playback ?? null
|
||||||
);
|
);
|
||||||
setHydrated(true);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
console.warn('[session] restore failed', error);
|
console.warn('[session] restore failed', error);
|
||||||
@@ -175,13 +104,8 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
|||||||
// The normal empty-session fallback below remains safe.
|
// The normal empty-session fallback below remains safe.
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setInitialStableHref(
|
// A failed restore must never leave autosave uninstalled.
|
||||||
normalizeStableHref(snapshot?.lastStableHref)
|
|
||||||
?? normalizeStableHref(initialPathname.current)
|
|
||||||
?? '/'
|
|
||||||
);
|
|
||||||
uninstallPersistence.current = installMobileSessionPersistence(snapshot?.playback ?? null);
|
uninstallPersistence.current = installMobileSessionPersistence(snapshot?.playback ?? null);
|
||||||
setHydrated(true);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) onReady();
|
if (!cancelled) onReady();
|
||||||
}
|
}
|
||||||
@@ -193,12 +117,7 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
|||||||
uninstallPersistence.current?.();
|
uninstallPersistence.current?.();
|
||||||
uninstallPersistence.current = null;
|
uninstallPersistence.current = null;
|
||||||
};
|
};
|
||||||
}, [navigationKey, onReady, returnToTabs]);
|
}, [navigationKey, onReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hydrated) return;
|
|
||||||
rememberStableHref(stableHrefForRoute(segments, pathname, params));
|
|
||||||
}, [hydrated, params, pathname, segments]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useQueueStore } from '@/stores/queueStore';
|
|||||||
import {
|
import {
|
||||||
MOBILE_SESSION_KIND,
|
MOBILE_SESSION_KIND,
|
||||||
MOBILE_SESSION_SCHEMA_VERSION,
|
MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
normalizeStableHref,
|
|
||||||
parseMobileSessionSnapshot,
|
parseMobileSessionSnapshot,
|
||||||
stringifyMobileSessionSnapshot,
|
stringifyMobileSessionSnapshot,
|
||||||
type MobileSessionSnapshotV1,
|
type MobileSessionSnapshotV1,
|
||||||
@@ -16,8 +15,6 @@ import {
|
|||||||
const STRUCTURAL_SAVE_DEBOUNCE_MS = 250;
|
const STRUCTURAL_SAVE_DEBOUNCE_MS = 250;
|
||||||
const POSITION_SAVE_THROTTLE_MS = 2000;
|
const POSITION_SAVE_THROTTLE_MS = 2000;
|
||||||
|
|
||||||
let lastStableHref = '/';
|
|
||||||
let scheduleStructuralSave: (() => void) | null = null;
|
|
||||||
let writeChain: Promise<void> = Promise.resolve();
|
let writeChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
export async function readPersistedMobileSession(): Promise<MobileSessionSnapshotV1 | null> {
|
export async function readPersistedMobileSession(): Promise<MobileSessionSnapshotV1 | null> {
|
||||||
@@ -37,17 +34,6 @@ function enqueueSnapshotWrite(snapshot: MobileSessionSnapshotV1): Promise<void>
|
|||||||
return writeChain;
|
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(
|
function currentSnapshot(
|
||||||
lastKnownPlayback: PlaybackSessionSnapshotV1 | null
|
lastKnownPlayback: PlaybackSessionSnapshotV1 | null
|
||||||
): { snapshot: MobileSessionSnapshotV1; playback: PlaybackSessionSnapshotV1 | null } {
|
): { snapshot: MobileSessionSnapshotV1; playback: PlaybackSessionSnapshotV1 | null } {
|
||||||
@@ -63,7 +49,6 @@ function currentSnapshot(
|
|||||||
kind: MOBILE_SESSION_KIND,
|
kind: MOBILE_SESSION_KIND,
|
||||||
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
lastStableHref,
|
|
||||||
playback,
|
playback,
|
||||||
},
|
},
|
||||||
playback,
|
playback,
|
||||||
@@ -122,8 +107,6 @@ export function installMobileSessionPersistence(
|
|||||||
scheduleSave(Math.max(0, POSITION_SAVE_THROTTLE_MS - elapsed));
|
scheduleSave(Math.max(0, POSITION_SAVE_THROTTLE_MS - elapsed));
|
||||||
};
|
};
|
||||||
|
|
||||||
scheduleStructuralSave = scheduleDebouncedSave;
|
|
||||||
|
|
||||||
const unsubscribePlayer = usePlayerStore.subscribe((state, previous) => {
|
const unsubscribePlayer = usePlayerStore.subscribe((state, previous) => {
|
||||||
if (
|
if (
|
||||||
state.playbackState !== previous.playbackState
|
state.playbackState !== previous.playbackState
|
||||||
@@ -155,12 +138,11 @@ export function installMobileSessionPersistence(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist route validation and queue normalization from hydration. The
|
// Persist the queue normalization from hydration. The snapshot fallback above
|
||||||
// snapshot fallback above gives a live native queue time to populate first.
|
// gives a live native queue time to populate first.
|
||||||
scheduleDebouncedSave();
|
scheduleDebouncedSave();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (scheduleStructuralSave === scheduleDebouncedSave) scheduleStructuralSave = null;
|
|
||||||
clearSaveTimer();
|
clearSaveTimer();
|
||||||
unsubscribePlayer();
|
unsubscribePlayer();
|
||||||
unsubscribeQueue();
|
unsubscribeQueue();
|
||||||
|
|||||||
@@ -3,15 +3,10 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
MOBILE_SESSION_KIND,
|
MOBILE_SESSION_KIND,
|
||||||
MOBILE_SESSION_SCHEMA_VERSION,
|
MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
hasExplicitLaunchDestination,
|
|
||||||
normalizeMobileSessionSnapshot,
|
normalizeMobileSessionSnapshot,
|
||||||
normalizeStableHref,
|
|
||||||
parseMobileSessionSnapshot,
|
parseMobileSessionSnapshot,
|
||||||
resolvePlaybackSession,
|
resolvePlaybackSession,
|
||||||
shouldRestoreSavedRoute,
|
|
||||||
stringifyMobileSessionSnapshot,
|
stringifyMobileSessionSnapshot,
|
||||||
stableHrefForRoute,
|
|
||||||
validateRestoredHref,
|
|
||||||
type MobileSessionSnapshotV1,
|
type MobileSessionSnapshotV1,
|
||||||
} from './sessionState.ts';
|
} from './sessionState.ts';
|
||||||
|
|
||||||
@@ -21,78 +16,11 @@ const tracks = [
|
|||||||
{ path: 'file:///c.flac', duration: 300, title: 'C' },
|
{ 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('/settings/playback'), '/settings/playback');
|
|
||||||
assert.equal(normalizeStableHref('/stats'), '/stats');
|
|
||||||
assert.equal(normalizeStableHref('/settings/lyrics'), '/settings/lyrics');
|
|
||||||
assert.equal(normalizeStableHref('/settings/troubleshooting'), '/settings/troubleshooting');
|
|
||||||
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', () => {
|
test('round trips a normalized versioned snapshot', () => {
|
||||||
const snapshot: MobileSessionSnapshotV1 = {
|
const snapshot: MobileSessionSnapshotV1 = {
|
||||||
kind: MOBILE_SESSION_KIND,
|
kind: MOBILE_SESSION_KIND,
|
||||||
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
savedAt: 123,
|
savedAt: 123,
|
||||||
lastStableHref: '/library/playlist/7',
|
|
||||||
playback: {
|
playback: {
|
||||||
queuePaths: ['file:///a.flac', 'file:///b.flac'],
|
queuePaths: ['file:///a.flac', 'file:///b.flac'],
|
||||||
activeIndex: 1,
|
activeIndex: 1,
|
||||||
@@ -115,7 +43,6 @@ test('rejects unknown versions and safely defaults corrupt fields', () => {
|
|||||||
kind: MOBILE_SESSION_KIND,
|
kind: MOBILE_SESSION_KIND,
|
||||||
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
savedAt: -5,
|
savedAt: -5,
|
||||||
lastStableHref: '/eq/import?data=large',
|
|
||||||
playback: {
|
playback: {
|
||||||
queuePaths: ['file:///a.flac', 'file:///b.flac'],
|
queuePaths: ['file:///a.flac', 'file:///b.flac'],
|
||||||
activeIndex: 999,
|
activeIndex: 999,
|
||||||
@@ -127,7 +54,6 @@ test('rejects unknown versions and safely defaults corrupt fields', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(normalized?.savedAt, 0);
|
assert.equal(normalized?.savedAt, 0);
|
||||||
assert.equal(normalized?.lastStableHref, '/');
|
|
||||||
assert.deepEqual(normalized?.playback, {
|
assert.deepEqual(normalized?.playback, {
|
||||||
queuePaths: ['file:///a.flac', 'file:///b.flac'],
|
queuePaths: ['file:///a.flac', 'file:///b.flac'],
|
||||||
activeIndex: 1,
|
activeIndex: 1,
|
||||||
@@ -139,12 +65,42 @@ test('rejects unknown versions and safely defaults corrupt fields', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the queue from snapshots written before route restore was removed', () => {
|
||||||
|
// Builds before this change stored the last route alongside the queue at the
|
||||||
|
// same schema version. Those snapshots must still restore playback on upgrade
|
||||||
|
// — bumping the version instead of ignoring the field would wipe every
|
||||||
|
// existing user's queue on first launch.
|
||||||
|
const legacy = normalizeMobileSessionSnapshot({
|
||||||
|
kind: MOBILE_SESSION_KIND,
|
||||||
|
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
|
savedAt: 123,
|
||||||
|
lastStableHref: '/library/artist/Radiohead',
|
||||||
|
playback: {
|
||||||
|
queuePaths: ['file:///a.flac', 'file:///b.flac'],
|
||||||
|
activeIndex: 1,
|
||||||
|
position: 80,
|
||||||
|
shuffle: true,
|
||||||
|
repeat: 'all',
|
||||||
|
originalOrderPaths: ['file:///b.flac', 'file:///a.flac'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(legacy?.playback?.activeIndex, 1);
|
||||||
|
assert.equal(legacy?.playback?.position, 80);
|
||||||
|
assert.equal(legacy?.playback?.shuffle, true);
|
||||||
|
assert.deepEqual(Object.keys(legacy ?? {}).sort(), [
|
||||||
|
'kind',
|
||||||
|
'playback',
|
||||||
|
'savedAt',
|
||||||
|
'schemaVersion',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('accepts legacy playback snapshots without a source and rejects malformed sources', () => {
|
test('accepts legacy playback snapshots without a source and rejects malformed sources', () => {
|
||||||
const legacy = normalizeMobileSessionSnapshot({
|
const legacy = normalizeMobileSessionSnapshot({
|
||||||
kind: MOBILE_SESSION_KIND,
|
kind: MOBILE_SESSION_KIND,
|
||||||
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
savedAt: 123,
|
savedAt: 123,
|
||||||
lastStableHref: '/',
|
|
||||||
playback: {
|
playback: {
|
||||||
queuePaths: ['file:///a.flac'],
|
queuePaths: ['file:///a.flac'],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
|
|||||||
+6
-183
@@ -6,7 +6,6 @@ export const MOBILE_SESSION_SCHEMA_VERSION = 1;
|
|||||||
|
|
||||||
const MAX_QUEUE_ITEMS = 100_000;
|
const MAX_QUEUE_ITEMS = 100_000;
|
||||||
const MAX_PATH_LENGTH = 8192;
|
const MAX_PATH_LENGTH = 8192;
|
||||||
const MAX_HREF_LENGTH = 4096;
|
|
||||||
const MAX_POSITION_SECONDS = 30 * 24 * 60 * 60;
|
const MAX_POSITION_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
export type SessionRepeatMode = 'none' | 'one' | 'all';
|
export type SessionRepeatMode = 'none' | 'one' | 'all';
|
||||||
@@ -26,7 +25,6 @@ export interface MobileSessionSnapshotV1 {
|
|||||||
kind: typeof MOBILE_SESSION_KIND;
|
kind: typeof MOBILE_SESSION_KIND;
|
||||||
schemaVersion: typeof MOBILE_SESSION_SCHEMA_VERSION;
|
schemaVersion: typeof MOBILE_SESSION_SCHEMA_VERSION;
|
||||||
savedAt: number;
|
savedAt: number;
|
||||||
lastStableHref: string;
|
|
||||||
playback: PlaybackSessionSnapshotV1 | null;
|
playback: PlaybackSessionSnapshotV1 | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,35 +43,6 @@ export interface ResolvedPlaybackSession<T extends SessionTrackLike> {
|
|||||||
source: PlaybackSource | null;
|
source: PlaybackSource | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
|
||||||
'/stats',
|
|
||||||
'/settings/appearance',
|
|
||||||
'/settings/library',
|
|
||||||
'/settings/audio',
|
|
||||||
'/settings/playback',
|
|
||||||
'/settings/services',
|
|
||||||
'/settings/lyrics',
|
|
||||||
'/settings/experimental',
|
|
||||||
'/settings/troubleshooting',
|
|
||||||
'/settings/info',
|
|
||||||
'/settings/haptics-lab',
|
|
||||||
'/sources',
|
|
||||||
'/lastfm',
|
|
||||||
'/desktop-remote',
|
|
||||||
'/desktop-sync',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -116,157 +85,6 @@ function normalizeRepeat(value: unknown): SessionRepeatMode {
|
|||||||
return value === 'one' || value === 'all' ? value : 'none';
|
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 {
|
export function normalizePlaybackSession(value: unknown): PlaybackSessionSnapshotV1 | null {
|
||||||
if (!isPlainRecord(value)) return null;
|
if (!isPlainRecord(value)) return null;
|
||||||
const queuePaths = normalizePathArray(value.queuePaths);
|
const queuePaths = normalizePathArray(value.queuePaths);
|
||||||
@@ -291,6 +109,12 @@ export function normalizePlaybackSession(value: unknown): PlaybackSessionSnapsho
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads named fields only, so unknown ones are dropped. Snapshots written before
|
||||||
|
* route restore was removed still carry a `lastStableHref` we deliberately
|
||||||
|
* ignore — which is exactly why the schema version stays at 1. Bumping it would
|
||||||
|
* make this return null for every existing install and discard their queue.
|
||||||
|
*/
|
||||||
export function normalizeMobileSessionSnapshot(value: unknown): MobileSessionSnapshotV1 | null {
|
export function normalizeMobileSessionSnapshot(value: unknown): MobileSessionSnapshotV1 | null {
|
||||||
if (!isPlainRecord(value)) return null;
|
if (!isPlainRecord(value)) return null;
|
||||||
if (value.kind !== MOBILE_SESSION_KIND || value.schemaVersion !== MOBILE_SESSION_SCHEMA_VERSION) {
|
if (value.kind !== MOBILE_SESSION_KIND || value.schemaVersion !== MOBILE_SESSION_SCHEMA_VERSION) {
|
||||||
@@ -301,7 +125,6 @@ export function normalizeMobileSessionSnapshot(value: unknown): MobileSessionSna
|
|||||||
kind: MOBILE_SESSION_KIND,
|
kind: MOBILE_SESSION_KIND,
|
||||||
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
schemaVersion: MOBILE_SESSION_SCHEMA_VERSION,
|
||||||
savedAt: Math.max(0, finiteNumber(value.savedAt)),
|
savedAt: Math.max(0, finiteNumber(value.savedAt)),
|
||||||
lastStableHref: normalizeStableHref(value.lastStableHref) ?? '/',
|
|
||||||
playback: normalizePlaybackSession(value.playback),
|
playback: normalizePlaybackSession(value.playback),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user