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; 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; } /** * Буфер событий: отправка пачками (по таймеру, при смене трека, при уходе в фон), * при ошибке — повтор с экспоненциальной задержкой. Сервер дедуплицирует по id. */ export class EventBuffer { private queue: PlayEvent[] = []; private sending = false; private retryMs = 0; private timer: ReturnType | 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 & Partial>) { 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 { 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; } } }