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>
224 lines
8.3 KiB
TypeScript
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,
|
|
)}
|
|
</>
|
|
);
|
|
}
|