Files
pipu-music/packages/core/src/player.ts
T
mrleo1nidandClaude Opus 5.5 eaedd1fc62
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s
feat(web): My Wave button, mood picker and endless wave playback (M4)
- WaveButton: TypeScript port of the prototype's canvas animation
  (orb and liquid), breathing at rest, pulsing to the track's BPM and
  energy in the cover palette, following the cursor, pressing with a
  spring; reduced motion and hidden tabs slow or stop it
- home: the wave in the centre with activity and mood chips; changing
  them while playing rebuilds the upcoming tracks without stopping
- player wave mode: asks for the next batch when two tracks are left,
  sends complete / skip / early skip / like / dislike as feedback and
  refreshes the queue right after a reaction or an early skip
- tracks expose bpm, energy and loudness once analysed; library page
  shows analysis progress and 7-day wave metrics; wave style setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:29:38 +03:00

222 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
/** Сколько треков осталось после текущего (без учёта повтора). */
get remaining(): number {
return Math.max(this.order.length - this.pos - 1, 0);
}
/** Добавить треки в конец (бесконечная волна). */
append(tracks: Track[]) {
const start = this.tracks.length;
this.tracks = [...this.tracks, ...tracks];
this.order = [...this.order, ...tracks.map((_, i) => start + i)];
}
/** Убрать ещё не начатые треки после текущего (волна пересобирается после реакции). */
dropUpcoming() {
const keep = this.order.slice(0, this.pos + 1);
const tracks = keep.map((i) => this.tracks[i]!);
this.tracks = tracks;
this.order = tracks.map((_, i) => i);
this.pos = Math.max(tracks.length - 1, 0);
}
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;
}
}
}