Files
pipu-music/apps/web/src/player/context.tsx
T
mrleo1nidandClaude Opus 5.5 976ffb574b
ci / ts / ts (packages + web) (push) Successful in 53s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 2m33s
feat(web): player bar, playable track lists and Collection (M3)
- 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>
2026-10-20 12:11:12 +03:00

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);
}