/** * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ export interface SleepTimerHooks { pause(): void; /** Persisted player volume — the fade's restore target. */ getVolume(): number; /** Transient engine-level volume (does NOT change the persisted player volume). */ setEngineVolume(volume: number): void; onTriggered(type: 'time' | 'mediaItem'): void; } const FADE_TICK_MS = 250; export class SleepTimerController { private state: | { type: 'time'; deadlineMs: number; fadeOutSeconds: number } | { type: 'mediaItem'; index: number } | null = null; private timeout: ReturnType | null = null; private fadeStartTimeout: ReturnType | null = null; private fadeInterval: ReturnType | null = null; private fading = false; constructor(private hooks: SleepTimerHooks) {} sleepAfterTime(seconds: number, fadeOutSeconds: number): void { this.cancel(); const fade = Math.min(Math.max(0, fadeOutSeconds), seconds); this.state = { type: 'time', deadlineMs: Date.now() + seconds * 1000, fadeOutSeconds: fade, }; this.timeout = setTimeout(() => this.trigger('time'), seconds * 1000); if (fade > 0) { this.fadeStartTimeout = setTimeout( () => { this.fading = true; const original = this.hooks.getVolume(); this.fadeInterval = setInterval(() => { if (this.state?.type !== 'time') return; const remaining = Math.max(0, this.state.deadlineMs - Date.now()); const fraction = remaining / (fade * 1000); this.hooks.setEngineVolume(original * Math.min(1, fraction)); }, FADE_TICK_MS); }, (seconds - fade) * 1000 ); } } sleepAfterMediaItemAtIndex(index: number): void { this.cancel(); this.state = { type: 'mediaItem', index }; } /** Called by WebTrackPlayer when an item finishes, BEFORE auto-advancing. */ notifyItemEnded(canonicalIndex: number): void { if ( this.state?.type === 'mediaItem' && this.state.index === canonicalIndex ) { this.trigger('mediaItem'); } } getState(): | { type: 'time'; remainingSeconds: number; fadeOutSeconds: number } | { type: 'mediaItem'; index: number } | null { if (this.state == null) return null; if (this.state.type === 'mediaItem') return { ...this.state }; return { type: 'time', remainingSeconds: Math.max( 0, Math.round((this.state.deadlineMs - Date.now()) / 1000) ), fadeOutSeconds: this.state.fadeOutSeconds, }; } cancel(): void { this.clearTimers(); if (this.fading) { this.hooks.setEngineVolume(this.hooks.getVolume()); this.fading = false; } this.state = null; } destroy(): void { this.cancel(); } private trigger(type: 'time' | 'mediaItem'): void { this.clearTimers(); this.hooks.pause(); if (this.fading || type === 'time') { this.hooks.setEngineVolume(this.hooks.getVolume()); this.fading = false; } this.state = null; this.hooks.onTriggered(type); } private clearTimers(): void { if (this.timeout) clearTimeout(this.timeout); if (this.fadeStartTimeout) clearTimeout(this.fadeStartTimeout); if (this.fadeInterval) clearInterval(this.fadeInterval); this.timeout = null; this.fadeStartTimeout = null; this.fadeInterval = null; } }