veza/apps/web/src/features/player/components/GlobalPlayer.tsx
senke c1db9f03b0 feat(v0.13.4): polish audio & player — PiP canvas, visualizer, Cast/AirPlay stubs
TASK-APLSH-001: Enhanced PiP with canvas-based display showing cover art + track info
TASK-APLSH-002: Chromecast detection hook (useCastSupport) — full casting deferred
TASK-APLSH-003: AirPlay detection hook (useAirPlaySupport) — Safari target picker
TASK-APLSH-004: AudioVisualizer component with 3 modes (bars/wave/spectrogram)
  - useSpectrumAnalyser hook (64 bands, high-res FFT)
  - Canvas-based rendering with SUMI color palette
  - Integrated into PlayerExpanded with toggle button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:59:30 +01:00

224 lines
8.3 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { usePlayer } from '@/features/player/hooks/usePlayer';
import { usePictureInPicture } from '@/features/player/hooks/usePictureInPicture';
import { useKeyboardShortcuts } from '@/features/player/hooks/useKeyboardShortcuts';
import { useAudioAnalyser } from '@/features/player/hooks/useAudioAnalyser';
import { useAudioNormalization } from '@/features/player/hooks/useAudioNormalization';
import { useMediaSession } from '@/features/player/hooks/useMediaSession';
import { useWakeLock } from '@/features/player/hooks/useWakeLock';
import { useCastSupport } from '@/features/player/hooks/useCastSupport';
import { useAirPlaySupport } from '@/features/player/hooks/useAirPlaySupport';
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);
// v0.13.1 TASK-AUDIO-003: Audio normalization
useAudioNormalization(player.currentTrack);
const currentTrack = player.currentTrack;
const displayTrack = currentTrack || IDLE_TRACK;
const isIdle = !currentTrack;
const { setVideoRef: setPiPVideoRef, togglePiP, isPiPActive, isSupported: isPiPSupported, updateTrackInfo: updatePiPTrackInfo } = usePictureInPicture(
currentTrack?.cover ?? null,
);
// v0.13.4 TASK-APLSH-002/003: Cast & AirPlay detection
const cast = useCastSupport();
const airplay = useAirPlaySupport(audioEl);
// v0.13.4 TASK-APLSH-001: Update PiP canvas when track changes
useEffect(() => {
if (currentTrack && isPiPActive) {
updatePiPTrackInfo({
title: currentTrack.title,
artist: currentTrack.artist || 'Unknown Artist',
cover: currentTrack.cover,
});
}
}, [currentTrack, isPiPActive, updatePiPTrackInfo]);
useWakeLock(player.isPlaying);
const SEEK_STEP_SEC = 10;
useMediaSession({
track: currentTrack ?? null,
isPlaying: player.isPlaying,
onPlay: () => !isIdle && player.resume(),
onPause: player.pause,
onPrevious: player.previous,
onNext: player.next,
onSeekBackward: () =>
!isIdle && player.seek(Math.max(0, player.currentTime - SEEK_STEP_SEC)),
onSeekForward: () =>
!isIdle &&
player.seek(Math.min(player.duration, player.currentTime + SEEK_STEP_SEC)),
});
return (
<>
<audio ref={setAudioRef} />
{isPiPSupported && (
<video
ref={setPiPVideoRef}
className="hidden w-0 h-0"
muted
playsInline
poster={currentTrack?.cover ?? undefined}
/>
)}
<PlayerExpanded
isOpen={isExpanded}
onClose={() => setIsExpanded(false)}
currentTime={player.currentTime}
duration={player.duration}
onSeek={player.seek}
player={player}
audioElement={audioEl}
/>
<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-4 sm:bottom-6 left-2 right-2 sm:left-4 sm:right-4 z-player transition-all duration-[var(--sumi-duration-slow)] ease-[var(--sumi-ease-out)]',
'max-w-full min-w-0',
'lg:right-4 lg: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-1.5 sm:gap-2 md:gap-3 h-14 sm:h-16 px-2 sm:px-3 md:px-4 relative z-10 min-w-0 overflow-hidden 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 min-w-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}
/>
<div className="hidden sm:block">
<PlaybackSpeedControl
speed={player.playbackSpeed}
onSpeedChange={player.setPlaybackSpeed}
disabled={isIdle}
/>
</div>
<div
className={cn(
'hidden sm:flex items-center gap-1.5 text-xs font-mono text-muted-foreground whitespace-nowrap shrink-0',
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}
pipSupported={isPiPSupported}
pipActive={isPiPActive}
onTogglePiP={togglePiP}
castAvailable={cast.isAvailable}
onCast={cast.requestSession}
airplayAvailable={airplay.isAvailable}
onAirPlay={airplay.showPicker}
/>
</div>
{!isIdle && (
<PlayerBarProgress
currentTime={player.currentTime}
duration={player.duration}
onSeek={(pct) => player.seek(pct * player.duration)}
/>
)}
</PlayerBarGlass>
</div>,
document.body,
)}
</>
);
}