refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
This commit is contained in:
1 parent
c84f90e2cc
commit
e158558da3
56 files changed
+3337
-2196
No files matched your search
+60
-339
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveOutcome,
|
||||
type WaveFeedback,
|
||||
normalizeGain,
|
||||
PlayAttempt,
|
||||
PlayQueue,
|
||||
@@ -10,80 +14,29 @@ import {
|
||||
type Track,
|
||||
} from '@pipu/core';
|
||||
|
||||
import { desktopHandle } from '../desktop';
|
||||
import { MediaControls } from './media';
|
||||
import type { PlayerState } from './types';
|
||||
import { AudioEngine } from './engine';
|
||||
import { PlaybackPersistence } from './persistence';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
activity: string | null;
|
||||
mood: string | null;
|
||||
/** Рекомендатель недоступен — играет запасная волна. */
|
||||
fallback: boolean;
|
||||
}
|
||||
export type { WaveInfo } from '@pipu/core';
|
||||
|
||||
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
||||
export type { PlayerState } from './types';
|
||||
|
||||
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
|
||||
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 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();
|
||||
@@ -92,13 +45,17 @@ export class Player {
|
||||
private context: PlayContext | null = null;
|
||||
private state: PlayerState;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly device = deviceId();
|
||||
private lastServerSave = 0;
|
||||
private readonly persistence: PlaybackPersistence;
|
||||
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 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;
|
||||
@@ -107,10 +64,7 @@ export class Player {
|
||||
private readonly session: AuthSession,
|
||||
audio?: HTMLAudioElement,
|
||||
) {
|
||||
this.audio = audio ?? new Audio();
|
||||
this.audio.preload = 'auto';
|
||||
this.overlap = new Audio();
|
||||
this.overlap.preload = 'auto';
|
||||
this.engine = new AudioEngine(audio);
|
||||
this.events = new EventBuffer({
|
||||
send: async (events) => {
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
@@ -121,9 +75,9 @@ export class Player {
|
||||
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.bindMediaSession();
|
||||
this.bindDesktopMedia();
|
||||
this.media = new MediaControls(this, this.getState, () => this.audio);
|
||||
}
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
@@ -140,13 +94,12 @@ export class Player {
|
||||
|
||||
getState = () => this.state;
|
||||
|
||||
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||
private desktopUnlisten: (() => void) | null = null;
|
||||
private readonly media: MediaControls;
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
for (const fn of this.listeners) fn();
|
||||
this.syncDesktopMedia();
|
||||
this.media.syncDesktopMedia();
|
||||
}
|
||||
|
||||
private syncQueue() {
|
||||
@@ -183,16 +136,19 @@ export class Player {
|
||||
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';
|
||||
@@ -210,12 +166,14 @@ export class Player {
|
||||
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);
|
||||
@@ -236,8 +194,8 @@ export class Player {
|
||||
|
||||
private leaveWave() {
|
||||
const wave = this.state.wave;
|
||||
this.wavePolicy.activate(null);
|
||||
if (!wave) return;
|
||||
this.waveFeedback = [];
|
||||
this.update({ wave: null });
|
||||
void this.session.client
|
||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||
@@ -245,7 +203,7 @@ export class Player {
|
||||
}
|
||||
|
||||
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
||||
this.wavePolicy.note(trackId, outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,29 +211,12 @@ export class Player {
|
||||
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
|
||||
*/
|
||||
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; // волну уже выключили
|
||||
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.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||
this.syncQueue();
|
||||
} catch {
|
||||
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
|
||||
} finally {
|
||||
this.waveLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
@@ -289,7 +230,7 @@ export class Player {
|
||||
void this.load(true, this.state.positionMs);
|
||||
return;
|
||||
}
|
||||
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
|
||||
void this.engine.resume()?.catch(() => undefined);
|
||||
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
||||
}
|
||||
@@ -402,7 +343,7 @@ export class Player {
|
||||
// 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);
|
||||
this.media.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
@@ -418,7 +359,8 @@ export class Player {
|
||||
void this.refillWave();
|
||||
if (autoplay) {
|
||||
try {
|
||||
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
|
||||
const resumed = this.engine.resume();
|
||||
if (resumed) await resumed;
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
@@ -465,73 +407,11 @@ export class Player {
|
||||
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;
|
||||
};
|
||||
|
||||
readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted);
|
||||
|
||||
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);
|
||||
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
|
||||
}
|
||||
|
||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||
@@ -590,7 +470,7 @@ export class Player {
|
||||
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);
|
||||
this.media.updateMediaMetadata(next);
|
||||
void this.audio.play().finally(() => {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
@@ -599,13 +479,13 @@ export class Player {
|
||||
|
||||
private bindAudio() {
|
||||
const a = this.audio;
|
||||
a.addEventListener('playing', () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
a.addEventListener('pause', () => {
|
||||
this.engine.on('pause', () => {
|
||||
if (a.ended) return;
|
||||
this.update({ playing: false });
|
||||
const t = this.queue.current;
|
||||
@@ -614,22 +494,22 @@ export class Player {
|
||||
this.saveServer();
|
||||
}
|
||||
});
|
||||
a.addEventListener('waiting', () => {
|
||||
this.engine.on('waiting', () => {
|
||||
if (this.attempt) this.update({ loading: true });
|
||||
});
|
||||
a.addEventListener('timeupdate', () => {
|
||||
this.engine.on('timeupdate', () => {
|
||||
const ms = Math.round(a.currentTime * 1000);
|
||||
this.attempt?.tick(ms);
|
||||
this.update({ positionMs: ms });
|
||||
this.updatePositionState();
|
||||
this.media.updatePositionState();
|
||||
if (this.state.playing) {
|
||||
this.maybePreload(ms);
|
||||
this.maybeCrossfade(ms);
|
||||
}
|
||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
if (this.state.playing && Date.now() - this.persistence.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
this.saveLocal();
|
||||
});
|
||||
a.addEventListener('ended', () => {
|
||||
this.engine.on('ended', () => {
|
||||
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||
this.finishAttempt('play_end');
|
||||
if (this.queue.next(true)) {
|
||||
@@ -640,7 +520,7 @@ export class Player {
|
||||
this.saveServer();
|
||||
}
|
||||
});
|
||||
a.addEventListener('error', () => void this.onError());
|
||||
this.engine.on('error', () => void this.onError());
|
||||
}
|
||||
|
||||
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||
@@ -683,138 +563,11 @@ export class Player {
|
||||
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);
|
||||
}
|
||||
private saveLocal() { this.persistence.saveLocal(); }
|
||||
private saveSoon() { this.persistence.saveSoon(); }
|
||||
saveServer(keepalive = false) { this.persistence.saveServer(keepalive); }
|
||||
|
||||
/** Уход со страницы: дослать события и состояние. */
|
||||
onHide() {
|
||||
@@ -828,35 +581,7 @@ export class Player {
|
||||
*/
|
||||
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 {
|
||||
// офлайн — остаёмся с локальной копией
|
||||
}
|
||||
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;
|
||||
@@ -872,25 +597,21 @@ export class Player {
|
||||
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]!);
|
||||
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.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.overlap.pause();
|
||||
this.overlap.removeAttribute('src');
|
||||
this.overlap.load();
|
||||
this.engine.dispose();
|
||||
this.persistence.dispose();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
this.desktopUnlisten?.();
|
||||
this.desktopUnlisten = null;
|
||||
this.media.dispose();
|
||||
void this.events.flush();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user