feat(core,web): player queue, play events and web player controller (M3, wip)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 1m5s

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 07:28:20 +03:00
1 parent c05e523cbe
commit ec3c2810c8
10 files changed
+1025 -4

No files matched your search

+74
View File
@@ -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);
}