Files
astra-mobile/src/stores/sleepTimerStore.ts
T
2026-07-24 02:18:37 -04:00

159 lines
5.7 KiB
TypeScript

import TrackPlayer from 'react-native-track-player';
import { create } from 'zustand';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
import { setPauseAtEndOfItem } from '@/audio/trackPlayerExtensions';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import {
getSleepTimerRemainingMs,
normalizePersistedSleepTimer,
normalizeSleepTimerMinutes,
shouldCompleteEndOfTrackTimer,
transitionSleepTimer,
type PersistedSleepTimerState,
} from '@/audio/sleepTimerState';
const SLEEP_TIMER_KEY = 'sleep_timer_state_v1';
interface SleepTimerStore {
timer: PersistedSleepTimerState | null;
remainingMs: number | null;
hydrated: boolean;
hydrate: () => Promise<void>;
startMinutes: (minutes: number) => Promise<void>;
startEndOfTrack: () => Promise<void>;
cancel: () => Promise<void>;
reconcile: (nowMs?: number) => Promise<void>;
reconcileEndOfTrack: (position: number, duration: number, playWhenReady: boolean) => Promise<void>;
}
let deadlineTimer: ReturnType<typeof setTimeout> | null = null;
let hydrationPromise: Promise<void> | null = null;
let reconcilePromise: Promise<void> | null = null;
function clearDeadlineTimer(): void {
if (deadlineTimer !== null) clearTimeout(deadlineTimer);
deadlineTimer = null;
}
async function persistTimer(timer: PersistedSleepTimerState | null): Promise<void> {
await setNativeSetting(SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : '');
}
async function hasActivePhoneTrack(): Promise<boolean> {
try {
return Boolean(await TrackPlayer.getActiveTrack());
} catch {
return false;
}
}
function scheduleDeadline(timer: PersistedSleepTimerState | null): void {
clearDeadlineTimer();
if (timer?.mode !== 'minutes' || timer.expiresAtMs === null) return;
const delay = Math.max(0, timer.expiresAtMs - Date.now());
deadlineTimer = setTimeout(() => {
deadlineTimer = null;
void useSleepTimerStore.getState().reconcile();
}, delay);
}
async function clearCompletedTimer(expectedTimer?: PersistedSleepTimerState): Promise<void> {
if (expectedTimer && useSleepTimerStore.getState().timer !== expectedTimer) return;
clearDeadlineTimer();
await setPauseAtEndOfItem(false).catch(() => {});
if (expectedTimer && useSleepTimerStore.getState().timer !== expectedTimer) return;
useSleepTimerStore.setState({ timer: null, remainingMs: null, hydrated: true });
await persistTimer(null);
}
export const useSleepTimerStore = create<SleepTimerStore>((set, get) => ({
timer: null,
remainingMs: null,
hydrated: false,
hydrate: async () => {
if (get().hydrated) return;
if (hydrationPromise) return hydrationPromise;
hydrationPromise = (async () => {
const raw = await getNativeSetting(SLEEP_TIMER_KEY);
let parsed: unknown = null;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch {
parsed = null;
}
const timer = normalizePersistedSleepTimer(parsed);
const active = timer ? await hasActivePhoneTrack() : false;
if (!timer || !active) {
set({ timer: null, remainingMs: null, hydrated: true });
if (raw) await persistTimer(null);
return;
}
set({
timer,
remainingMs: getSleepTimerRemainingMs(timer, Date.now()),
hydrated: true,
});
if (timer.mode === 'end-of-track') await setPauseAtEndOfItem(true);
scheduleDeadline(timer);
await get().reconcile();
})().finally(() => {
hydrationPromise = null;
});
return hydrationPromise;
},
startMinutes: async (value) => {
const minutes = normalizeSleepTimerMinutes(value);
if (minutes === null) throw new Error('Choose a whole number from 1 to 720 minutes.');
if (usePlaybackTargetStore.getState().target !== 'phone') throw new Error('Sleep timers are available for phone playback only.');
if (!await hasActivePhoneTrack()) throw new Error('Start phone playback before setting a sleep timer.');
await setPauseAtEndOfItem(false).catch(() => {});
const timer = transitionSleepTimer(get().timer, { type: 'start-minutes', minutes }, Date.now());
if (!timer) throw new Error('Choose a whole number from 1 to 720 minutes.');
set({ timer, remainingMs: minutes * 60_000, hydrated: true });
await persistTimer(timer);
scheduleDeadline(timer);
},
startEndOfTrack: async () => {
if (usePlaybackTargetStore.getState().target !== 'phone') throw new Error('Sleep timers are available for phone playback only.');
if (!await hasActivePhoneTrack()) throw new Error('Start phone playback before setting a sleep timer.');
await setPauseAtEndOfItem(true);
clearDeadlineTimer();
const timer = transitionSleepTimer(get().timer, { type: 'start-end-of-track' }, Date.now());
set({ timer, remainingMs: null, hydrated: true });
await persistTimer(timer);
},
cancel: async () => {
await clearCompletedTimer();
},
reconcile: async (nowMs = Date.now()) => {
const timer = get().timer;
if (!timer) return;
if (timer.mode === 'end-of-track') return;
const remainingMs = getSleepTimerRemainingMs(timer, nowMs) ?? 0;
set({ remainingMs });
if (remainingMs > 0) {
scheduleDeadline(timer);
return;
}
if (reconcilePromise) return reconcilePromise;
reconcilePromise = (async () => {
await TrackPlayer.pause().catch(() => {});
await clearCompletedTimer(timer);
})().finally(() => {
reconcilePromise = null;
});
return reconcilePromise;
},
reconcileEndOfTrack: async (position, duration, playWhenReady) => {
const timer = get().timer;
if (!shouldCompleteEndOfTrackTimer(timer, position, duration, playWhenReady) || !timer) return;
await clearCompletedTimer(timer);
},
}));