import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core'; import type { PlayerState } from './types'; const LOCAL_KEY = 'pipu.player'; /** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */ const MAX_SAVED_QUEUE = 500; export interface Saved { queue: string[]; index: number; positionMs: number; context: PlayContext | null; volume: number; shuffle: boolean; repeat: RepeatMode; savedAt: string; } function deviceId(): string { try { let id = localStorage.getItem('pipu.device'); if (!id) { id = crypto.randomUUID(); localStorage.setItem('pipu.device', id); } return id; } catch { return 'web'; } } export class PlaybackPersistence { private readonly device = deviceId(); lastServerSave = 0; constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {} snapshot(state: PlayerState): Saved { const ids = state.queue.map((t) => t.id); const start = Math.max(0, Math.min(state.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE)); return { queue: ids.slice(start, start + MAX_SAVED_QUEUE), index: state.index - start, positionMs: state.positionMs, context: state.context, volume: state.volume, shuffle: state.shuffle, repeat: state.repeat, savedAt: new Date().toISOString(), }; } saveLocal() { if (!this.getState().track) return; try { localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState()))); } catch { // недоступное хранилище не мешает воспроизведению } } private saveTimer: ReturnType | null = null; saveSoon() { if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = setTimeout(() => this.saveServer(), 1000); } /** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */ saveServer(keepalive = false) { const cur = this.getState().track; if (!cur) return; this.lastServerSave = Date.now(); this.saveLocal(); const s = this.snapshot(this.getState()); void this.session.client .PUT('/api/v1/me/playback-state', { body: { track_id: cur.id, position_ms: s.positionMs, queue: s.queue, queue_index: s.index, context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined, shuffle: s.shuffle, repeat_mode: s.repeat, volume: s.volume, device_id: this.device, }, keepalive, }) .catch(() => undefined); } async restore(): Promise { let local: Saved | null = null; try { const raw = localStorage.getItem(LOCAL_KEY); local = raw ? (JSON.parse(raw) as Saved) : null; } catch { local = null; } let saved = local; try { const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state')); if ( server.track_id && server.updated_at && (!local || (server.device_id !== this.device && server.updated_at > local.savedAt)) ) { saved = { queue: server.queue, index: server.queue_index, positionMs: server.position_ms, context: (server.context?.type ? server.context : null) as PlayContext | null, volume: server.volume, shuffle: server.shuffle, repeat: server.repeat_mode, savedAt: server.updated_at, }; } } catch { // офлайн — остаёмся с локальной копией } return saved; } dispose() { if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = null; } }