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);
|
||||
}
|
||||
Reference in new issue
Block a user