ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m22s
ci / ts / ts (packages + web) (push) Successful in 3m9s
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 4m7s
release / prune (старые файлы и прогоны) (push) Successful in 11s
752 lines
28 KiB
TypeScript
752 lines
28 KiB
TypeScript
import {
|
|
EventBuffer,
|
|
createActivityPublisher,
|
|
type ActivityPublisher,
|
|
ListeningCoordinator,
|
|
type ListeningRoom,
|
|
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';
|
|
import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline';
|
|
|
|
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 activity: ActivityPublisher | null = null;
|
|
private listening: ListeningCoordinator | null = null;
|
|
private applyingSync = false;
|
|
private remoteRoom: string | null = null;
|
|
readonly offline: DesktopOffline | null;
|
|
private retryEvents: (() => Promise<void>) | null = null;
|
|
private readonly engine: AudioEngine;
|
|
private get audio() { return this.engine.audio; }
|
|
private get overlap() { return this.engine.overlap; }
|
|
private fading = false;
|
|
private fadeVersion = 0;
|
|
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<typeof setInterval>[] = [];
|
|
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);
|
|
const owner = offlineOwner(session);
|
|
this.offline = owner ? new DesktopOffline(owner, session) : null;
|
|
if (owner) {
|
|
const send = desktopEventSink(session, owner);
|
|
this.retryEvents = () => send([]);
|
|
this.sendEvents = send;
|
|
}
|
|
this.events = new EventBuffer({
|
|
send: async (events) => {
|
|
if (this.sendEvents) return this.sendEvents(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.activity ??= createActivityPublisher(this.session);
|
|
this.activity.update(this.state);
|
|
if (!this.listening) {
|
|
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
|
|
read: this.getState,
|
|
pause: () => { this.restoreVersion++; this.leaveWave(); this.cancelCrossfade(); this.audio.pause(); },
|
|
apply: (room, position, current) => this.syncListening(room, position, current),
|
|
changed: listening => this.update({ listening }),
|
|
});
|
|
this.listening.start();
|
|
}
|
|
this.offline?.activate();
|
|
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
|
if (this.retryEvents) {
|
|
void this.retryEvents().catch(() => undefined);
|
|
this.timers.push(setInterval(() => void this.retryEvents?.().catch(() => undefined), FLUSH_EVERY_MS));
|
|
}
|
|
}
|
|
|
|
private sendEvents: ((events: import('@pipu/core').PlayEvent[]) => Promise<void>) | null = null;
|
|
|
|
// --- подписка (useSyncExternalStore) ---
|
|
|
|
subscribe = (fn: () => void) => {
|
|
this.listeners.add(fn);
|
|
return () => this.listeners.delete(fn);
|
|
};
|
|
|
|
getState = () => this.state;
|
|
|
|
private readonly media: MediaControls;
|
|
|
|
private update(patch: Partial<PlayerState>) {
|
|
this.state = { ...this.state, ...patch };
|
|
this.activity?.update(this.state);
|
|
this.listening?.update();
|
|
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) {
|
|
if (this.listening?.locked && !this.applyingSync) return;
|
|
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 }) {
|
|
if (this.listening?.locked) return;
|
|
this.restoreVersion++;
|
|
this.finishAttempt('skip');
|
|
this.leaveWave();
|
|
const version = this.wavePolicy.version;
|
|
let res;
|
|
try {
|
|
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', {
|
|
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
|
|
}));
|
|
} catch (error) {
|
|
if (!this.offline) throw error;
|
|
await this.offline.ready;
|
|
if (!this.wavePolicy.isCurrent(version)) return;
|
|
const tracks = this.offline.tracks().filter((t) => t.reaction !== 'dislike');
|
|
if (!tracks.length) throw error;
|
|
this.playList(tracks.sort(() => Math.random() - 0.5), 0, { type: 'collection', id: 'offline' });
|
|
return;
|
|
}
|
|
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) {
|
|
if (this.listening?.locked) return;
|
|
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() {
|
|
if (this.listening?.locked) return;
|
|
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.listening?.locked && !this.applyingSync) return;
|
|
if (this.state.playing) this.pause();
|
|
else this.play();
|
|
}
|
|
|
|
play() {
|
|
if (this.listening?.locked && !this.applyingSync) return;
|
|
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() {
|
|
if (this.listening?.locked && !this.applyingSync) return;
|
|
this.cancelCrossfade();
|
|
this.audio.pause();
|
|
}
|
|
|
|
next() {
|
|
if (this.listening?.locked) return;
|
|
this.finishAttempt('skip');
|
|
if (this.queue.next()) {
|
|
this.syncQueue();
|
|
void this.load(true);
|
|
} else {
|
|
this.pause();
|
|
}
|
|
}
|
|
|
|
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
|
|
prev() {
|
|
if (this.listening?.locked) return;
|
|
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) {
|
|
if (this.listening?.locked) return;
|
|
this.finishAttempt('skip');
|
|
this.queue.jump(index);
|
|
this.syncQueue();
|
|
void this.load(true);
|
|
}
|
|
|
|
seek(ms: number) {
|
|
if (this.listening?.locked && !this.applyingSync) return;
|
|
const track = this.queue.current;
|
|
if (!track) return;
|
|
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
|
this.cancelCrossfade();
|
|
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() {
|
|
if (this.listening?.locked) return;
|
|
this.queue.setShuffle(!this.queue.shuffle);
|
|
this.syncQueue();
|
|
this.saveLocal();
|
|
}
|
|
|
|
cycleRepeat() {
|
|
if (this.listening?.locked) return;
|
|
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, current = () => true) {
|
|
this.cancelCrossfade();
|
|
const track = this.queue.current;
|
|
if (!track?.stream_url) return;
|
|
const version = this.restoreVersion;
|
|
if (this.offline) {
|
|
await this.offline.ready.catch(() => undefined);
|
|
if (!current() || this.queue.current !== track || version !== this.restoreVersion) return;
|
|
}
|
|
this.offline?.keep([track.id]);
|
|
const source = this.offline?.local(track).stream_url ?? track.stream_url;
|
|
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') !== source) this.audio.src = source;
|
|
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
|
this.media.updateMediaMetadata(track);
|
|
if (!autoplay) return;
|
|
void this.offline?.save(track).catch(() => undefined);
|
|
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;
|
|
if (!current() || version !== this.restoreVersion) return;
|
|
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);
|
|
}
|
|
|
|
/** A cancelled transition must not keep a second source audible or change the new queue. */
|
|
private cancelCrossfade() {
|
|
this.fadeVersion++;
|
|
const wasFading = this.fading;
|
|
this.fading = false;
|
|
this.ignoreEnded = false;
|
|
this.overlap.pause();
|
|
this.overlap.volume = 0;
|
|
if (this.overlap.hasAttribute('src')) {
|
|
this.overlap.removeAttribute('src');
|
|
this.overlap.load();
|
|
}
|
|
if (wasFading) this.applyLoudness();
|
|
}
|
|
|
|
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
|
private maybeCrossfade(positionMs: number) {
|
|
const cf = this.crossfadeSeconds();
|
|
if (this.listening?.locked || 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;
|
|
const version = ++this.fadeVersion;
|
|
this.offline?.keep([this.queue.current!.id, next.id]);
|
|
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
|
|
this.overlap.volume = 0;
|
|
void this.overlap.play().catch(() => {
|
|
if (version === this.fadeVersion) this.cancelCrossfade();
|
|
});
|
|
const started = performance.now();
|
|
const step = () => {
|
|
if (!this.fading || version !== this.fadeVersion) 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, version);
|
|
};
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
private finishCrossfade(next: Track, version: number) {
|
|
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 = this.offline?.local(next).stream_url ?? next.stream_url;
|
|
this.offline?.keep([next.id]);
|
|
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.offline?.save(next).catch(() => undefined);
|
|
void this.audio.play().finally(() => {
|
|
if (version !== this.fadeVersion) return;
|
|
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(this.offline?.local(next).stream_url ?? 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;
|
|
let items: Track[];
|
|
try {
|
|
items = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })).items;
|
|
} catch (error) {
|
|
if (!this.offline) throw error;
|
|
await this.offline.ready;
|
|
const cached = new Map(this.offline.tracks().map((t) => [t.id, t]));
|
|
items = saved.queue.flatMap((id) => cached.get(id) ?? []);
|
|
}
|
|
if (version !== this.restoreVersion) return;
|
|
const wanted = saved.queue[saved.index];
|
|
const tracks = 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.listening?.dispose();
|
|
this.listening = null;
|
|
this.activity?.dispose();
|
|
this.activity = null;
|
|
this.restoreVersion++;
|
|
this.wavePolicy.activate(null);
|
|
this.fadeVersion++;
|
|
this.fading = false;
|
|
this.finishAttempt('skip');
|
|
this.engine.dispose();
|
|
this.offline?.dispose();
|
|
this.persistence.dispose();
|
|
for (const t of this.timers) clearInterval(t);
|
|
this.timers = [];
|
|
this.media.dispose();
|
|
void this.events.flush();
|
|
}
|
|
|
|
beginListeningAudio() {
|
|
if (this.state.listening?.room?.playing) {
|
|
this.applyingSync = true; try { this.play(); } finally { this.applyingSync = false; }
|
|
}
|
|
this.listening?.beginAudio();
|
|
}
|
|
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
|
if (!room.track_id) { this.cancelCrossfade(); this.audio.pause(); return; }
|
|
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
|
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
|
if (!current()) return;
|
|
this.remoteRoom = room.id;
|
|
this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = { type: 'queue' };
|
|
this.queue.set([track], 0); this.queue.repeat = 'off'; this.queue.shuffle = false; this.syncQueue();
|
|
await this.load(room.playing, Math.min(position, Math.max(0, track.duration_ms - 100)), current);
|
|
return;
|
|
}
|
|
if (!current()) return;
|
|
this.applyingSync = true;
|
|
try {
|
|
if (Math.abs(this.state.positionMs - position) > 1500) this.seek(Math.min(position, Math.max(0, this.state.durationMs - 100)));
|
|
if (room.playing && !this.state.playing) this.play();
|
|
if (!room.playing && this.state.playing) this.pause();
|
|
} finally { this.applyingSync = false; }
|
|
}
|
|
}
|