- 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>
222 lines
7.7 KiB
TypeScript
222 lines
7.7 KiB
TypeScript
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;
|
||
}
|
||
}
|
||
}
|