perf(web): preload restored audio before playback
ci / ts / ts (packages + web) (push) Successful in 2m20s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m43s

This commit is contained in:
mrleo1nid committed 2026-10-21 22:49:10 +03:00
1 parent 20abe20686
commit 3a097c15c8
2 files changed
+119 -12

No files matched your search

+40 -12
View File
@@ -97,6 +97,7 @@ export class Player {
private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private restoreVersion = 0;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
@@ -159,6 +160,7 @@ export class Player {
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;
@@ -171,6 +173,7 @@ export class Player {
/** Запустить волну (или перезапустить с новым контекстом). */
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(
@@ -275,7 +278,7 @@ export class Player {
play() {
if (!this.queue.current) return;
if (!this.audio.src) {
if (!this.audio.src || !this.attempt) {
void this.load(true, this.state.positionMs);
return;
}
@@ -388,9 +391,12 @@ export class Player {
private async load(autoplay: boolean, startMs = 0) {
const track = this.queue.current;
if (!track?.stream_url) return;
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
this.audio.src = track.stream_url;
if (startMs > 0) this.audio.currentTime = startMs / 1000;
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;
@@ -401,7 +407,6 @@ export class Player {
context_type: this.context?.type,
context_id: this.context?.id,
});
this.updateMediaMetadata(track);
this.saveServer();
void this.refillWave();
if (autoplay) {
@@ -597,22 +602,28 @@ export class Player {
if (a.ended) return;
this.update({ playing: false });
const t = this.queue.current;
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
this.saveServer();
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('waiting', () => this.update({ loading: true }));
a.addEventListener('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms);
this.update({ positionMs: ms });
this.updatePositionState();
this.maybePreload(ms);
this.maybeCrossfade(ms);
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) return;
if (this.ignoreEnded || this.fading || !this.attempt) return;
this.finishAttempt('play_end');
if (this.queue.next(true)) {
this.syncQueue();
@@ -629,11 +640,18 @@ export class Player {
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);
@@ -775,6 +793,7 @@ export class Player {
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
*/
async restore() {
const version = ++this.restoreVersion;
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
@@ -804,8 +823,9 @@ export class Player {
} catch {
// офлайн — остаёмся с локальной копией
}
if (!saved?.queue.length) return;
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;
@@ -819,12 +839,20 @@ export class Player {
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 = [];
void this.events.flush();