feat(core,web): player queue, play events and web player controller (M3, wip)
- 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:
1 parent
c05e523cbe
commit
ec3c2810c8
10 files changed
+1025
-4
No files matched your search
@@ -0,0 +1,74 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Player, type PlayerState } from './player';
|
||||||
|
|
||||||
|
const PlayerContext = createContext<Player | null>(null);
|
||||||
|
|
||||||
|
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
|
||||||
|
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const session = useSession();
|
||||||
|
const [player] = useState(() => new Player(session));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void player.restore().catch(() => undefined);
|
||||||
|
const onHide = () => player.onHide();
|
||||||
|
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
|
||||||
|
window.addEventListener('pagehide', onHide);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
const onKey = (e: KeyboardEvent) => handleHotkey(player, e);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', onHide);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
player.dispose();
|
||||||
|
};
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
|
return <PlayerContext.Provider value={player}>{children}</PlayerContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пробел — пауза, ←/→ — ±10 с, Shift+←/→ — трек, L — лайк (docs/web.md). */
|
||||||
|
export function handleHotkey(player: Player, e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const s = player.getState();
|
||||||
|
if (!s.track) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
if (target?.closest('button, a')) return; // пробел на кнопке нажимает её
|
||||||
|
e.preventDefault();
|
||||||
|
player.toggle();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) player.prev();
|
||||||
|
else player.seek(s.positionMs - 10_000);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) player.next();
|
||||||
|
else player.seek(s.positionMs + 10_000);
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
case 'L':
|
||||||
|
case 'д':
|
||||||
|
case 'Д':
|
||||||
|
e.preventDefault();
|
||||||
|
void player.react(s.track, s.track.reaction === 'like' ? null : 'like').catch(() => undefined);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayer(): Player {
|
||||||
|
const p = useContext(PlayerContext);
|
||||||
|
if (!p) throw new Error('usePlayer outside PlayerProvider');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerState(): PlayerState {
|
||||||
|
const p = usePlayer();
|
||||||
|
return useSyncExternalStore(p.subscribe, p.getState);
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import {
|
||||||
|
EventBuffer,
|
||||||
|
PlayAttempt,
|
||||||
|
PlayQueue,
|
||||||
|
unwrap,
|
||||||
|
type AuthSession,
|
||||||
|
type PlayContext,
|
||||||
|
type RepeatMode,
|
||||||
|
type Track,
|
||||||
|
} from '@pipu/core';
|
||||||
|
|
||||||
|
export type { PlayContext, Track };
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
track: Track | null;
|
||||||
|
queue: Track[];
|
||||||
|
index: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
playing: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
positionMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
volume: number;
|
||||||
|
muted: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: RepeatMode;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_KEY = 'pipu.player';
|
||||||
|
const SAVE_EVERY_MS = 10_000;
|
||||||
|
const FLUSH_EVERY_MS = 30_000;
|
||||||
|
const PRELOAD_BEFORE_MS = 20_000;
|
||||||
|
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
||||||
|
const MAX_SAVED_QUEUE = 500;
|
||||||
|
|
||||||
|
interface Saved {
|
||||||
|
queue: string[];
|
||||||
|
index: number;
|
||||||
|
positionMs: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
volume: number;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: RepeatMode;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceId(): string {
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem('pipu.device');
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
localStorage.setItem('pipu.device', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
|
||||||
|
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||||
|
*/
|
||||||
|
export class Player {
|
||||||
|
private readonly audio: HTMLAudioElement;
|
||||||
|
private readonly queue = new PlayQueue();
|
||||||
|
private readonly events: EventBuffer;
|
||||||
|
private attempt: PlayAttempt | null = null;
|
||||||
|
private context: PlayContext | null = null;
|
||||||
|
private state: PlayerState;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private readonly device = deviceId();
|
||||||
|
private lastServerSave = 0;
|
||||||
|
private preloaded: string | null = null;
|
||||||
|
private refreshedFor: string | null = null;
|
||||||
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly session: AuthSession,
|
||||||
|
audio?: HTMLAudioElement,
|
||||||
|
) {
|
||||||
|
this.audio = audio ?? new Audio();
|
||||||
|
this.audio.preload = 'auto';
|
||||||
|
this.events = new EventBuffer({
|
||||||
|
send: async (events) => {
|
||||||
|
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.state = {
|
||||||
|
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
|
||||||
|
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
|
||||||
|
};
|
||||||
|
this.bindAudio();
|
||||||
|
this.bindMediaSession();
|
||||||
|
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- подписка (useSyncExternalStore) ---
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private update(patch: Partial<PlayerState>) {
|
||||||
|
this.state = { ...this.state, ...patch };
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncQueue() {
|
||||||
|
this.update({
|
||||||
|
track: this.queue.current ?? null,
|
||||||
|
queue: this.queue.tracks,
|
||||||
|
index: this.queue.index,
|
||||||
|
shuffle: this.queue.shuffle,
|
||||||
|
repeat: this.queue.repeat,
|
||||||
|
context: this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- управление ---
|
||||||
|
|
||||||
|
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
|
||||||
|
playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
|
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||||
|
const target = tracks[index];
|
||||||
|
if (!target || !playable.length) return;
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.context = context;
|
||||||
|
this.queue.set(playable, Math.max(playable.indexOf(target), 0));
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.state.playing) this.pause();
|
||||||
|
else this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (!this.queue.current) return;
|
||||||
|
if (!this.audio.src) {
|
||||||
|
void this.load(true, this.state.positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.audio.play().catch(() => this.update({ playing: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.audio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
if (this.queue.next()) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
|
||||||
|
prev() {
|
||||||
|
if (this.audio.currentTime > 3) {
|
||||||
|
this.seek(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
if (this.queue.prev()) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jump(index: number) {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.queue.jump(index);
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(ms: number) {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track) return;
|
||||||
|
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||||
|
const from = Math.round(this.audio.currentTime * 1000);
|
||||||
|
this.attempt?.seek(to);
|
||||||
|
this.audio.currentTime = to / 1000;
|
||||||
|
this.update({ positionMs: to });
|
||||||
|
this.events.push({ type: 'seek', track_id: track.id, from_ms: from, position_ms: to });
|
||||||
|
this.saveSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(v: number) {
|
||||||
|
this.audio.volume = Math.min(Math.max(v, 0), 1);
|
||||||
|
this.audio.muted = false;
|
||||||
|
this.update({ volume: this.audio.volume, muted: false });
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
this.audio.muted = !this.audio.muted;
|
||||||
|
this.update({ muted: this.audio.muted });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShuffle() {
|
||||||
|
this.queue.setShuffle(!this.queue.shuffle);
|
||||||
|
this.syncQueue();
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleRepeat() {
|
||||||
|
const order: RepeatMode[] = ['off', 'all', 'one'];
|
||||||
|
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
|
||||||
|
this.syncQueue();
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лайк / дизлайк / снять реакцию. Дизлайк текущего трека сразу переключает на следующий. */
|
||||||
|
async react(track: Track, reaction: 'like' | 'dislike' | null) {
|
||||||
|
const prev = track.reaction;
|
||||||
|
const apply = (r: Track['reaction']) => {
|
||||||
|
for (const t of this.queue.tracks) if (t.id === track.id) t.reaction = r;
|
||||||
|
this.syncQueue();
|
||||||
|
};
|
||||||
|
apply(reaction);
|
||||||
|
const type = reaction ?? (prev === 'like' ? 'unlike' : 'undislike');
|
||||||
|
this.events.push({ type, track_id: track.id, position_ms: this.state.positionMs });
|
||||||
|
try {
|
||||||
|
const params = { params: { path: { id: track.id } } };
|
||||||
|
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
|
||||||
|
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
|
||||||
|
} catch (e) {
|
||||||
|
apply(prev);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- загрузка трека ---
|
||||||
|
|
||||||
|
private async load(autoplay: boolean, startMs = 0) {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track?.stream_url) return;
|
||||||
|
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||||
|
this.audio.src = track.stream_url;
|
||||||
|
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||||
|
this.attempt = new PlayAttempt(track, startMs);
|
||||||
|
this.preloaded = null;
|
||||||
|
this.events.push({
|
||||||
|
type: 'play_start',
|
||||||
|
track_id: track.id,
|
||||||
|
position_ms: startMs,
|
||||||
|
context_type: this.context?.type,
|
||||||
|
context_id: this.context?.id,
|
||||||
|
});
|
||||||
|
this.updateMediaMetadata(track);
|
||||||
|
this.saveServer();
|
||||||
|
if (autoplay) {
|
||||||
|
try {
|
||||||
|
await this.audio.play();
|
||||||
|
} catch {
|
||||||
|
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||||
|
this.update({ playing: false, loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
||||||
|
private finishAttempt(type: 'play_end' | 'skip') {
|
||||||
|
const a = this.attempt;
|
||||||
|
if (!a) return;
|
||||||
|
this.attempt = null;
|
||||||
|
const pos = Math.round(this.audio.currentTime * 1000);
|
||||||
|
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
|
||||||
|
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindAudio() {
|
||||||
|
const a = this.audio;
|
||||||
|
a.addEventListener('playing', () => {
|
||||||
|
this.update({ playing: true, loading: false });
|
||||||
|
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
|
||||||
|
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('pause', () => {
|
||||||
|
if (a.ended) return;
|
||||||
|
this.update({ playing: false });
|
||||||
|
const t = this.queue.current;
|
||||||
|
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||||
|
this.saveServer();
|
||||||
|
});
|
||||||
|
a.addEventListener('waiting', () => this.update({ loading: true }));
|
||||||
|
a.addEventListener('timeupdate', () => {
|
||||||
|
const ms = Math.round(a.currentTime * 1000);
|
||||||
|
this.attempt?.tick(ms);
|
||||||
|
this.update({ positionMs: ms });
|
||||||
|
this.updatePositionState();
|
||||||
|
this.maybePreload(ms);
|
||||||
|
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||||
|
this.saveLocal();
|
||||||
|
});
|
||||||
|
a.addEventListener('ended', () => {
|
||||||
|
this.finishAttempt('play_end');
|
||||||
|
if (this.queue.next(true)) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.update({ playing: false, positionMs: 0 });
|
||||||
|
this.saveServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('error', () => void this.onError());
|
||||||
|
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||||
|
private async onError() {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track) return;
|
||||||
|
if (this.refreshedFor !== track.id) {
|
||||||
|
this.refreshedFor = track.id;
|
||||||
|
try {
|
||||||
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||||
|
const fresh = res.items[0];
|
||||||
|
if (fresh?.stream_url) {
|
||||||
|
Object.assign(track, fresh);
|
||||||
|
void this.load(true, this.state.positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// упадём в пропуск ниже
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.update({ error: 'player.error', loading: false, playing: false });
|
||||||
|
this.attempt = null;
|
||||||
|
if (this.queue.peekNext() && this.queue.peekNext()!.id !== track.id) setTimeout(() => this.next(), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** За 20 с до конца запрашиваем первый байт следующего трека — на сервере прогревается транскод. */
|
||||||
|
private maybePreload(ms: number) {
|
||||||
|
const cur = this.queue.current;
|
||||||
|
const next = this.queue.peekNext();
|
||||||
|
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
|
||||||
|
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
|
||||||
|
this.preloaded = next.id;
|
||||||
|
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Media Session ---
|
||||||
|
|
||||||
|
private bindMediaSession() {
|
||||||
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
|
if (!ms) return;
|
||||||
|
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
|
||||||
|
try {
|
||||||
|
ms.setActionHandler(action, fn);
|
||||||
|
} catch {
|
||||||
|
// действие не поддерживается браузером
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set('play', () => this.play());
|
||||||
|
set('pause', () => this.pause());
|
||||||
|
set('nexttrack', () => this.next());
|
||||||
|
set('previoustrack', () => this.prev());
|
||||||
|
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
|
||||||
|
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
|
||||||
|
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMediaMetadata(track: Track) {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
|
||||||
|
const cover = track.album.cover_url;
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artists.map((a) => a.name).join(', '),
|
||||||
|
album: track.album.title,
|
||||||
|
artwork: cover
|
||||||
|
? [
|
||||||
|
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
|
||||||
|
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePositionState() {
|
||||||
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
|
const d = this.audio.duration;
|
||||||
|
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
|
||||||
|
try {
|
||||||
|
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
|
||||||
|
} catch {
|
||||||
|
// некорректная позиция во время смены трека
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- сохранение состояния ---
|
||||||
|
|
||||||
|
private snapshot(): Saved {
|
||||||
|
const ids = this.queue.tracks.map((t) => t.id);
|
||||||
|
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
|
||||||
|
return {
|
||||||
|
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
|
||||||
|
index: this.queue.index - start,
|
||||||
|
positionMs: this.state.positionMs,
|
||||||
|
context: this.context,
|
||||||
|
volume: this.state.volume,
|
||||||
|
shuffle: this.queue.shuffle,
|
||||||
|
repeat: this.queue.repeat,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveLocal() {
|
||||||
|
if (!this.queue.current) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
|
||||||
|
} catch {
|
||||||
|
// недоступное хранилище не мешает воспроизведению
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private saveSoon() {
|
||||||
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
|
||||||
|
saveServer(keepalive = false) {
|
||||||
|
const cur = this.queue.current;
|
||||||
|
if (!cur) return;
|
||||||
|
this.lastServerSave = Date.now();
|
||||||
|
this.saveLocal();
|
||||||
|
const s = this.snapshot();
|
||||||
|
void this.session.client
|
||||||
|
.PUT('/api/v1/me/playback-state', {
|
||||||
|
body: {
|
||||||
|
track_id: cur.id,
|
||||||
|
position_ms: s.positionMs,
|
||||||
|
queue: s.queue,
|
||||||
|
queue_index: s.index,
|
||||||
|
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
|
||||||
|
shuffle: s.shuffle,
|
||||||
|
repeat_mode: s.repeat,
|
||||||
|
volume: s.volume,
|
||||||
|
device_id: this.device,
|
||||||
|
},
|
||||||
|
keepalive,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Уход со страницы: дослать события и состояние. */
|
||||||
|
onHide() {
|
||||||
|
this.saveServer(true);
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстановление после запуска: сначала локальная копия (мгновенно), затем серверная,
|
||||||
|
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||||
|
*/
|
||||||
|
async restore() {
|
||||||
|
let local: Saved | null = null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_KEY);
|
||||||
|
local = raw ? (JSON.parse(raw) as Saved) : null;
|
||||||
|
} catch {
|
||||||
|
local = null;
|
||||||
|
}
|
||||||
|
let saved = local;
|
||||||
|
try {
|
||||||
|
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||||
|
if (
|
||||||
|
server.track_id &&
|
||||||
|
server.updated_at &&
|
||||||
|
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
|
||||||
|
) {
|
||||||
|
saved = {
|
||||||
|
queue: server.queue,
|
||||||
|
index: server.queue_index,
|
||||||
|
positionMs: server.position_ms,
|
||||||
|
context: (server.context?.type ? server.context : null) as PlayContext | null,
|
||||||
|
volume: server.volume,
|
||||||
|
shuffle: server.shuffle,
|
||||||
|
repeat: server.repeat_mode,
|
||||||
|
savedAt: server.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// офлайн — остаёмся с локальной копией
|
||||||
|
}
|
||||||
|
if (!saved?.queue.length) return;
|
||||||
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||||
|
const wanted = saved.queue[saved.index];
|
||||||
|
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
|
||||||
|
this.context = saved.context;
|
||||||
|
this.queue.repeat = saved.repeat;
|
||||||
|
this.queue.set(tracks, idx);
|
||||||
|
if (saved.shuffle) this.queue.setShuffle(true);
|
||||||
|
this.audio.volume = saved.volume;
|
||||||
|
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
|
||||||
|
this.syncQueue();
|
||||||
|
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||||
|
this.updateMediaMetadata(tracks[idx]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.audio.pause();
|
||||||
|
this.audio.removeAttribute('src');
|
||||||
|
for (const t of this.timers) clearInterval(t);
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,3 +32,17 @@ export { default as personIcon } from '@material-symbols/svg-400/rounded/person.
|
|||||||
export { default as refreshIcon } from '@material-symbols/svg-400/rounded/refresh.svg';
|
export { default as refreshIcon } from '@material-symbols/svg-400/rounded/refresh.svg';
|
||||||
export { default as storageIcon } from '@material-symbols/svg-400/rounded/hard_drive.svg';
|
export { default as storageIcon } from '@material-symbols/svg-400/rounded/hard_drive.svg';
|
||||||
export { default as storageFillIcon } from '@material-symbols/svg-400/rounded/hard_drive-fill.svg';
|
export { default as storageFillIcon } from '@material-symbols/svg-400/rounded/hard_drive-fill.svg';
|
||||||
|
export { default as pauseIcon } from '@material-symbols/svg-400/rounded/pause-fill.svg';
|
||||||
|
export { default as nextIcon } from '@material-symbols/svg-400/rounded/skip_next-fill.svg';
|
||||||
|
export { default as prevIcon } from '@material-symbols/svg-400/rounded/skip_previous-fill.svg';
|
||||||
|
export { default as likeIcon } from '@material-symbols/svg-400/rounded/favorite.svg';
|
||||||
|
export { default as likeFillIcon } from '@material-symbols/svg-400/rounded/favorite-fill.svg';
|
||||||
|
export { default as dislikeIcon } from '@material-symbols/svg-400/rounded/heart_broken.svg';
|
||||||
|
export { default as dislikeFillIcon } from '@material-symbols/svg-400/rounded/heart_broken-fill.svg';
|
||||||
|
export { default as volumeIcon } from '@material-symbols/svg-400/rounded/volume_up.svg';
|
||||||
|
export { default as volumeOffIcon } from '@material-symbols/svg-400/rounded/volume_off.svg';
|
||||||
|
export { default as shuffleIcon } from '@material-symbols/svg-400/rounded/shuffle.svg';
|
||||||
|
export { default as repeatIcon } from '@material-symbols/svg-400/rounded/repeat.svg';
|
||||||
|
export { default as repeatOneIcon } from '@material-symbols/svg-400/rounded/repeat_one.svg';
|
||||||
|
export { default as historyIcon } from '@material-symbols/svg-400/rounded/history.svg';
|
||||||
|
export { default as historyFillIcon } from '@material-symbols/svg-400/rounded/history-fill.svg';
|
||||||
@@ -10,4 +10,15 @@ export {
|
|||||||
type ClientType,
|
type ClientType,
|
||||||
type RefreshStore,
|
type RefreshStore,
|
||||||
} from './auth';
|
} 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';
|
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
|
||||||
@@ -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$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,11 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"audit": "Audit log",
|
"audit": "Audit log",
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
"sources": "Storage"
|
"sources": "Storage",
|
||||||
|
"collection": "Collection",
|
||||||
|
"history": "History",
|
||||||
|
"likes": "Likes",
|
||||||
|
"dislikes": "Dislikes"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"menu": "Profile menu",
|
"menu": "Profile menu",
|
||||||
@@ -43,7 +47,26 @@
|
|||||||
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
||||||
},
|
},
|
||||||
"player": {
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -117,5 +140,20 @@
|
|||||||
"back_home": "Back home",
|
"back_home": "Back home",
|
||||||
"artist": "Artist",
|
"artist": "Artist",
|
||||||
"album": "Album"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,5 +61,10 @@
|
|||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"unavailable": "Search is temporarily unavailable"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,11 @@
|
|||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"audit": "Журнал",
|
"audit": "Журнал",
|
||||||
"library": "Библиотека",
|
"library": "Библиотека",
|
||||||
"sources": "Хранилища"
|
"sources": "Хранилища",
|
||||||
|
"collection": "Коллекция",
|
||||||
|
"history": "История",
|
||||||
|
"likes": "Лайки",
|
||||||
|
"dislikes": "Дизлайки"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"menu": "Меню профиля",
|
"menu": "Меню профиля",
|
||||||
@@ -43,7 +47,26 @@
|
|||||||
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
||||||
},
|
},
|
||||||
"player": {
|
"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": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
@@ -117,5 +140,20 @@
|
|||||||
"back_home": "На главную",
|
"back_home": "На главную",
|
||||||
"artist": "Исполнитель",
|
"artist": "Исполнитель",
|
||||||
"album": "Альбом"
|
"album": "Альбом"
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"title": "Коллекция",
|
||||||
|
"history": "История",
|
||||||
|
"likes": "Лайки",
|
||||||
|
"dislikes": "Дизлайки",
|
||||||
|
"clear_history": "Очистить историю",
|
||||||
|
"clear_confirm": "Очистить историю прослушиваний? Рекомендации её всё равно учтут.",
|
||||||
|
"today": "Сегодня",
|
||||||
|
"yesterday": "Вчера",
|
||||||
|
"empty_history": "Здесь появится то, что вы слушали",
|
||||||
|
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
|
||||||
|
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
|
||||||
|
"play_all": "Слушать всё",
|
||||||
|
"more": "Показать ещё"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,5 +61,10 @@
|
|||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"unavailable": "Поиск временно недоступен"
|
"unavailable": "Поиск временно недоступен"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"link_invalid": "Ссылка устарела — обновите страницу",
|
||||||
|
"track_unavailable": "Трек недоступен",
|
||||||
|
"transcode_failed": "Не удалось подготовить трек"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user