mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 04:59:53 +02:00
159 lines
5.7 KiB
TypeScript
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);
|
|
},
|
|
}));
|