Files
pipu-music/packages/core/src/player.ts
T
mrleo1nid 20abe20686
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m32s
ci / ts / ts (packages + web) (push) Successful in 2m49s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 4m20s
fix(mobile): prevent crash when restoring a saved playback queue
2026-10-21 18:41:48 +03:00

223 lines
7.8 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,
private readonly createId: (now: number) => string = uuidv7,
) {}
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 ?? this.createId(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;
}
}
}