diff --git a/apps/web/src/player/context.tsx b/apps/web/src/player/context.tsx new file mode 100644 index 0000000..36c034a --- /dev/null +++ b/apps/web/src/player/context.tsx @@ -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(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 {children}; +} + +/** Пробел — пауза, ←/→ — ±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); +} diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts new file mode 100644 index 0000000..8538bdc --- /dev/null +++ b/apps/web/src/player/player.ts @@ -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[] = []; + + 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) { + 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 | 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(); + } +} diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts index 4e6d9db..500b587 100644 --- a/apps/web/src/shared/ui/icons.ts +++ b/apps/web/src/shared/ui/icons.ts @@ -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 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 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'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ec4ecaa..65765f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,4 +10,15 @@ export { type ClientType, type RefreshStore, } 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'; diff --git a/packages/core/src/player.test.ts b/packages/core/src/player.test.ts new file mode 100644 index 0000000..41791b0 --- /dev/null +++ b/packages/core/src/player.test.ts @@ -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$/); + }); +}); diff --git a/packages/core/src/player.ts b/packages/core/src/player.ts new file mode 100644 index 0000000..9868450 --- /dev/null +++ b/packages/core/src/player.ts @@ -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; 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; +} + +/** + * Буфер событий: отправка пачками (по таймеру, при смене трека, при уходе в фон), + * при ошибке — повтор с экспоненциальной задержкой. Сервер дедуплицирует по 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; + } + } +} diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 5cd4ff6..6ef6cf9 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -25,7 +25,11 @@ "users": "Users", "audit": "Audit log", "library": "Library", - "sources": "Storage" + "sources": "Storage", + "collection": "Collection", + "history": "History", + "likes": "Likes", + "dislikes": "Dislikes" }, "profile": { "menu": "Profile menu", @@ -43,7 +47,26 @@ "wave_soon": "An endless stream tuned to your taste will appear once the server has a library." }, "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": { "title": "Settings", @@ -117,5 +140,20 @@ "back_home": "Back home", "artist": "Artist", "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" } } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index d62d2f4..6f8f4c4 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -61,5 +61,10 @@ }, "search": { "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" } } diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 210cb00..9a8e792 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -25,7 +25,11 @@ "users": "Пользователи", "audit": "Журнал", "library": "Библиотека", - "sources": "Хранилища" + "sources": "Хранилища", + "collection": "Коллекция", + "history": "История", + "likes": "Лайки", + "dislikes": "Дизлайки" }, "profile": { "menu": "Меню профиля", @@ -43,7 +47,26 @@ "wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку." }, "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": { "title": "Настройки", @@ -117,5 +140,20 @@ "back_home": "На главную", "artist": "Исполнитель", "album": "Альбом" + }, + "collection": { + "title": "Коллекция", + "history": "История", + "likes": "Лайки", + "dislikes": "Дизлайки", + "clear_history": "Очистить историю", + "clear_confirm": "Очистить историю прослушиваний? Рекомендации её всё равно учтут.", + "today": "Сегодня", + "yesterday": "Вчера", + "empty_history": "Здесь появится то, что вы слушали", + "empty_likes": "Отмечайте треки сердечком — они соберутся здесь", + "empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет", + "play_all": "Слушать всё", + "more": "Показать ещё" } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 3a34eda..8ec77bd 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -61,5 +61,10 @@ }, "search": { "unavailable": "Поиск временно недоступен" + }, + "playback": { + "link_invalid": "Ссылка устарела — обновите страницу", + "track_unavailable": "Трек недоступен", + "transcode_failed": "Не удалось подготовить трек" } }