- player bar with cover, like and dislike, previous / play / next, shuffle, repeat, seek bar and volume; PlayerProvider lives with the signed-in shell and restores the last track and position paused - clicking a row (or Enter) plays the list from that track with its context (album, search, history, collection); the current track is highlighted and rows have a like button; a reaction refreshes lists - Collection: history grouped by day with clearing, likes with "play all" and dislikes, all with paging; sidebar section - events and playback state are flushed when the page is hidden; player timers survive StrictMode remounts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
2.9 KiB
TypeScript
79 lines
2.9 KiB
TypeScript
import { useQueryClient } from '@tanstack/react-query';
|
|
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 queryClient = useQueryClient();
|
|
const [player] = useState(() => new Player(session));
|
|
player.onReaction = () => void queryClient.invalidateQueries();
|
|
|
|
useEffect(() => {
|
|
player.start();
|
|
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);
|
|
}
|