From 3a097c15c838315dad5c43ddd0fa060cf7cccd96 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:49:10 +0300 Subject: [PATCH] perf(web): preload restored audio before playback --- apps/web/src/player/player.ts | 52 ++++++++++++++----- apps/web/src/player/preload.test.ts | 79 +++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/player/preload.test.ts diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index ae27c9e..2baaae1 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -97,6 +97,7 @@ export class Player { private timers: ReturnType[] = []; 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(); diff --git a/apps/web/src/player/preload.test.ts b/apps/web/src/player/preload.test.ts new file mode 100644 index 0000000..26047fa --- /dev/null +++ b/apps/web/src/player/preload.test.ts @@ -0,0 +1,79 @@ +import type { AuthSession, Track } from '@pipu/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Player } from './player'; + +const track: Track = { + id: 'a', title: 'A', duration_ms: 200_000, artists: [], + album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] }, + genres: [], disc_no: null, track_no: null, available: true, + stream_url: '/s/a', reaction: null, features: null, +}; +const ok = (data: unknown) => ({ data, response: new Response() }); + +function fixture() { + localStorage.removeItem('pipu.player'); + const client = { + GET: vi.fn().mockResolvedValue(ok({ + track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000, + volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21', + })), + POST: vi.fn().mockResolvedValue(ok({ items: [track] })), + PUT: vi.fn().mockResolvedValue(ok({})), + }; + const audio = document.createElement('audio'); + const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio); + return { player, audio, client }; +} + +afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); }); + +describe('restored audio preparation', () => { + it('buffers at the saved position without playback or listening events, then reuses the source', async () => { + const { player, audio, client } = fixture(); + const play = vi.spyOn(audio, 'play'); + const source = vi.spyOn(audio, 'src', 'set'); + await player.restore(); + expect(audio.getAttribute('src')).toBe('/s/a'); + expect(audio.currentTime).toBe(42); + expect(audio.preload).toBe('auto'); + expect(play).not.toHaveBeenCalled(); + expect(client.PUT).not.toHaveBeenCalled(); + expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 }); + player.onHide(); + expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events. + player.play(); + expect(play).toHaveBeenCalledTimes(1); + expect(source).toHaveBeenCalledTimes(1); + expect(player.getState().playing).toBe(true); + player.dispose(); + }); + + it('does not autoplay or skip after a preload failure; Play retries the source', async () => { + const { player, audio } = fixture(); + const play = vi.spyOn(audio, 'play'); + await player.restore(); + audio.dispatchEvent(new Event('error')); + audio.dispatchEvent(new Event('ended')); + expect(play).not.toHaveBeenCalled(); + expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false }); + player.play(); + expect(audio.getAttribute('src')).toBe('/s/a'); + expect(play).toHaveBeenCalledTimes(1); + player.dispose(); + }); + + it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => { + const { player, client, audio } = fixture(); + let resolve!: (value: unknown) => void; + client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const restoring = player.restore(); + await vi.waitFor(() => expect(client.POST).toHaveBeenCalled()); + if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' }); + else player.dispose(); + resolve(ok({ items: [track] })); + await restoring; + expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null); + if (action === 'selection') player.dispose(); + }); +});