import { EventBuffer, WaveCoordinator, EARLY_SKIP_MS, type WaveOutcome, type WaveFeedback, normalizeGain, PlayAttempt, PlayQueue, unwrap, type AuthSession, type PlayContext, type RepeatMode, type Track, } from '@pipu/core'; import { MediaControls } from './media'; import type { PlayerState } from './types'; import { AudioEngine } from './engine'; import { PlaybackPersistence } from './persistence'; export type { PlayContext, Track }; export type { WaveInfo } from '@pipu/core'; export type { PlayerState } from './types'; const SAVE_EVERY_MS = 10_000; const FLUSH_EVERY_MS = 30_000; const PRELOAD_BEFORE_MS = 20_000; /** * Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement, * Media Session и сохранение «трек и момент» (локально и на сервере). */ export class Player { private readonly engine: AudioEngine; private get audio() { return this.engine.audio; } private get overlap() { return this.engine.overlap; } private fading = false; private ignoreEnded = false; private readonly queue = new PlayQueue(); private readonly events: EventBuffer; private attempt: PlayAttempt | null = null; private context: PlayContext | null = null; private state: PlayerState; private readonly listeners = new Set<() => void>(); private readonly persistence: PlaybackPersistence; private preloaded: string | null = null; private refreshedFor: string | null = null; private timers: ReturnType[] = []; private readonly wavePolicy = new WaveCoordinator({ next: async (id: string, feedback: WaveFeedback[]) => unwrap( await this.session.client.POST('/api/v1/wave/sessions/{id}/next', { params: { path: { id } }, body: { feedback }, }), ), }); private restoreVersion = 0; /** Реакция сохранена на сервере — списки на экране нужно перечитать. */ onReaction: (() => void) | null = null; constructor( private readonly session: AuthSession, audio?: HTMLAudioElement, ) { this.engine = new AudioEngine(audio); this.events = new EventBuffer({ send: async (events) => { unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); }, }); this.state = { wave: null, track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0, durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null, }; this.persistence = new PlaybackPersistence(session, this.getState); this.bindAudio(); this.media = new MediaControls(this, this.getState, () => this.audio); } /** Запуск фоновой отправки событий (парный вызов — dispose). */ start() { this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS)); } // --- подписка (useSyncExternalStore) --- subscribe = (fn: () => void) => { this.listeners.add(fn); return () => this.listeners.delete(fn); }; getState = () => this.state; private readonly media: MediaControls; private update(patch: Partial) { this.state = { ...this.state, ...patch }; for (const fn of this.listeners) fn(); this.media.syncDesktopMedia(); } private syncQueue() { this.update({ track: this.queue.current ?? null, queue: this.queue.tracks, index: this.queue.index, shuffle: this.queue.shuffle, repeat: this.queue.repeat, context: this.context, }); } // --- управление --- /** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */ playList(tracks: Track[], index: number, context: PlayContext) { const playable = tracks.filter((t) => t.available && t.stream_url); const target = tracks[index]; if (!target || !playable.length) return; this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = context; this.queue.set(playable, Math.max(playable.indexOf(target), 0)); this.syncQueue(); void this.load(true); } // --- «Моя волна» --- /** Запустить волну (или перезапустить с новым контекстом). */ async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) { this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); const version = this.wavePolicy.version; const res = unwrap( await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) }, }), ); if (!this.wavePolicy.isCurrent(version)) return; const tracks = res.tracks.filter((t) => t.available && t.stream_url); if (!tracks.length) { this.update({ error: 'wave.empty' }); return; } this.wavePolicy.activate(res.session_id); this.context = { type: 'wave', id: res.session_id, label: seed?.label }; this.queue.shuffle = false; this.queue.repeat = 'off'; this.queue.set(tracks, 0); this.update({ wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback } }); this.syncQueue(); void this.load(true); } /** * Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7). * Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с. */ async setWaveContext(activity: string | null, mood: string | null) { const wave = this.state.wave; if (!wave) return this.startWave(activity, mood); this.update({ wave: { ...wave, activity, mood } }); const version = this.wavePolicy.changeContext(wave.sessionId); const res = unwrap( await this.session.client.PATCH('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } }, body: { activity, mood }, }), ); if (!this.wavePolicy.isCurrent(version)) return; const fresh = res.tracks.filter((t) => t.available && t.stream_url); this.queue.dropUpcoming(); this.queue.append(fresh); this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); if (fresh.length && this.queue.next()) { this.finishAttempt('skip', true); this.syncQueue(); void this.load(true); } else { this.syncQueue(); } } stopWave() { this.leaveWave(); this.pause(); } private leaveWave() { const wave = this.state.wave; this.wavePolicy.activate(null); if (!wave) return; this.update({ wave: null }); void this.session.client .DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } }) .catch(() => undefined); } private noteWave(trackId: string, outcome: WaveOutcome) { this.wavePolicy.note(trackId, outcome); } /** * Следующая пачка волны с накопленными реакциями. refresh — сначала выбросить ещё не начатые треки: * после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию. */ private async refillWave(refresh = false) { await this.wavePolicy.refill(this.queue.remaining, refresh, (res) => { if (refresh) this.queue.dropUpcoming(); this.queue.append(res.tracks.filter((t) => t.available && t.stream_url)); this.update({ wave: { ...this.state.wave!, fallback: res.fallback } }); this.syncQueue(); }); } toggle() { if (this.state.playing) this.pause(); else this.play(); } play() { if (!this.queue.current) return; if (!this.audio.src || !this.attempt) { void this.load(true, this.state.positionMs); return; } void this.engine.resume()?.catch(() => undefined); // play() в старых браузерах и jsdom может вернуть undefined вместо промиса. void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false })); } pause() { this.audio.pause(); } next() { this.finishAttempt('skip'); if (this.queue.next()) { this.syncQueue(); void this.load(true); } else { this.pause(); } } /** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */ prev() { if (this.audio.currentTime > 3) { this.seek(0); return; } this.finishAttempt('skip'); if (this.queue.prev()) { this.syncQueue(); void this.load(true); } } jump(index: number) { this.finishAttempt('skip'); this.queue.jump(index); this.syncQueue(); void this.load(true); } seek(ms: number) { const track = this.queue.current; if (!track) return; const to = Math.min(Math.max(ms, 0), track.duration_ms); const from = Math.round(this.audio.currentTime * 1000); this.attempt?.seek(to); this.audio.currentTime = to / 1000; this.update({ positionMs: to }); this.events.push({ type: 'seek', track_id: track.id, from_ms: from, position_ms: to }); this.saveSoon(); } setVolume(v: number) { const volume = Math.min(Math.max(v, 0), 1); this.audio.muted = false; this.update({ volume, muted: false }); this.applyLoudness(); this.saveLocal(); } toggleMute() { this.audio.muted = !this.audio.muted; this.update({ muted: this.audio.muted }); this.applyLoudness(); } toggleShuffle() { this.queue.setShuffle(!this.queue.shuffle); this.syncQueue(); this.saveLocal(); } cycleRepeat() { const order: RepeatMode[] = ['off', 'all', 'one']; this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!; this.syncQueue(); this.saveLocal(); } /** Лайк / дизлайк / снять реакцию. Дизлайк текущего трека сразу переключает на следующий. */ async react(track: Track, reaction: 'like' | 'dislike' | null) { const prev = track.reaction; const apply = (r: Track['reaction']) => { for (const t of this.queue.tracks) if (t.id === track.id) t.reaction = r; this.syncQueue(); }; apply(reaction); const type = reaction ?? (prev === 'like' ? 'unlike' : 'undislike'); this.events.push({ type, track_id: track.id, position_ms: this.state.positionMs }); try { const params = { params: { path: { id: track.id } } }; if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } })); else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params)); } catch (e) { apply(prev); throw e; } this.onReaction?.(); if (reaction) { this.noteWave(track.id, reaction); void this.refillWave(true); } if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next(); } // --- загрузка трека --- private async load(autoplay: boolean, startMs = 0) { const track = this.queue.current; if (!track?.stream_url) return; this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms }); // Keep the restored element's buffer when the user presses Play. if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url; if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000; this.media.updateMediaMetadata(track); if (!autoplay) return; this.applyLoudness(); this.attempt = new PlayAttempt(track, startMs); this.preloaded = null; this.events.push({ type: 'play_start', track_id: track.id, position_ms: startMs, context_type: this.context?.type, context_id: this.context?.id, }); this.saveServer(); void this.refillWave(); if (autoplay) { try { const resumed = this.engine.resume(); if (resumed) await resumed; await this.audio.play(); } catch { // Автозапуск заблокирован браузером — ждём нажатия «Слушать». this.update({ playing: false, loading: false }); } } } /** Завершает текущую попытку: событие с реально прослушанным временем. */ /** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */ private finishAttempt(type: 'play_end' | 'skip', neutral = false) { const a = this.attempt; if (!a) return; this.attempt = null; const pos = Math.round(this.audio.currentTime * 1000); if (neutral) { if (a.playedMs >= EARLY_SKIP_MS) { this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) }); void this.events.flush(); } return; } const kind = type === 'skip' ? a.outcome(pos) : 'play_end'; this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) }); void this.events.flush(); if (kind === 'play_end') this.noteWave(a.track.id, 'complete'); else { const early = a.playedMs < EARLY_SKIP_MS; this.noteWave(a.track.id, early ? 'skip_early' : 'skip'); if (early) void this.refillWave(true); } } private prefs() { const user = this.session.state.status === 'signed_in' ? this.session.state.user : null; return user?.ui_prefs; } private normalizeOn() { return this.prefs()?.normalize !== false; } private crossfadeSeconds() { return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0)); } readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted); private applyLoudness(mix = 1) { const user = this.state.muted ? 0 : this.state.volume; this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix); } /** В конце трека накладывает следующий на crossfade_s секунд. */ private maybeCrossfade(positionMs: number) { const cf = this.crossfadeSeconds(); if (this.fading || cf <= 0 || this.state.repeat === 'one') return; const dur = this.audio.duration; if (!Number.isFinite(dur)) return; const left = dur * 1000 - positionMs; if (left <= 0 || left > cf * 1000) return; const next = this.state.queue[this.state.index + 1]; if (!next?.stream_url || !next.available) return; this.fading = true; this.overlap.src = next.stream_url; this.overlap.volume = 0; void this.overlap.play().catch(() => { this.fading = false; }); const started = performance.now(); const step = () => { if (!this.fading) return; const p = Math.min(1, (performance.now() - started) / (cf * 1000)); this.applyLoudness(1 - p); this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p); if (p < 1) { requestAnimationFrame(step); return; } this.finishCrossfade(next); }; requestAnimationFrame(step); } private finishCrossfade(next: Track) { const at = this.overlap.currentTime; this.ignoreEnded = true; this.audio.pause(); this.overlap.pause(); this.finishAttempt('play_end'); if (!this.queue.next(true)) { this.fading = false; this.ignoreEnded = false; this.update({ playing: false, positionMs: 0 }); return; } this.syncQueue(); const src = next.stream_url; if (!src) { this.fading = false; this.ignoreEnded = false; this.update({ playing: false }); return; } this.audio.src = src; if (at > 0) this.audio.currentTime = at; this.applyLoudness(); this.attempt = new PlayAttempt(next, Math.round(at * 1000)); this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms }); this.media.updateMediaMetadata(next); void this.audio.play().finally(() => { this.fading = false; this.ignoreEnded = false; }); } private bindAudio() { const a = this.audio; this.engine.on('playing', () => { this.update({ playing: true, loading: false }); if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) { this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs }); } }); this.engine.on('pause', () => { if (a.ended) return; this.update({ playing: false }); const t = this.queue.current; if (t && this.attempt) { this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs }); this.saveServer(); } }); this.engine.on('waiting', () => { if (this.attempt) this.update({ loading: true }); }); this.engine.on('timeupdate', () => { const ms = Math.round(a.currentTime * 1000); this.attempt?.tick(ms); this.update({ positionMs: ms }); this.media.updatePositionState(); if (this.state.playing) { this.maybePreload(ms); this.maybeCrossfade(ms); } if (this.state.playing && Date.now() - this.persistence.lastServerSave > SAVE_EVERY_MS) this.saveServer(); this.saveLocal(); }); this.engine.on('ended', () => { if (this.ignoreEnded || this.fading || !this.attempt) return; this.finishAttempt('play_end'); if (this.queue.next(true)) { this.syncQueue(); void this.load(true); } else { this.update({ playing: false, positionMs: 0 }); this.saveServer(); } }); this.engine.on('error', () => void this.onError()); } /** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */ private async onError() { const track = this.queue.current; if (!track) return; // A speculative load must never start playback or skip the saved track. if (!this.attempt) { this.audio.removeAttribute('src'); this.audio.load(); return; } if (this.refreshedFor !== track.id) { this.refreshedFor = track.id; try { const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })); const fresh = res.items[0]; if (this.queue.current !== track || !this.attempt) return; if (fresh?.stream_url) { Object.assign(track, fresh); void this.load(true, this.state.positionMs); return; } } catch { // упадём в пропуск ниже } } this.update({ error: 'player.error', loading: false, playing: false }); this.attempt = null; if (this.queue.peekNext() && this.queue.peekNext()!.id !== track.id) setTimeout(() => this.next(), 1500); } /** За 20 с до конца запрашиваем первый байт следующего трека — на сервере прогревается транскод. */ private maybePreload(ms: number) { const cur = this.queue.current; const next = this.queue.peekNext(); if (!cur || !next?.stream_url || this.preloaded === next.id) return; if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return; this.preloaded = next.id; void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined); } // --- сохранение состояния --- private saveLocal() { this.persistence.saveLocal(); } private saveSoon() { this.persistence.saveSoon(); } saveServer(keepalive = false) { this.persistence.saveServer(keepalive); } /** Уход со страницы: дослать события и состояние. */ onHide() { this.saveServer(true); void this.events.flush(); } /** * Восстановление после запуска: сначала локальная копия (мгновенно), затем серверная, * если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции. */ async restore() { const version = ++this.restoreVersion; const saved = await this.persistence.restore(); if (version !== this.restoreVersion || !saved?.queue.length) return; const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })); if (version !== this.restoreVersion) return; const wanted = saved.queue[saved.index]; const tracks = res.items.filter((t) => t.available && t.stream_url); if (!tracks.length) return; const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0); this.context = saved.context; this.queue.repeat = saved.repeat; this.queue.set(tracks, idx); if (saved.shuffle) this.queue.setShuffle(true); this.audio.volume = saved.volume; const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0; this.syncQueue(); this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume }); this.media.updateMediaMetadata(tracks[idx]!); // Prepare at the saved position without creating a listening attempt. await this.load(false, pos); } dispose() { this.restoreVersion++; this.wavePolicy.activate(null); this.fading = false; this.finishAttempt('skip'); this.engine.dispose(); this.persistence.dispose(); for (const t of this.timers) clearInterval(t); this.timers = []; this.media.dispose(); void this.events.flush(); } }