Files
astra-mobile/src/session/sessionState.ts
T
2026-07-15 20:02:10 -04:00

371 lines
12 KiB
TypeScript

import { normalizePlaybackSource } from '../audio/playbackSource.ts';
import type { PlaybackSource } from '../types/audio.ts';
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[];
/** Optional for backward compatibility with version-1 snapshots written before queue sources. */
source?: PlaybackSource | null;
}
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[];
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',
'/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> {
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,
source: normalizePlaybackSource(value.source),
};
}
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,
source: snapshot.source ?? null,
};
}