feat(core,web): player queue, play events and web player controller (M3, wip)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 1m5s

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 07:28:20 +03:00
1 parent c05e523cbe
commit ec3c2810c8
10 files changed
+1025 -4

No files matched your search

+11
View File
@@ -10,4 +10,15 @@ export {
type ClientType,
type RefreshStore,
} from './auth';
export {
EventBuffer,
PlayAttempt,
PlayQueue,
uuidv7,
type EventSink,
type PlayContext,
type PlayEvent,
type RepeatMode,
type Track,
} from './player';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
+110
View File
@@ -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$/);
});
});
+200
View File
@@ -0,0 +1,200 @@
import type { components } from '@pipu/api-client';
export type Track = components['schemas']['Track'];
export type PlayEvent = components['schemas']['PlayEvent'];
export type RepeatMode = 'off' | 'one' | 'all';
export type PlayContext = { type: NonNullable<PlayEvent['context_type']>; id?: string; label?: string };
/** UUIDv7: время в первых 48 битах — сервер дедуплицирует события по id, сортировка по времени. */
export function uuidv7(now = Date.now(), rnd: (n: number) => Uint8Array = randomBytes): string {
const b = rnd(16);
const ts = BigInt(now);
for (let i = 0; i < 6; i++) b[i] = Number((ts >> BigInt(8 * (5 - i))) & 0xffn);
b[6] = (b[6]! & 0x0f) | 0x70;
b[8] = (b[8]! & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
function randomBytes(n: number): Uint8Array {
const b = new Uint8Array(n);
globalThis.crypto.getRandomValues(b);
return b;
}
/**
* Очередь воспроизведения. Перемешивание хранится порядком индексов: текущий трек остаётся текущим,
* а выключение возвращает исходный порядок.
*/
export class PlayQueue {
tracks: Track[] = [];
/** Позиция в order. */
pos = 0;
order: number[] = [];
shuffle = false;
repeat: RepeatMode = 'off';
constructor(private readonly random: () => number = Math.random) {}
get current(): Track | undefined {
return this.tracks[this.order[this.pos] ?? -1];
}
/** Индекс текущего трека в исходном списке. */
get index(): number {
return this.order[this.pos] ?? 0;
}
set(tracks: Track[], index = 0) {
this.tracks = tracks;
this.order = tracks.map((_, i) => i);
this.pos = Math.min(Math.max(index, 0), Math.max(tracks.length - 1, 0));
if (this.shuffle) this.reshuffle();
}
setShuffle(on: boolean) {
const cur = this.index;
this.shuffle = on;
this.order = this.tracks.map((_, i) => i);
this.pos = cur;
if (on) this.reshuffle();
}
private reshuffle() {
const cur = this.order[this.pos] ?? 0;
const rest = this.order.filter((i) => i !== cur);
for (let i = rest.length - 1; i > 0; i--) {
const j = Math.floor(this.random() * (i + 1));
[rest[i], rest[j]] = [rest[j]!, rest[i]!];
}
this.order = this.tracks.length ? [cur, ...rest] : [];
this.pos = 0;
}
/** Следующий трек. auto — трек доиграл сам (repeat one повторяет его). null — очередь кончилась. */
next(auto = false): Track | null {
if (!this.tracks.length) return null;
if (auto && this.repeat === 'one') return this.current ?? null;
if (this.pos + 1 < this.order.length) {
this.pos++;
return this.current ?? null;
}
if (this.repeat === 'all') {
this.pos = 0;
return this.current ?? null;
}
return null;
}
/** Предыдущий трек (с начала очереди при repeat all — последний). */
prev(): Track | null {
if (!this.tracks.length) return null;
if (this.pos > 0) this.pos--;
else if (this.repeat === 'all') this.pos = this.order.length - 1;
return this.current ?? null;
}
/** Что сыграет после текущего — для предзагрузки. */
peekNext(): Track | null {
if (this.repeat === 'one') return this.current ?? null;
const i = this.pos + 1 < this.order.length ? this.pos + 1 : this.repeat === 'all' ? 0 : -1;
return i < 0 ? null : (this.tracks[this.order[i]!] ?? null);
}
jump(index: number) {
const p = this.order.indexOf(index);
if (p >= 0) this.pos = p;
}
}
/** Скачок позиции больше этого — перемотка, а не воспроизведение. */
const SEEK_GAP_MS = 2000;
/** Осталось меньше этого — трек доигран (play_end, а не skip). */
const END_SLACK_MS = 5000;
/**
* Учёт попытки прослушивания одного трека: played_ms — реально прослушанное время,
* перемотки вперёд не засчитываются.
*/
export class PlayAttempt {
playedMs = 0;
private last: number;
constructor(
readonly track: Track,
startMs = 0,
) {
this.last = startMs;
}
/** Новая позиция воспроизведения (timeupdate). Возвращает true, если это была перемотка. */
tick(positionMs: number): boolean {
const delta = positionMs - this.last;
this.last = positionMs;
if (delta > 0 && delta <= SEEK_GAP_MS) {
this.playedMs += delta;
return false;
}
return delta !== 0;
}
/** Явная перемотка: позиция меняется без засчитывания. */
seek(positionMs: number) {
this.last = positionMs;
}
/** Тип завершения попытки по позиции. */
outcome(positionMs: number): 'play_end' | 'skip' {
return this.track.duration_ms - positionMs <= END_SLACK_MS ? 'play_end' : 'skip';
}
}
export interface EventSink {
send(events: PlayEvent[]): Promise<void>;
}
/**
* Буфер событий: отправка пачками (по таймеру, при смене трека, при уходе в фон),
* при ошибке — повтор с экспоненциальной задержкой. Сервер дедуплицирует по id.
*/
export class EventBuffer {
private queue: PlayEvent[] = [];
private sending = false;
private retryMs = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly sink: EventSink,
private readonly now: () => number = Date.now,
private readonly maxRetryMs = 5 * 60_000,
) {}
get pending(): readonly PlayEvent[] {
return this.queue;
}
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
const t = this.now();
this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
if (this.queue.length >= 500) void this.flush();
}
async flush(): Promise<boolean> {
if (this.sending || this.queue.length === 0) return true;
this.sending = true;
const batch = this.queue.slice(0, 500);
try {
await this.sink.send(batch);
this.queue = this.queue.slice(batch.length);
this.retryMs = 0;
return true;
} catch {
this.retryMs = Math.min(this.retryMs ? this.retryMs * 2 : 2000, this.maxRetryMs);
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => void this.flush(), this.retryMs);
return false;
} finally {
this.sending = false;
}
}
}
+40 -2
View File
@@ -25,7 +25,11 @@
"users": "Users",
"audit": "Audit log",
"library": "Library",
"sources": "Storage"
"sources": "Storage",
"collection": "Collection",
"history": "History",
"likes": "Likes",
"dislikes": "Dislikes"
},
"profile": {
"menu": "Profile menu",
@@ -43,7 +47,26 @@
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
},
"player": {
"empty": "Nothing is playing"
"empty": "Nothing is playing",
"label": "Player",
"play": "Play",
"pause": "Pause",
"next": "Next",
"prev": "Previous",
"like": "Like",
"unlike": "Remove from likes",
"dislike": "Dislike",
"undislike": "Remove dislike",
"volume": "Volume",
"mute": "Mute",
"unmute": "Unmute",
"shuffle": "Shuffle",
"repeat_off": "Repeat off",
"repeat_all": "Repeat queue",
"repeat_one": "Repeat track",
"progress": "Position",
"unavailable": "Track is unavailable — skipping",
"error": "Could not play the track"
},
"settings": {
"title": "Settings",
@@ -117,5 +140,20 @@
"back_home": "Back home",
"artist": "Artist",
"album": "Album"
},
"collection": {
"title": "Collection",
"history": "History",
"likes": "Likes",
"dislikes": "Dislikes",
"clear_history": "Clear history",
"clear_confirm": "Clear your listening history? Recommendations will still take it into account.",
"today": "Today",
"yesterday": "Yesterday",
"empty_history": "What you listen to will show up here",
"empty_likes": "Tap the heart on tracks — they will gather here",
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
"play_all": "Play all",
"more": "Show more"
}
}
+5
View File
@@ -61,5 +61,10 @@
},
"search": {
"unavailable": "Search is temporarily unavailable"
},
"playback": {
"link_invalid": "The link has expired — reload the page",
"track_unavailable": "Track is unavailable",
"transcode_failed": "Could not prepare the track"
}
}
+40 -2
View File
@@ -25,7 +25,11 @@
"users": "Пользователи",
"audit": "Журнал",
"library": "Библиотека",
"sources": "Хранилища"
"sources": "Хранилища",
"collection": "Коллекция",
"history": "История",
"likes": "Лайки",
"dislikes": "Дизлайки"
},
"profile": {
"menu": "Меню профиля",
@@ -43,7 +47,26 @@
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
},
"player": {
"empty": "Ничего не играет"
"empty": "Ничего не играет",
"label": "Плеер",
"play": "Слушать",
"pause": "Пауза",
"next": "Следующий",
"prev": "Предыдущий",
"like": "Нравится",
"unlike": "Убрать из лайков",
"dislike": "Не нравится",
"undislike": "Убрать дизлайк",
"volume": "Громкость",
"mute": "Выключить звук",
"unmute": "Включить звук",
"shuffle": "Перемешать",
"repeat_off": "Повтор выключен",
"repeat_all": "Повторять очередь",
"repeat_one": "Повторять трек",
"progress": "Позиция",
"unavailable": "Трек недоступен — пропускаем",
"error": "Не удалось воспроизвести трек"
},
"settings": {
"title": "Настройки",
@@ -117,5 +140,20 @@
"back_home": "На главную",
"artist": "Исполнитель",
"album": "Альбом"
},
"collection": {
"title": "Коллекция",
"history": "История",
"likes": "Лайки",
"dislikes": "Дизлайки",
"clear_history": "Очистить историю",
"clear_confirm": "Очистить историю прослушиваний? Рекомендации её всё равно учтут.",
"today": "Сегодня",
"yesterday": "Вчера",
"empty_history": "Здесь появится то, что вы слушали",
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
"play_all": "Слушать всё",
"more": "Показать ещё"
}
}
+5
View File
@@ -61,5 +61,10 @@
},
"search": {
"unavailable": "Поиск временно недоступен"
},
"playback": {
"link_invalid": "Ссылка устарела — обновите страницу",
"track_unavailable": "Трек недоступен",
"transcode_failed": "Не удалось подготовить трек"
}
}