perf(web): preload restored audio before playback
This commit is contained in:
1 parent
20abe20686
commit
3a097c15c8
2 files changed
+116
-9
No files matched your search
@@ -97,6 +97,7 @@ export class Player {
|
|||||||
private timers: ReturnType<typeof setInterval>[] = [];
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||||
private waveLoading = false;
|
private waveLoading = false;
|
||||||
|
private restoreVersion = 0;
|
||||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||||
onReaction: (() => void) | null = null;
|
onReaction: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -159,6 +160,7 @@ export class Player {
|
|||||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||||
const target = tracks[index];
|
const target = tracks[index];
|
||||||
if (!target || !playable.length) return;
|
if (!target || !playable.length) return;
|
||||||
|
this.restoreVersion++;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
this.context = context;
|
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 }) {
|
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||||
|
this.restoreVersion++;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
@@ -275,7 +278,7 @@ export class Player {
|
|||||||
|
|
||||||
play() {
|
play() {
|
||||||
if (!this.queue.current) return;
|
if (!this.queue.current) return;
|
||||||
if (!this.audio.src) {
|
if (!this.audio.src || !this.attempt) {
|
||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -388,9 +391,12 @@ export class Player {
|
|||||||
private async load(autoplay: boolean, startMs = 0) {
|
private async load(autoplay: boolean, startMs = 0) {
|
||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track?.stream_url) return;
|
if (!track?.stream_url) return;
|
||||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||||
this.audio.src = track.stream_url;
|
// Keep the restored element's buffer when the user presses Play.
|
||||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
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.applyLoudness();
|
||||||
this.attempt = new PlayAttempt(track, startMs);
|
this.attempt = new PlayAttempt(track, startMs);
|
||||||
this.preloaded = null;
|
this.preloaded = null;
|
||||||
@@ -401,7 +407,6 @@ export class Player {
|
|||||||
context_type: this.context?.type,
|
context_type: this.context?.type,
|
||||||
context_id: this.context?.id,
|
context_id: this.context?.id,
|
||||||
});
|
});
|
||||||
this.updateMediaMetadata(track);
|
|
||||||
this.saveServer();
|
this.saveServer();
|
||||||
void this.refillWave();
|
void this.refillWave();
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
@@ -597,22 +602,28 @@ export class Player {
|
|||||||
if (a.ended) return;
|
if (a.ended) return;
|
||||||
this.update({ playing: false });
|
this.update({ playing: false });
|
||||||
const t = this.queue.current;
|
const t = this.queue.current;
|
||||||
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
if (t && this.attempt) {
|
||||||
|
this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||||
this.saveServer();
|
this.saveServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('waiting', () => {
|
||||||
|
if (this.attempt) this.update({ loading: true });
|
||||||
});
|
});
|
||||||
a.addEventListener('waiting', () => this.update({ loading: true }));
|
|
||||||
a.addEventListener('timeupdate', () => {
|
a.addEventListener('timeupdate', () => {
|
||||||
const ms = Math.round(a.currentTime * 1000);
|
const ms = Math.round(a.currentTime * 1000);
|
||||||
this.attempt?.tick(ms);
|
this.attempt?.tick(ms);
|
||||||
this.update({ positionMs: ms });
|
this.update({ positionMs: ms });
|
||||||
this.updatePositionState();
|
this.updatePositionState();
|
||||||
|
if (this.state.playing) {
|
||||||
this.maybePreload(ms);
|
this.maybePreload(ms);
|
||||||
this.maybeCrossfade(ms);
|
this.maybeCrossfade(ms);
|
||||||
|
}
|
||||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||||
this.saveLocal();
|
this.saveLocal();
|
||||||
});
|
});
|
||||||
a.addEventListener('ended', () => {
|
a.addEventListener('ended', () => {
|
||||||
if (this.ignoreEnded || this.fading) return;
|
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||||
this.finishAttempt('play_end');
|
this.finishAttempt('play_end');
|
||||||
if (this.queue.next(true)) {
|
if (this.queue.next(true)) {
|
||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
@@ -629,11 +640,18 @@ export class Player {
|
|||||||
private async onError() {
|
private async onError() {
|
||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track) return;
|
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) {
|
if (this.refreshedFor !== track.id) {
|
||||||
this.refreshedFor = track.id;
|
this.refreshedFor = track.id;
|
||||||
try {
|
try {
|
||||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||||
const fresh = res.items[0];
|
const fresh = res.items[0];
|
||||||
|
if (this.queue.current !== track || !this.attempt) return;
|
||||||
if (fresh?.stream_url) {
|
if (fresh?.stream_url) {
|
||||||
Object.assign(track, fresh);
|
Object.assign(track, fresh);
|
||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
@@ -775,6 +793,7 @@ export class Player {
|
|||||||
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||||
*/
|
*/
|
||||||
async restore() {
|
async restore() {
|
||||||
|
const version = ++this.restoreVersion;
|
||||||
let local: Saved | null = null;
|
let local: Saved | null = null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(LOCAL_KEY);
|
const raw = localStorage.getItem(LOCAL_KEY);
|
||||||
@@ -804,8 +823,9 @@ export class Player {
|
|||||||
} catch {
|
} 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 } }));
|
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 wanted = saved.queue[saved.index];
|
||||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
@@ -819,12 +839,20 @@ export class Player {
|
|||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||||
this.updateMediaMetadata(tracks[idx]!);
|
this.updateMediaMetadata(tracks[idx]!);
|
||||||
|
// Prepare at the saved position without creating a listening attempt.
|
||||||
|
await this.load(false, pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
|
this.restoreVersion++;
|
||||||
|
this.fading = false;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
this.audio.removeAttribute('src');
|
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);
|
for (const t of this.timers) clearInterval(t);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
void this.events.flush();
|
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