Files
pipu-music/apps/web/src/player/player.ts
T
mrleo1nidandCursor efe78d7e07
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
feat(desktop): wrap the web client in a Tauri app
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:45:03 +03:00

897 lines
31 KiB
TypeScript

import {
EventBuffer,
normalizeGain,
PlayAttempt,
PlayQueue,
unwrap,
type AuthSession,
type PlayContext,
type RepeatMode,
type Track,
} from '@pipu/core';
import { desktopHandle } from '../desktop';
export type { PlayContext, Track };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
/** Рекомендатель недоступен — играет запасная волна. */
fallback: boolean;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
const WAVE_REFILL_AT = 2;
/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */
const EARLY_SKIP_MS = 30_000;
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
const LOCAL_KEY = 'pipu.player';
const SAVE_EVERY_MS = 10_000;
const FLUSH_EVERY_MS = 30_000;
const PRELOAD_BEFORE_MS = 20_000;
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
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';
}
}
/**
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
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 device = deviceId();
private lastServerSave = 0;
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private restoreVersion = 0;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
constructor(
private readonly session: AuthSession,
audio?: HTMLAudioElement,
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
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.bindAudio();
this.bindMediaSession();
this.bindDesktopMedia();
}
/** Запуск фоновой отправки событий (парный вызов — 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 lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
this.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 res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}),
);
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) {
this.update({ error: 'wave.empty' });
return;
}
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 res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
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;
if (!wave) return;
this.waveFeedback = [];
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) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
}
/**
* Следующая пачка волны с накопленными реакциями. refresh — сначала выбросить ещё не начатые треки:
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
*/
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || this.waveLoading) return;
if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили
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();
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
} finally {
this.waveLoading = false;
}
}
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;
}
if (this.audioContext?.state === 'suspended') void this.audioContext.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.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 {
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
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));
}
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
private spectrum = new Uint8Array(512);
private audioFrame = { bass: 0, mid: 0, treble: 0 };
private sampledAt = -Infinity;
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
readAudio = () => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const active = this.state.playing && !this.state.loading && !this.state.muted;
const analyser = this.analyser;
if (!active || !analyser) {
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
return this.audioFrame;
}
analyser.getByteFrequencyData(this.spectrum);
const band = (low: number, high: number) => {
const hz = analyser.context.sampleRate / analyser.fftSize;
const start = Math.max(1, Math.ceil(low / hz));
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
let sum = 0;
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
return Math.sqrt(sum / Math.max(1, end - start));
};
this.audioFrame.bass = band(40, 220);
this.audioFrame.mid = band(220, 2500);
this.audioFrame.treble = band(2500, 10000);
return this.audioFrame;
};
private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.55;
this.gain.connect(this.analyser);
// The overlap is already audible through its own output; analyse it only.
try {
const overlapSource = ctx.createMediaElementSource(this.overlap);
overlapSource.connect(ctx.destination);
overlapSource.connect(this.analyser);
} catch { /* Crossfade analysis is optional. */ }
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
}
/** В конце трека накладывает следующий на 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.updateMediaMetadata(next);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
});
}
private bindAudio() {
const a = this.audio;
a.addEventListener('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 });
}
});
a.addEventListener('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();
}
});
a.addEventListener('waiting', () => {
if (this.attempt) this.update({ loading: true });
});
a.addEventListener('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms);
this.update({ positionMs: ms });
this.updatePositionState();
if (this.state.playing) {
this.maybePreload(ms);
this.maybeCrossfade(ms);
}
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal();
});
a.addEventListener('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();
}
});
a.addEventListener('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);
}
// --- Media Session ---
private bindDesktopMedia() {
void desktopHandle()?.listenMedia((action) => {
if (action === 'play') this.play();
else if (action === 'pause' || action === 'stop') this.pause();
else if (action === 'toggle') this.toggle();
else if (action === 'next') this.next();
else if (action === 'previous') this.prev();
}).then((stop) => {
this.desktopUnlisten = stop;
});
}
private syncDesktopMedia() {
const desktop = desktopHandle();
const track = this.state.track;
if (!desktop || !track) return;
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
this.lastDesktop = { id: track.id, playing: this.state.playing };
desktop.setMedia({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
coverUrl: track.album.cover_url,
playing: this.state.playing,
});
}
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.play());
set('pause', () => this.pause());
set('nexttrack', () => this.next());
set('previoustrack', () => this.prev());
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
}
private updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
private updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.audio.duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
// --- сохранение состояния ---
private snapshot(): Saved {
const ids = this.queue.tracks.map((t) => t.id);
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: this.queue.index - start,
positionMs: this.state.positionMs,
context: this.context,
volume: this.state.volume,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
savedAt: new Date().toISOString(),
};
}
private saveLocal() {
if (!this.queue.current) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.queue.current;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot();
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);
}
/** Уход со страницы: дослать события и состояние. */
onHide() {
this.saveServer(true);
void this.events.flush();
}
/**
* Восстановление после запуска: сначала локальная копия (мгновенно), затем серверная,
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
*/
async restore() {
const version = ++this.restoreVersion;
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 {
// офлайн — остаёмся с локальной копией
}
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.updateMediaMetadata(tracks[idx]!);
// Prepare at the saved position without creating a listening attempt.
await this.load(false, pos);
}
dispose() {
this.restoreVersion++;
this.fading = false;
this.finishAttempt('skip');
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.overlap.pause();
this.overlap.removeAttribute('src');
this.overlap.load();
for (const t of this.timers) clearInterval(t);
this.timers = [];
this.desktopUnlisten?.();
this.desktopUnlisten = null;
void this.events.flush();
}
}