776 lines
24 KiB
TypeScript
776 lines
24 KiB
TypeScript
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { Progress } from '@/components/ui/progress'
|
|
import { Slider } from '@/components/ui/slider'
|
|
import { filesService } from '@/lib/api/services/files'
|
|
import {
|
|
type MessageResponse,
|
|
type PlayerMode,
|
|
type PlayerState,
|
|
playerService,
|
|
} from '@/lib/api/services/player'
|
|
import { soundsService } from '@/lib/api/services/sounds'
|
|
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
ArrowRight,
|
|
ArrowRightToLine,
|
|
Download,
|
|
ExternalLink,
|
|
List,
|
|
Maximize2,
|
|
Minimize2,
|
|
MoreVertical,
|
|
Music,
|
|
Pause,
|
|
Play,
|
|
Repeat,
|
|
Repeat1,
|
|
Shuffle,
|
|
SkipBack,
|
|
SkipForward,
|
|
Square,
|
|
Volume2,
|
|
VolumeX,
|
|
} from 'lucide-react'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { Playlist } from './Playlist'
|
|
import { NumberFlowDuration } from '../ui/number-flow-duration'
|
|
|
|
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
|
|
|
|
interface PlayerProps {
|
|
className?: string
|
|
onPlayerModeChange?: (mode: PlayerDisplayMode) => void
|
|
}
|
|
|
|
export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|
const [state, setState] = useState<PlayerState>({
|
|
status: 'stopped',
|
|
mode: 'continuous',
|
|
volume: 80,
|
|
previous_volume: 80,
|
|
position: 0,
|
|
})
|
|
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
|
|
// Initialize from localStorage or default to 'normal'
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem(
|
|
'playerDisplayMode',
|
|
) as PlayerDisplayMode
|
|
return saved &&
|
|
['normal', 'minimized', 'maximized', 'sidebar'].includes(saved)
|
|
? saved
|
|
: 'normal'
|
|
}
|
|
return 'normal'
|
|
})
|
|
|
|
// Notify parent when display mode changes and save to localStorage
|
|
useEffect(() => {
|
|
onPlayerModeChange?.(displayMode)
|
|
// Save to localStorage
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('playerDisplayMode', displayMode)
|
|
}
|
|
}, [displayMode, onPlayerModeChange])
|
|
const [showPlaylist, setShowPlaylist] = useState(false)
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
// Load initial state
|
|
useEffect(() => {
|
|
const loadState = async () => {
|
|
try {
|
|
const initialState = await playerService.getState()
|
|
setState(initialState)
|
|
} catch (error) {
|
|
console.error('Failed to load player state:', error)
|
|
}
|
|
}
|
|
loadState()
|
|
}, [])
|
|
|
|
// Listen for player state updates
|
|
useEffect(() => {
|
|
const handlePlayerState = (...args: unknown[]) => {
|
|
const newState = args[0] as PlayerState
|
|
setState(newState)
|
|
}
|
|
|
|
playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
|
|
|
|
return () => {
|
|
playerEvents.off(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
|
|
}
|
|
}, [])
|
|
|
|
// Handle body scroll when in fullscreen
|
|
useEffect(() => {
|
|
if (displayMode === 'maximized') {
|
|
// Disable body scroll
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
// Re-enable body scroll
|
|
document.body.style.overflow = 'unset'
|
|
}
|
|
|
|
// Cleanup when component unmounts
|
|
return () => {
|
|
document.body.style.overflow = 'unset'
|
|
}
|
|
}, [displayMode])
|
|
|
|
const executeAction = useCallback(
|
|
async (
|
|
action: () => Promise<void | MessageResponse>,
|
|
actionName: string,
|
|
) => {
|
|
setIsLoading(true)
|
|
try {
|
|
await action()
|
|
} catch (error) {
|
|
console.error(`Failed to ${actionName}:`, error)
|
|
toast.error(`Failed to ${actionName}`)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
},
|
|
[],
|
|
)
|
|
|
|
const handlePlayPause = useCallback(() => {
|
|
if (state.status === 'playing') {
|
|
executeAction(playerService.pause, 'pause')
|
|
} else {
|
|
executeAction(playerService.play, 'play')
|
|
}
|
|
}, [state.status, executeAction])
|
|
|
|
const handleStop = useCallback(() => {
|
|
executeAction(playerService.stop, 'stop')
|
|
}, [executeAction])
|
|
|
|
const handlePrevious = useCallback(() => {
|
|
executeAction(playerService.previous, 'go to previous track')
|
|
}, [executeAction])
|
|
|
|
const handleNext = useCallback(() => {
|
|
executeAction(playerService.next, 'go to next track')
|
|
}, [executeAction])
|
|
|
|
const handleSeek = useCallback(
|
|
(position: number[]) => {
|
|
const newPosition = position[0]
|
|
executeAction(() => playerService.seek(newPosition), 'seek')
|
|
},
|
|
[executeAction],
|
|
)
|
|
|
|
const handleVolumeChange = useCallback(
|
|
(volume: number[]) => {
|
|
const newVolume = volume[0]
|
|
executeAction(() => playerService.setVolume(newVolume), 'change volume')
|
|
},
|
|
[executeAction],
|
|
)
|
|
|
|
const handleMute = useCallback(() => {
|
|
if (state.volume === 0) {
|
|
// Unmute
|
|
executeAction(playerService.unmute, 'unmute')
|
|
} else {
|
|
// Mute
|
|
executeAction(playerService.mute, 'mute')
|
|
}
|
|
}, [state.volume, executeAction])
|
|
|
|
const handleModeChange = useCallback(() => {
|
|
const modes: PlayerMode[] = [
|
|
'continuous',
|
|
'loop',
|
|
'loop_one',
|
|
'random',
|
|
'single',
|
|
]
|
|
const currentIndex = modes.indexOf(state.mode)
|
|
const nextMode = modes[(currentIndex + 1) % modes.length]
|
|
executeAction(() => playerService.setMode(nextMode), 'change mode')
|
|
}, [state.mode, executeAction])
|
|
|
|
const handleDownloadSound = useCallback(async () => {
|
|
if (!state.current_sound) return
|
|
|
|
try {
|
|
await filesService.downloadSound(state.current_sound.id)
|
|
toast.success('Download started')
|
|
} catch (error) {
|
|
console.error('Failed to download sound:', error)
|
|
toast.error('Failed to download sound')
|
|
}
|
|
}, [state.current_sound])
|
|
|
|
const handleStopAllSounds = useCallback(async () => {
|
|
try {
|
|
await soundsService.stopSounds()
|
|
toast.success('All sounds stopped')
|
|
} catch (error) {
|
|
console.error('Failed to stop all sounds:', error)
|
|
toast.error('Failed to stop all sounds')
|
|
}
|
|
}, [])
|
|
|
|
const getModeIcon = () => {
|
|
switch (state.mode) {
|
|
case 'continuous':
|
|
return <ArrowRight className="h-4 w-4" />
|
|
case 'loop':
|
|
return <Repeat className="h-4 w-4" />
|
|
case 'loop_one':
|
|
return <Repeat1 className="h-4 w-4" />
|
|
case 'random':
|
|
return <Shuffle className="h-4 w-4" />
|
|
default:
|
|
return <ArrowRightToLine className="h-4 w-4" />
|
|
}
|
|
}
|
|
|
|
const expandFromSidebar = useCallback(() => {
|
|
setDisplayMode('normal')
|
|
}, [])
|
|
|
|
const getPlayerPosition = () => {
|
|
switch (displayMode) {
|
|
case 'minimized':
|
|
return 'fixed bottom-4 right-4 z-50'
|
|
case 'maximized':
|
|
return 'fixed inset-0 z-50 bg-background'
|
|
case 'sidebar':
|
|
return 'hidden' // Hidden when in sidebar mode
|
|
default:
|
|
return 'fixed bottom-4 right-4 z-50'
|
|
}
|
|
}
|
|
|
|
const renderMinimizedPlayer = () => (
|
|
<Card className="w-48 bg-background/90 backdrop-blur-sm pt-0 pb-0">
|
|
<CardContent className="p-2">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handlePrevious}
|
|
disabled={isLoading}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<SkipBack className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handlePlayPause}
|
|
disabled={isLoading}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
{state.status === 'playing' ? (
|
|
<Pause className="h-4 w-4" />
|
|
) : (
|
|
<Play className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleStop}
|
|
disabled={isLoading}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<Square className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleNext}
|
|
disabled={isLoading}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<SkipForward className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setDisplayMode('normal')}
|
|
className="h-8 w-8 p-0 ml-auto"
|
|
>
|
|
<Maximize2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
|
|
const renderNormalPlayer = () => (
|
|
<Card className="w-80 bg-background/90 backdrop-blur-sm pt-3 pb-3">
|
|
<CardContent className="pt-0 pb-0 pl-3 pr-3">
|
|
{/* Window Controls */}
|
|
<div className="flex items-center justify-end gap-1 pb-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setDisplayMode('sidebar')}
|
|
className="h-6 w-6 p-0 hover:bg-muted"
|
|
title="Minimize to Sidebar"
|
|
>
|
|
<Minimize2 className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setDisplayMode('maximized')}
|
|
className="h-6 w-6 p-0 hover:bg-muted"
|
|
title="Fullscreen"
|
|
>
|
|
<Maximize2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Album Art / Thumbnail */}
|
|
<div className="mb-4">
|
|
{state.current_sound?.thumbnail ? (
|
|
<div className="w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
|
<img
|
|
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
|
alt={state.current_sound.name}
|
|
className="w-full h-full object-cover"
|
|
onError={e => {
|
|
// Hide image and show music icon if thumbnail fails to load
|
|
const target = e.target as HTMLImageElement
|
|
target.style.display = 'none'
|
|
const musicIcon = target.nextElementSibling as HTMLElement
|
|
if (musicIcon) musicIcon.style.display = 'block'
|
|
}}
|
|
/>
|
|
<Music
|
|
className={cn(
|
|
'h-8 w-8 text-muted-foreground',
|
|
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
|
)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Track Info */}
|
|
<div className="mb-4 text-center">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<h3 className="font-medium text-sm truncate">
|
|
{state.current_sound?.name || 'No track selected'}
|
|
</h3>
|
|
{state.current_sound &&
|
|
(state.current_sound.extract_url || state.current_sound.id) && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
|
<MoreVertical className="h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
{state.current_sound.extract_url && (
|
|
<DropdownMenuItem asChild>
|
|
<a
|
|
href={state.current_sound.extract_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
Source
|
|
</a>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
onClick={handleDownloadSound}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
File
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="mb-4">
|
|
<Progress
|
|
value={(state.position / (state.duration || 1)) * 100}
|
|
className="w-full h-2 cursor-pointer"
|
|
onClick={e => {
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
const clickX = e.clientX - rect.left
|
|
const percentage = clickX / rect.width
|
|
const newPosition = Math.round(percentage * (state.duration || 0))
|
|
handleSeek([newPosition])
|
|
}}
|
|
/>
|
|
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
|
<NumberFlowDuration duration={state.position} />
|
|
<NumberFlowDuration duration={state.duration || 0} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Controls */}
|
|
<div className="flex items-center justify-center gap-2 mb-4">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleModeChange}
|
|
className="h-8 w-8 p-0"
|
|
title={`Mode: ${state.mode.replace('_', ' ')}`}
|
|
>
|
|
{getModeIcon()}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handlePrevious}
|
|
disabled={isLoading}
|
|
>
|
|
<SkipBack className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handlePlayPause}
|
|
disabled={isLoading}
|
|
className="h-10 w-10 rounded-full"
|
|
>
|
|
{state.status === 'playing' ? (
|
|
<Pause className="h-5 w-5" />
|
|
) : (
|
|
<Play className="h-5 w-5" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleStop}
|
|
disabled={isLoading}
|
|
>
|
|
<Square className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleNext}
|
|
disabled={isLoading}
|
|
>
|
|
<SkipForward className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setShowPlaylist(!showPlaylist)}
|
|
className="h-8 w-8 p-0"
|
|
title="Toggle Playlist"
|
|
>
|
|
<List className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Secondary Controls */}
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{state.mode.replace('_', ' ')}
|
|
</Badge>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleMute}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
{state.volume === 0 ? (
|
|
<VolumeX className="h-4 w-4" />
|
|
) : (
|
|
<Volume2 className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
<div className="w-16">
|
|
<Slider
|
|
value={[state.volume]}
|
|
max={100}
|
|
step={1}
|
|
onValueChange={handleVolumeChange}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Playlist */}
|
|
{showPlaylist && state.playlist && (
|
|
<div className="mt-4 pt-4 border-t">
|
|
<Playlist
|
|
playlist={state.playlist}
|
|
currentIndex={state.index}
|
|
onTrackSelect={index =>
|
|
executeAction(
|
|
() => playerService.playAtIndex(index),
|
|
'play track',
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
|
|
const renderMaximizedPlayer = () => (
|
|
<div className="h-full flex flex-col bg-background/95 backdrop-blur-md">
|
|
{/* Header */}
|
|
<div className="p-4 border-b flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">Now Playing</h2>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleStopAllSounds}
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950/50"
|
|
>
|
|
<Square className="h-4 w-4 mr-2 fill-current" />
|
|
Stop All Sounds
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setDisplayMode('normal')}
|
|
>
|
|
<Minimize2 className="h-4 w-4 mr-2" />
|
|
Exit Fullscreen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex">
|
|
{/* Main Player Area */}
|
|
<div className="flex-1 flex flex-col items-center justify-center p-8">
|
|
{/* Large Album Art */}
|
|
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
|
|
{state.current_sound?.thumbnail ? (
|
|
<img
|
|
src={filesService.getThumbnailUrl(state.current_sound.id)}
|
|
alt={state.current_sound.name}
|
|
className="w-full h-full object-cover"
|
|
onError={e => {
|
|
// Hide image and show music icon if thumbnail fails to load
|
|
const target = e.target as HTMLImageElement
|
|
target.style.display = 'none'
|
|
const musicIcon = target.nextElementSibling as HTMLElement
|
|
if (musicIcon) musicIcon.style.display = 'block'
|
|
}}
|
|
/>
|
|
) : null}
|
|
<Music
|
|
className={cn(
|
|
'h-32 w-32 text-muted-foreground',
|
|
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Track Info */}
|
|
<div className="text-center mb-8">
|
|
<div className="flex items-center justify-center gap-3 mb-2">
|
|
<h1 className="text-2xl font-bold">
|
|
{state.current_sound?.name || 'No track selected'}
|
|
</h1>
|
|
{state.current_sound &&
|
|
(state.current_sound.extract_url || state.current_sound.id) && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
{state.current_sound.extract_url && (
|
|
<DropdownMenuItem asChild>
|
|
<a
|
|
href={state.current_sound.extract_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
Source
|
|
</a>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
onClick={handleDownloadSound}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
File
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="w-full max-w-md mb-8">
|
|
<Progress
|
|
value={(state.position / (state.duration || 1)) * 100}
|
|
className="w-full h-3 cursor-pointer"
|
|
onClick={e => {
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
const clickX = e.clientX - rect.left
|
|
const percentage = clickX / rect.width
|
|
const newPosition = Math.round(
|
|
percentage * (state.duration || 0),
|
|
)
|
|
handleSeek([newPosition])
|
|
}}
|
|
/>
|
|
<div className="flex justify-between text-sm text-muted-foreground mt-2">
|
|
<NumberFlowDuration duration={state.position} />
|
|
<NumberFlowDuration duration={state.duration || 0} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Large Controls */}
|
|
<div className="flex items-center gap-4 mb-8">
|
|
<Button
|
|
size="lg"
|
|
variant="ghost"
|
|
onClick={handlePrevious}
|
|
disabled={isLoading}
|
|
>
|
|
<SkipBack className="h-6 w-6" />
|
|
</Button>
|
|
<Button
|
|
size="lg"
|
|
onClick={handlePlayPause}
|
|
disabled={isLoading}
|
|
className="h-16 w-16 rounded-full"
|
|
>
|
|
{state.status === 'playing' ? (
|
|
<Pause className="h-8 w-8" />
|
|
) : (
|
|
<Play className="h-8 w-8" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="lg"
|
|
variant="ghost"
|
|
onClick={handleStop}
|
|
disabled={isLoading}
|
|
>
|
|
<Square className="h-6 w-6" />
|
|
</Button>
|
|
<Button
|
|
size="lg"
|
|
variant="ghost"
|
|
onClick={handleNext}
|
|
disabled={isLoading}
|
|
>
|
|
<SkipForward className="h-6 w-6" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Secondary Controls */}
|
|
<div className="flex items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="ghost" onClick={handleModeChange}>
|
|
{getModeIcon()}
|
|
</Button>
|
|
<Badge variant="secondary">{state.mode.replace('_', ' ')}</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="ghost" onClick={handleMute}>
|
|
{state.volume === 0 ? (
|
|
<VolumeX className="h-4 w-4" />
|
|
) : (
|
|
<Volume2 className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
<div className="w-24">
|
|
<Slider
|
|
value={[state.volume]}
|
|
max={100}
|
|
step={1}
|
|
onValueChange={handleVolumeChange}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground w-8">
|
|
{Math.round(state.volume)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Playlist Sidebar */}
|
|
{state.playlist && (
|
|
<div className="w-96 border-l bg-muted/10 backdrop-blur-sm">
|
|
<div className="p-4 border-b">
|
|
<h3 className="font-semibold">Playlist</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{state.playlist.sounds.length} tracks
|
|
</p>
|
|
</div>
|
|
<div className="p-4">
|
|
<Playlist
|
|
playlist={state.playlist}
|
|
currentIndex={state.index}
|
|
onTrackSelect={index =>
|
|
executeAction(
|
|
() => playerService.playAtIndex(index),
|
|
'play track',
|
|
)
|
|
}
|
|
variant="maximized"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
// Expose expand function for external use
|
|
useEffect(() => {
|
|
// Store expand function globally so sidebar can access it
|
|
const windowWithExpand = window as unknown as {
|
|
__expandPlayerFromSidebar?: () => void
|
|
}
|
|
windowWithExpand.__expandPlayerFromSidebar = expandFromSidebar
|
|
return () => {
|
|
delete windowWithExpand.__expandPlayerFromSidebar
|
|
}
|
|
}, [expandFromSidebar])
|
|
|
|
if (!state) return null
|
|
|
|
return (
|
|
<div className={cn(getPlayerPosition(), className)}>
|
|
{displayMode === 'minimized' && renderMinimizedPlayer()}
|
|
{displayMode === 'normal' && renderNormalPlayer()}
|
|
{displayMode === 'maximized' && renderMaximizedPlayer()}
|
|
</div>
|
|
)
|
|
}
|