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
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
897 lines
31 KiB
TypeScript
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();
|
|
}
|
|
}
|