/** Policy shared by web and native players; queue ownership stays with the adapter. */ export const EARLY_SKIP_MS = 30_000; export const WAVE_REFILL_AT = 2; export type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike'; export interface WaveInfo { sessionId: string; activity: string | null; mood: string | null; fallback: boolean; } export interface WaveFeedback { track_id: string; outcome: WaveOutcome } export interface WaveGateway { next(sessionId: string, feedback: WaveFeedback[]): Promise; } export class WaveCoordinator { private sessionId: string | null = null; private generation = 0; private pending: WaveFeedback[] = []; private loading: number | null = null; constructor(private readonly gateway: WaveGateway) {} get version() { return this.generation; } isCurrent(version: number) { return version === this.generation; } /** Also invalidates in-flight responses when the context changes within a session. */ activate(sessionId: string | null) { this.sessionId = sessionId; this.pending = []; this.loading = null; return ++this.generation; } changeContext(sessionId: string) { const pending = this.sessionId === sessionId ? this.pending : []; const version = this.activate(sessionId); this.pending = pending; return version; } note(trackId: string, outcome: WaveOutcome) { if (this.sessionId) this.pending.push({ track_id: trackId, outcome }); } async refill(remaining: number, refresh: boolean, apply: (result: T, current: () => boolean) => void | Promise) { const id = this.sessionId; const version = this.generation; if (!id || this.loading === version || (!refresh && remaining > WAVE_REFILL_AT)) return; this.loading = version; const feedback = this.pending; this.pending = []; try { const result = await this.gateway.next(id, feedback); if (this.isCurrent(version)) await apply(result, () => this.isCurrent(version)); } catch { if (this.isCurrent(version)) this.pending = [...feedback, ...this.pending]; } finally { if (this.loading === version) this.loading = null; } } }