feat(core,web): player queue, play events and web player controller (M3, wip)
- core: PlayQueue with shuffle and repeat, PlayAttempt counting only time actually played, EventBuffer with batching and backoff, uuidv7 - web: Player over HTMLAudioElement with Media Session, preload of the next track, expired-link refresh, local and server playback state, hotkeys; PlayerProvider is not wired into the shell yet - ru/en strings and icons for the player and Collection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c05e523cbe
commit
ec3c2810c8
10 files changed
+1025
-4
No files matched your search
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EventBuffer, PlayAttempt, PlayQueue, uuidv7, type Track } from './player';
|
||||
|
||||
const track = (id: string, duration_ms = 200_000): Track => ({
|
||||
id,
|
||||
title: id,
|
||||
duration_ms,
|
||||
artists: [],
|
||||
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [],
|
||||
disc_no: null,
|
||||
track_no: null,
|
||||
available: true,
|
||||
stream_url: `/s/${id}`,
|
||||
reaction: null,
|
||||
});
|
||||
|
||||
const ids = (q: PlayQueue) => q.order.map((i) => q.tracks[i]!.id);
|
||||
|
||||
describe('PlayQueue', () => {
|
||||
it('walks forward and back, stops at the end', () => {
|
||||
const q = new PlayQueue();
|
||||
q.set([track('a'), track('b'), track('c')], 1);
|
||||
expect(q.current?.id).toBe('b');
|
||||
expect(q.next()?.id).toBe('c');
|
||||
expect(q.peekNext()).toBeNull();
|
||||
expect(q.next()).toBeNull();
|
||||
expect(q.prev()?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('repeats one track only when it ends by itself', () => {
|
||||
const q = new PlayQueue();
|
||||
q.set([track('a'), track('b')]);
|
||||
q.repeat = 'one';
|
||||
expect(q.next(true)?.id).toBe('a');
|
||||
expect(q.next()?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('wraps around with repeat all', () => {
|
||||
const q = new PlayQueue();
|
||||
q.set([track('a'), track('b')], 1);
|
||||
q.repeat = 'all';
|
||||
expect(q.peekNext()?.id).toBe('a');
|
||||
expect(q.next()?.id).toBe('a');
|
||||
expect(q.prev()?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('shuffles keeping the current track first and restores order', () => {
|
||||
let seed = 0.3;
|
||||
const q = new PlayQueue(() => (seed = (seed * 9301 + 0.49297) % 1));
|
||||
q.set(['a', 'b', 'c', 'd', 'e'].map((id) => track(id)), 2);
|
||||
q.setShuffle(true);
|
||||
expect(q.current?.id).toBe('c');
|
||||
expect(ids(q)[0]).toBe('c');
|
||||
expect([...ids(q)].sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
q.next();
|
||||
const cur = q.current!.id;
|
||||
q.setShuffle(false);
|
||||
expect(q.current?.id).toBe(cur);
|
||||
expect(ids(q)).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayAttempt', () => {
|
||||
it('counts only time actually played', () => {
|
||||
const a = new PlayAttempt(track('a'));
|
||||
for (let t = 250; t <= 10_000; t += 250) a.tick(t);
|
||||
expect(a.playedMs).toBe(10_000);
|
||||
expect(a.tick(60_000)).toBe(true); // перемотка вперёд
|
||||
a.tick(60_250);
|
||||
expect(a.playedMs).toBe(10_250);
|
||||
a.seek(5000);
|
||||
a.tick(5250);
|
||||
expect(a.playedMs).toBe(10_500);
|
||||
});
|
||||
|
||||
it('treats the last seconds as finished', () => {
|
||||
const a = new PlayAttempt(track('a', 200_000));
|
||||
expect(a.outcome(196_000)).toBe('play_end');
|
||||
expect(a.outcome(120_000)).toBe('skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventBuffer', () => {
|
||||
it('sends in batches and retries with backoff', async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
|
||||
const buf = new EventBuffer({ send }, () => Date.UTC(2026, 0, 1));
|
||||
buf.push({ type: 'play_start', track_id: 't1' });
|
||||
buf.push({ type: 'skip', track_id: 't1', played_ms: 1000 });
|
||||
|
||||
expect(await buf.flush()).toBe(false);
|
||||
expect(buf.pending).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(buf.pending).toHaveLength(0);
|
||||
const sent = send.mock.calls[1]![0];
|
||||
expect(sent[0].occurred_at).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(sent[0].id).not.toBe(sent[1].id);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uuidv7', () => {
|
||||
it('encodes time and version', () => {
|
||||
const id = uuidv7(0x0190_0000_0000, () => new Uint8Array(16).fill(0xff));
|
||||
expect(id).toMatch(/^01900000-0000-7fff-bfff-ffffffffffff$/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user