mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
172 lines
5.5 KiB
TypeScript
172 lines
5.5 KiB
TypeScript
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
|
|
|| state.source !== previous.source
|
|
) {
|
|
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();
|
|
};
|
|
}
|