veza/apps/web/src/features/player/components/GlobalPlayer.tsx
senke 134b8979c0 chore(v0.102): consolidate remaining changes — docs, frontend, backend
- 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
2026-02-20 13:02:12 +01:00

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