Files
pipu-music/apps/web/src/player/player.ts
T
mrleo1nid 11a16cd023
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
feat(social): add listening activity, shared rooms and modern chat
2026-10-23 10:45:40 +03:00

730 lines
27 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 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.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.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);
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) {
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);
}
/** В конце трека накладывает следующий на 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.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(() => {
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 = 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(() => {
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.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.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; }
}
}