diff --git a/.gitignore b/.gitignore index 3b37297..044876a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ vendor/kotlinaudio/kotlin-audio/.cxx/ HANDOFF.md DESIGN.md docs/release/android.md +docs/release/checklist.md Untitled-1.md diff --git a/src/session/SessionLifecycle.tsx b/src/session/SessionLifecycle.tsx index d385eb2..0ad2f4a 100644 --- a/src/session/SessionLifecycle.tsx +++ b/src/session/SessionLifecycle.tsx @@ -1,15 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; -import { Linking } from 'react-native'; -import { - useGlobalSearchParams, - usePathname, - useRootNavigationState, - useSegments, -} from 'expo-router'; -import { useReturnToTabs } from '@/navigation/returnToTabs'; +import { useEffect, useRef } from 'react'; +import { useRootNavigationState } 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'; @@ -24,78 +15,29 @@ import type { DbTrack } from '@/types/library'; import { installMobileSessionPersistence, readPersistedMobileSession, - rememberStableHref, - setInitialStableHref, } from './sessionPersistence'; -import { - normalizeStableHref, - resolvePlaybackSession, - shouldRestoreSavedRoute, - stableHrefForRoute, -} from './sessionState'; +import { resolvePlaybackSession } from './sessionState'; interface SessionLifecycleProps { onReady: () => void; } -async function validateSavedHref(href: string): Promise { - const normalized = normalizeStableHref(href) ?? '/'; - const [pathname, query = ''] = normalized.split('?', 2); - const albumMatch = pathname.match(/^\/library\/album\/([^/]+)$/); - if (albumMatch) { - const key = decodeURIComponent(albumMatch[1]); - const result = await AstraLibraryData.getAlbumDetail>( - key, - 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>( - 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. */ +/** + * Restores the playback session once, then owns session autosave. + * + * Deliberately does *not* restore the route. Closing an app on mobile means + * "start me fresh"; leaving it in recents keeps the task alive, and React + * Navigation already holds that state in memory without our help. So a cold + * launch falls through to the router's own initial route (Home) and only the + * queue, current track, and position come back off disk. + */ export function SessionLifecycle({ onReady }: SessionLifecycleProps) { - const returnToTabs = useReturnToTabs(); - const pathname = usePathname(); - const segments = useSegments(); - const params = useGlobalSearchParams<{ - key?: string | string[]; - name?: string | string[]; - id?: string | string[]; - credit?: string | string[]; - }>(); + // The navigator does not gate anything we restore, but the effect resets the + // player overlay, so let the router mount its first screen before we run. 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; @@ -106,9 +48,8 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) { const snapshotRead = readPersistedMobileSession(); let snapshot: Awaited = null; try { - const [loadedSnapshot, initialUrl] = await Promise.all([ + const [loadedSnapshot] = await Promise.all([ snapshotRead, - Linking.getInitialURL(), (async () => { await useLibraryStore.getState().initialize(); try { @@ -151,21 +92,9 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) { } 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((resolve) => requestAnimationFrame(() => resolve())); - } - uninstallPersistence.current = installMobileSessionPersistence( snapshot?.playback ?? null ); - setHydrated(true); } catch (error) { if (cancelled) return; console.warn('[session] restore failed', error); @@ -175,13 +104,8 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) { // The normal empty-session fallback below remains safe. } if (cancelled) return; - setInitialStableHref( - normalizeStableHref(snapshot?.lastStableHref) - ?? normalizeStableHref(initialPathname.current) - ?? '/' - ); + // A failed restore must never leave autosave uninstalled. uninstallPersistence.current = installMobileSessionPersistence(snapshot?.playback ?? null); - setHydrated(true); } finally { if (!cancelled) onReady(); } @@ -193,12 +117,7 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) { uninstallPersistence.current?.(); uninstallPersistence.current = null; }; - }, [navigationKey, onReady, returnToTabs]); - - useEffect(() => { - if (!hydrated) return; - rememberStableHref(stableHrefForRoute(segments, pathname, params)); - }, [hydrated, params, pathname, segments]); + }, [navigationKey, onReady]); return null; } diff --git a/src/session/sessionPersistence.ts b/src/session/sessionPersistence.ts index d7e084e..d123b5e 100644 --- a/src/session/sessionPersistence.ts +++ b/src/session/sessionPersistence.ts @@ -6,7 +6,6 @@ import { useQueueStore } from '@/stores/queueStore'; import { MOBILE_SESSION_KIND, MOBILE_SESSION_SCHEMA_VERSION, - normalizeStableHref, parseMobileSessionSnapshot, stringifyMobileSessionSnapshot, type MobileSessionSnapshotV1, @@ -16,8 +15,6 @@ import { const STRUCTURAL_SAVE_DEBOUNCE_MS = 250; const POSITION_SAVE_THROTTLE_MS = 2000; -let lastStableHref = '/'; -let scheduleStructuralSave: (() => void) | null = null; let writeChain: Promise = Promise.resolve(); export async function readPersistedMobileSession(): Promise { @@ -37,17 +34,6 @@ function enqueueSnapshotWrite(snapshot: MobileSessionSnapshotV1): Promise 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 } { @@ -63,7 +49,6 @@ function currentSnapshot( kind: MOBILE_SESSION_KIND, schemaVersion: MOBILE_SESSION_SCHEMA_VERSION, savedAt: Date.now(), - lastStableHref, playback, }, playback, @@ -122,8 +107,6 @@ export function installMobileSessionPersistence( scheduleSave(Math.max(0, POSITION_SAVE_THROTTLE_MS - elapsed)); }; - scheduleStructuralSave = scheduleDebouncedSave; - const unsubscribePlayer = usePlayerStore.subscribe((state, previous) => { if ( state.playbackState !== previous.playbackState @@ -155,12 +138,11 @@ export function installMobileSessionPersistence( } }); - // Persist route validation and queue normalization from hydration. The - // snapshot fallback above gives a live native queue time to populate first. + // Persist the 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(); diff --git a/src/session/sessionState.test.mts b/src/session/sessionState.test.mts index c40ed50..5933006 100644 --- a/src/session/sessionState.test.mts +++ b/src/session/sessionState.test.mts @@ -3,15 +3,10 @@ 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'; @@ -21,78 +16,11 @@ const tracks = [ { 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', () => { 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, @@ -115,7 +43,6 @@ test('rejects unknown versions and safely defaults corrupt fields', () => { 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, @@ -127,7 +54,6 @@ test('rejects unknown versions and safely defaults corrupt fields', () => { }); assert.equal(normalized?.savedAt, 0); - assert.equal(normalized?.lastStableHref, '/'); assert.deepEqual(normalized?.playback, { queuePaths: ['file:///a.flac', 'file:///b.flac'], 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', () => { const legacy = normalizeMobileSessionSnapshot({ kind: MOBILE_SESSION_KIND, schemaVersion: MOBILE_SESSION_SCHEMA_VERSION, savedAt: 123, - lastStableHref: '/', playback: { queuePaths: ['file:///a.flac'], activeIndex: 0, diff --git a/src/session/sessionState.ts b/src/session/sessionState.ts index 56c9d71..56249a4 100644 --- a/src/session/sessionState.ts +++ b/src/session/sessionState.ts @@ -6,7 +6,6 @@ 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'; @@ -26,7 +25,6 @@ export interface MobileSessionSnapshotV1 { kind: typeof MOBILE_SESSION_KIND; schemaVersion: typeof MOBILE_SESSION_SCHEMA_VERSION; savedAt: number; - lastStableHref: string; playback: PlaybackSessionSnapshotV1 | null; } @@ -45,35 +43,6 @@ export interface ResolvedPlaybackSession { 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 { 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'; } -function decodeRoutePart(value: string): string | null { - try { - return decodeURIComponent(value); - } catch { - return null; - } -} - -/** - * Canonicalizes routes that are safe to restore after a cold launch. Transient - * editors, scanners, import/redirect routes, and unknown future routes return - * null so the previously remembered stable page remains authoritative. - */ -export function normalizeStableHref(value: unknown): string | null { - const href = nonEmptyString(value, MAX_HREF_LENGTH); - if (!href || !href.startsWith('/') || href.startsWith('//') || href.includes('\\')) return null; - - const hashless = href.split('#', 1)[0] ?? ''; - const queryIndex = hashless.indexOf('?'); - const rawPath = queryIndex >= 0 ? hashless.slice(0, queryIndex) : hashless; - const rawQuery = queryIndex >= 0 ? hashless.slice(queryIndex + 1) : ''; - const path = rawPath.length > 1 ? rawPath.replace(/\/+$/, '') : rawPath; - - if (!path || path.includes('//') || /%5c/i.test(path)) return null; - const decodedSegments = path.split('/').slice(1).map(decodeRoutePart); - if (decodedSegments.some( - (segment) => segment === null || segment === '.' || segment === '..' || segment.includes('\\') - )) { - return null; - } - - if (STATIC_STABLE_PATHS.has(path)) return path; - if (/^\/library\/album\/[^/]+$/.test(path)) return path; - if (/^\/library\/playlist\/(?:favorites|\d+)$/.test(path)) return path; - - if (/^\/library\/artist\/[^/]+(?:\/(?:albums|songs|appearances))?$/.test(path)) { - const hasCredit = rawQuery - .split('&') - .map((part) => part.split('=', 2).map(decodeRoutePart)) - .some(([key, entry]) => key === 'credit' && entry === '1'); - return hasCredit ? `${path}?credit=1` : path; - } - - return null; -} - -export function validateRestoredHref( - href: string, - context: StableRouteValidationContext -): string { - const normalized = normalizeStableHref(href) ?? '/'; - const [pathname, query = ''] = normalized.split('?', 2); - - const albumMatch = pathname.match(/^\/library\/album\/([^/]+)$/); - if (albumMatch) { - const key = decodeRoutePart(albumMatch[1]); - return key && context.hasAlbum(key) ? normalized : '/library'; - } - - const artistMatch = pathname.match( - /^\/library\/artist\/([^/]+)(?:\/(?:albums|songs|appearances))?$/ - ); - if (artistMatch) { - const name = decodeRoutePart(artistMatch[1]); - const credit = new URLSearchParams(query).get('credit') === '1'; - return name && context.hasArtist(name, credit) ? normalized : '/library'; - } - - const playlistMatch = pathname.match(/^\/library\/playlist\/(favorites|\d+)$/); - if (playlistMatch) { - if (playlistMatch[1] === 'favorites') return normalized; - return context.hasPlaylist(Number(playlistMatch[1])) ? normalized : '/library'; - } - - return normalized; -} - -function firstRouteParam(value: unknown): string | null { - const entry = Array.isArray(value) ? value[0] : value; - return typeof entry === 'string' && entry.length > 0 ? entry : null; -} - -/** Builds an encoded href from Expo Router's file segments and decoded params. */ -export function stableHrefForRoute( - segments: readonly string[], - pathname: string, - params: Record -): string { - const routeSegments = segments.filter( - (segment) => !(segment.startsWith('(') && segment.endsWith(')')) - ); - const key = firstRouteParam(params.key); - if (routeSegments.join('/') === 'library/album/[key]' && key) { - return `/library/album/${encodeURIComponent(key)}`; - } - - const name = firstRouteParam(params.name); - if ( - name - && routeSegments[0] === 'library' - && routeSegments[1] === 'artist' - && routeSegments[2] === '[name]' - ) { - const subpage = routeSegments[3]; - const suffix = subpage === 'albums' || subpage === 'songs' || subpage === 'appearances' - ? `/${subpage}` - : ''; - const credit = firstRouteParam(params.credit) === '1' ? '?credit=1' : ''; - return `/library/artist/${encodeURIComponent(name)}${suffix}${credit}`; - } - - const id = firstRouteParam(params.id); - if (routeSegments.join('/') === 'library/playlist/[id]' && id) { - return `/library/playlist/${encodeURIComponent(id)}`; - } - - return pathname; -} - -/** Whether an initial OS URL names a destination that must beat disk restore. */ -export function hasExplicitLaunchDestination(value: string | null): boolean { - if (!value) return false; - try { - const url = new URL(value); - let path = url.pathname || ''; - if (url.protocol === 'astra:' && url.hostname) path = `/${url.hostname}${path}`; - const expoMarker = path.indexOf('/--/'); - if (expoMarker >= 0) path = path.slice(expoMarker + 3); - return path !== '' && path !== '/'; - } catch { - // A non-empty URL we cannot parse is still an explicit external launch. - return true; - } -} - -/** Whether restart navigation may replace the router's initial destination. */ -export function shouldRestoreSavedRoute( - initialPathname: string, - initialUrl: string | null -): boolean { - if (hasExplicitLaunchDestination(initialUrl)) return false; - if (initialPathname === '/') return true; - if (initialPathname === '/notification.click' || initialPathname === '/eq/import') { - return false; - } - // Stable non-root routes are initial deep-link/widget destinations when the - // URL has already been consumed by Expo Router. Transient editor/scanner - // state is never allowed to beat the last stable disk route. - return normalizeStableHref(initialPathname) === null; -} - export function normalizePlaybackSession(value: unknown): PlaybackSessionSnapshotV1 | null { if (!isPlainRecord(value)) return null; const queuePaths = normalizePathArray(value.queuePaths); @@ -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 { if (!isPlainRecord(value)) return null; 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, schemaVersion: MOBILE_SESSION_SCHEMA_VERSION, savedAt: Math.max(0, finiteNumber(value.savedAt)), - lastStableHref: normalizeStableHref(value.lastStableHref) ?? '/', playback: normalizePlaybackSession(value.playback), }; }