- docs: SCOPE_CONTROL, CONTRIBUTING, README, .github templates - frontend: DeveloperDashboardView, Player components, MSW handlers, auth, reactQuerySync - backend: playback_analytics, playlist_service, testutils, integration README Excluded (artifacts): .auth, playwright-report, test-results, storybook_audit_detailed.json
170 lines
6 KiB
TypeScript
170 lines
6 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { usePlayer } from '@/features/player/hooks/usePlayer';
|
|
import { useKeyboardShortcuts } from '@/features/player/hooks/useKeyboardShortcuts';
|
|
import { useAudioAnalyser } from '@/features/player/hooks/useAudioAnalyser';
|
|
import { useMediaSession } from '@/features/player/hooks/useMediaSession';
|
|
import { useUIStore } from '@/stores/ui';
|
|
import { formatTime } from '@/features/player/services/playerService';
|
|
import { PlayerControls } from './PlayerControls';
|
|
import { PlayerQueue } from './PlayerQueue';
|
|
import { PlayerExpanded } from './PlayerExpanded';
|
|
import {
|
|
PlayerBarGlass,
|
|
PlayerBarTrackInfo,
|
|
PlayerBarProgress,
|
|
PlayerBarRight,
|
|
} from './player-bar';
|
|
import { PlaybackSpeedControl } from './PlaybackSpeedControl';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const IDLE_TRACK = {
|
|
id: 'idle',
|
|
title: 'System Online',
|
|
artist: 'Select a track to play',
|
|
cover: '',
|
|
duration: 0,
|
|
url: '',
|
|
};
|
|
|
|
export function GlobalPlayer() {
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
|
const setAudioRef = useCallback((el: HTMLAudioElement | null) => {
|
|
(audioRef as React.MutableRefObject<HTMLAudioElement | null>).current = el;
|
|
setAudioEl(el);
|
|
}, []);
|
|
|
|
const { sidebarOpen } = useUIStore();
|
|
const player = usePlayer(audioRef);
|
|
useKeyboardShortcuts(player);
|
|
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [showQueue, setShowQueue] = useState(false);
|
|
|
|
const waveformLevels = useAudioAnalyser(audioEl, player.isPlaying);
|
|
const currentTrack = player.currentTrack;
|
|
const displayTrack = currentTrack || IDLE_TRACK;
|
|
const isIdle = !currentTrack;
|
|
|
|
useMediaSession({
|
|
track: currentTrack ?? null,
|
|
isPlaying: player.isPlaying,
|
|
onPlay: () => !isIdle && player.resume(),
|
|
onPause: player.pause,
|
|
onPrevious: player.previous,
|
|
onNext: player.next,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<audio ref={setAudioRef} />
|
|
|
|
<PlayerExpanded
|
|
isOpen={isExpanded}
|
|
onClose={() => setIsExpanded(false)}
|
|
currentTime={player.currentTime}
|
|
duration={player.duration}
|
|
onSeek={player.seek}
|
|
player={player}
|
|
/>
|
|
|
|
<PlayerQueue
|
|
isOpen={showQueue}
|
|
onClose={() => setShowQueue(false)}
|
|
currentTrackId={currentTrack?.id}
|
|
onPlay={(track) => player.play(track)}
|
|
/>
|
|
|
|
{createPortal(
|
|
<div
|
|
data-testid="global-player"
|
|
role="region"
|
|
aria-label="Global player"
|
|
className={cn(
|
|
'fixed bottom-6 left-4 right-4 z-player transition-all duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)]',
|
|
'lg:right-4 w-player-bar',
|
|
sidebarOpen ? 'lg:left-main-expanded lg:w-player-bar-expanded' : 'lg:left-main-collapsed lg:w-player-bar-collapsed',
|
|
isExpanded && 'translate-y-full opacity-0 pointer-events-none',
|
|
)}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
<PlayerBarGlass isHovered={isHovered}>
|
|
<div className="flex items-center justify-between gap-2 sm:gap-3 h-14 sm:h-16 px-3 sm:px-4 relative z-10 min-w-0 flex-nowrap">
|
|
<PlayerBarTrackInfo
|
|
title={displayTrack.title}
|
|
artist={displayTrack.artist || 'Unknown Artist'}
|
|
cover={displayTrack.cover}
|
|
isIdle={isIdle}
|
|
isPlaying={player.isPlaying}
|
|
onExpand={() => !isIdle && setIsExpanded(true)}
|
|
/>
|
|
|
|
<section
|
|
className="flex flex-col items-center justify-center gap-0.5 flex-shrink-0"
|
|
aria-label="Playback controls"
|
|
>
|
|
<PlayerControls
|
|
compact
|
|
isPlaying={player.isPlaying}
|
|
onPlayPause={() => {
|
|
if (player.isPlaying) player.pause();
|
|
else if (!isIdle) player.resume();
|
|
}}
|
|
onNext={player.next}
|
|
onPrevious={player.previous}
|
|
onShuffle={player.toggleShuffle}
|
|
onRepeat={() => {
|
|
const modes = ['off', 'track', 'playlist'] as const;
|
|
const current = player.repeat ?? 'off';
|
|
const next = modes[(modes.indexOf(current) + 1) % modes.length] ?? 'off';
|
|
player.setRepeat(next);
|
|
}}
|
|
shuffle={player.shuffle}
|
|
repeat={player.repeat}
|
|
/>
|
|
<PlaybackSpeedControl
|
|
speed={player.playbackSpeed}
|
|
onSpeedChange={player.setPlaybackSpeed}
|
|
disabled={isIdle}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-1.5 text-xs font-mono text-muted-foreground whitespace-nowrap',
|
|
isIdle ? 'opacity-50' : 'opacity-90',
|
|
)}
|
|
>
|
|
<span>{formatTime(player.currentTime)}</span>
|
|
<span className="opacity-30">/</span>
|
|
<span>{formatTime(player.duration)}</span>
|
|
</div>
|
|
</section>
|
|
|
|
<PlayerBarRight
|
|
volume={player.volume}
|
|
muted={player.muted}
|
|
onVolumeChange={player.setVolume}
|
|
onToggleMute={player.toggleMute}
|
|
showQueue={showQueue}
|
|
onToggleQueue={() => setShowQueue(!showQueue)}
|
|
waveformLevels={waveformLevels}
|
|
isPlaying={player.isPlaying}
|
|
/>
|
|
</div>
|
|
|
|
{!isIdle && (
|
|
<PlayerBarProgress
|
|
currentTime={player.currentTime}
|
|
duration={player.duration}
|
|
onSeek={(pct) => player.seek(pct * player.duration)}
|
|
/>
|
|
)}
|
|
</PlayerBarGlass>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
}
|