perf(web): preload restored audio before playback
This commit is contained in:
1 parent
20abe20686
commit
b4b5ed4673
2 files changed
+119
-12
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user