Compare commits

..

5 Commits

10 changed files with 871 additions and 582 deletions

View File

@@ -1,12 +1,3 @@
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar'
import { filesService } from '@/lib/api/services/files'
import { import {
type MessageResponse, type MessageResponse,
type PlayerState, type PlayerState,
@@ -14,26 +5,17 @@ import {
} from '@/lib/api/services/player' } from '@/lib/api/services/player'
import { PLAYER_EVENTS, playerEvents } from '@/lib/events' import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import {
Maximize2,
Music,
Pause,
Play,
SkipBack,
SkipForward,
Volume2,
VolumeX,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { NumberFlowDuration } from '../ui/number-flow-duration' import { CompactPlayerControls } from './CompactPlayerControls'
import { CompactPlayerProgress } from './CompactPlayerProgress'
import { CompactPlayerTrackInfo } from './CompactPlayerTrackInfo'
interface CompactPlayerProps { interface CompactPlayerProps {
className?: string className?: string
} }
export function CompactPlayer({ className }: CompactPlayerProps) { export function CompactPlayer({ className }: CompactPlayerProps) {
const { isMobile, state: sidebarState } = useSidebar()
const [state, setState] = useState<PlayerState>({ const [state, setState] = useState<PlayerState>({
status: 'stopped', status: 'stopped',
mode: 'continuous', mode: 'continuous',
@@ -114,156 +96,50 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
} }
}, [state.volume, executeAction]) }, [state.volume, executeAction])
// // Don't show if no current sound const handleExpand = useCallback(() => {
// if (!state.current_sound) {
// return null
// }
return (
<div className={cn('w-full', className)}>
{/* Collapsed state - only play/pause button */}
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={sidebarState !== "collapsed" || isMobile}
>
{state.status === 'playing' ? 'Pause' : 'Play'}
</TooltipContent>
</Tooltip>
</div>
{/* Expanded state - full player */}
<div className="group-data-[collapsible=icon]:hidden">
{/* Track Info */}
<div className="flex items-center gap-2 mb-3 px-1">
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
{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-4 w-4 text-muted-foreground',
state.current_sound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{state.current_sound?.name || 'No track selected'}
</div>
<div className="text-xs text-muted-foreground">
{state.playlist?.name}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const expandFn = ( const expandFn = (
window as unknown as { __expandPlayerFromSidebar?: () => void } window as unknown as { __expandPlayerFromSidebar?: () => void }
).__expandPlayerFromSidebar ).__expandPlayerFromSidebar
if (expandFn) expandFn() if (expandFn) expandFn()
}} }, [])
className="h-6 w-6 p-0 flex-shrink-0"
title="Expand Player"
>
<Maximize2 className="h-3 w-3" />
</Button>
</div>
{/* Progress Bar */} return (
<div className="mb-3"> <div className={cn('w-full', className)}>
<Progress {/* Collapsed state - only play/pause button */}
value={(state.position / (state.duration || 1)) * 100} <CompactPlayerControls
className="w-full h-1" status={state.status}
volume={state.volume}
isLoading={isLoading}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onVolumeToggle={handleVolumeToggle}
variant="collapsed"
/> />
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span><NumberFlowDuration duration={state.position} /></span>
<span><NumberFlowDuration duration={state.duration || 0} /></span>
</div>
</div>
{/* Controls */} {/* Expanded state - full player */}
<div className="flex items-center justify-between gap-1"> <div className="group-data-[collapsible=icon]:hidden">
<Button <CompactPlayerTrackInfo
size="sm" currentSound={state.current_sound}
variant="ghost" playlistName={state.playlist?.name}
onClick={handlePrevious} onExpand={handleExpand}
disabled={isLoading} />
className="h-7 w-7 p-0"
title="Previous"
>
<SkipBack className="h-3 w-3" />
</Button>
<Button <CompactPlayerProgress
size="sm" position={state.position}
variant="ghost" duration={state.duration || 0}
onClick={handlePlayPause} />
disabled={isLoading}
className="h-8 w-8 p-0"
title={state.status === 'playing' ? 'Pause' : 'Play'}
>
{state.status === 'playing' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button <CompactPlayerControls
size="sm" status={state.status}
variant="ghost" volume={state.volume}
onClick={handleNext} isLoading={isLoading}
disabled={isLoading} onPlayPause={handlePlayPause}
className="h-7 w-7 p-0" onPrevious={handlePrevious}
title="Next" onNext={handleNext}
> onVolumeToggle={handleVolumeToggle}
<SkipForward className="h-3 w-3" /> variant="expanded"
</Button> />
<Button
size="sm"
variant="ghost"
onClick={handleVolumeToggle}
className="h-7 w-7 p-0"
title={state.volume === 0 ? 'Unmute' : 'Mute'}
>
{state.volume === 0 ? (
<VolumeX className="h-3 w-3" />
) : (
<Volume2 className="h-3 w-3" />
)}
</Button>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,128 @@
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar'
import type { PlayerState } from '@/lib/api/services/player'
import {
Pause,
Play,
SkipBack,
SkipForward,
Volume2,
VolumeX,
} from 'lucide-react'
import { memo } from 'react'
interface CompactPlayerControlsProps {
status: PlayerState['status']
volume: number
isLoading: boolean
onPlayPause: () => void
onPrevious: () => void
onNext: () => void
onVolumeToggle: () => void
variant?: 'collapsed' | 'expanded'
}
export const CompactPlayerControls = memo(function CompactPlayerControls({
status,
volume,
isLoading,
onPlayPause,
onPrevious,
onNext,
onVolumeToggle,
variant = 'expanded',
}: CompactPlayerControlsProps) {
const { isMobile, state: sidebarState } = useSidebar()
if (variant === 'collapsed') {
return (
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onPlayPause}
disabled={isLoading}
className="h-8 w-8 p-0"
>
{status === 'playing' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={sidebarState !== "collapsed" || isMobile}
>
{status === 'playing' ? 'Pause' : 'Play'}
</TooltipContent>
</Tooltip>
</div>
)
}
// Expanded variant
return (
<div className="flex items-center justify-between gap-1">
<Button
size="sm"
variant="ghost"
onClick={onPrevious}
disabled={isLoading}
className="h-7 w-7 p-0"
title="Previous"
>
<SkipBack className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onPlayPause}
disabled={isLoading}
className="h-8 w-8 p-0"
title={status === 'playing' ? 'Pause' : 'Play'}
>
{status === 'playing' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={onNext}
disabled={isLoading}
className="h-7 w-7 p-0"
title="Next"
>
<SkipForward className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onVolumeToggle}
className="h-7 w-7 p-0"
title={volume === 0 ? 'Unmute' : 'Mute'}
>
{volume === 0 ? (
<VolumeX className="h-3 w-3" />
) : (
<Volume2 className="h-3 w-3" />
)}
</Button>
</div>
)
})

View File

@@ -0,0 +1,31 @@
import { Progress } from '@/components/ui/progress'
import { memo, useMemo } from 'react'
import { NumberFlowDuration } from '../ui/number-flow-duration'
interface CompactPlayerProgressProps {
position: number
duration: number
}
export const CompactPlayerProgress = memo(function CompactPlayerProgress({
position,
duration,
}: CompactPlayerProgressProps) {
const progressPercentage = useMemo(() =>
(position / (duration || 1)) * 100,
[position, duration]
)
return (
<div className="mb-3">
<Progress
value={progressPercentage}
className="w-full h-1"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span><NumberFlowDuration duration={position} /></span>
<span><NumberFlowDuration duration={duration || 0} /></span>
</div>
</div>
)
})

View File

@@ -0,0 +1,62 @@
import { Button } from '@/components/ui/button'
import { filesService } from '@/lib/api/services/files'
import type { PlayerState } from '@/lib/api/services/player'
import { cn } from '@/lib/utils'
import { Maximize2, Music } from 'lucide-react'
import { memo } from 'react'
interface CompactPlayerTrackInfoProps {
currentSound: PlayerState['current_sound']
playlistName?: string
onExpand: () => void
}
export const CompactPlayerTrackInfo = memo(function CompactPlayerTrackInfo({
currentSound,
playlistName,
onExpand,
}: CompactPlayerTrackInfoProps) {
return (
<div className="flex items-center gap-2 mb-3 px-1">
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
{currentSound?.thumbnail ? (
<img
src={filesService.getThumbnailUrl(currentSound.id)}
alt={currentSound.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-4 w-4 text-muted-foreground',
currentSound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{currentSound?.name || 'No track selected'}
</div>
<div className="text-xs text-muted-foreground">
{playlistName}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onExpand}
className="h-6 w-6 p-0 flex-shrink-0"
title="Expand Player"
>
<Maximize2 className="h-3 w-3" />
</Button>
</div>
)
})

View File

@@ -1,14 +1,5 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' 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 { filesService } from '@/lib/api/services/files'
import { import {
type MessageResponse, type MessageResponse,
@@ -20,30 +11,16 @@ import { soundsService } from '@/lib/api/services/sounds'
import { PLAYER_EVENTS, playerEvents } from '@/lib/events' import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
ArrowRight,
ArrowRightToLine,
Download,
ExternalLink,
List,
Maximize2, Maximize2,
Minimize2, Minimize2,
MoreVertical,
Music,
Pause,
Play,
Repeat,
Repeat1,
Shuffle,
SkipBack,
SkipForward,
Square, Square,
Volume2,
VolumeX,
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Playlist } from './Playlist' import { Playlist } from './Playlist'
import { NumberFlowDuration } from '../ui/number-flow-duration' import { PlayerControls } from './PlayerControls'
import { PlayerProgress } from './PlayerProgress'
import { PlayerTrackInfo } from './PlayerTrackInfo'
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar' export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
@@ -130,6 +107,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
const [showPlaylist, setShowPlaylist] = useState(false) const [showPlaylist, setShowPlaylist] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
// Load initial state // Load initial state
useEffect(() => { useEffect(() => {
const loadState = async () => { const loadState = async () => {
@@ -279,31 +257,6 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
} }
}, []) }, [])
// Memoize expensive calculations to prevent unnecessary re-computations
const modeIcon = useMemo(() => {
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" />
}
}, [state.mode])
const progressPercentage = useMemo(() =>
(state.position / (state.duration || 1)) * 100,
[state.position, state.duration]
)
const modeLabel = useMemo(() =>
state.mode.replace('_', ' '),
[state.mode]
)
const expandFromSidebar = useCallback(() => { const expandFromSidebar = useCallback(() => {
setDisplayMode('normal') setDisplayMode('normal')
@@ -326,46 +279,17 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<Card className="w-48 bg-background/90 backdrop-blur-sm pt-0 pb-0"> <Card className="w-48 bg-background/90 backdrop-blur-sm pt-0 pb-0">
<CardContent className="p-2"> <CardContent className="p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <PlayerControls
size="sm" status={state.status}
variant="ghost" mode={state.mode}
onClick={handlePrevious} isLoading={isLoading}
disabled={isLoading} onPlayPause={handlePlayPause}
className="h-8 w-8 p-0" onStop={handleStop}
> onPrevious={handlePrevious}
<SkipBack className="h-4 w-4" /> onNext={handleNext}
</Button> onModeChange={handleModeChange}
<Button variant="minimized"
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 <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -404,180 +328,32 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
</Button> </Button>
</div> </div>
{/* Album Art / Thumbnail */} <PlayerTrackInfo
<div className="mb-4"> currentSound={state.current_sound}
{state.current_sound?.thumbnail ? ( onDownloadSound={handleDownloadSound}
<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( <PlayerProgress
'h-8 w-8 text-muted-foreground', position={state.position}
state.current_sound?.thumbnail ? 'hidden' : 'block', duration={state.duration || 0}
)} onSeek={handleSeek}
/> />
</div>
) : null}
</div>
{/* Track Info */} <PlayerControls
<div className="mb-4 text-center"> status={state.status}
<div className="flex items-center justify-center gap-2"> mode={state.mode}
<h3 className="font-medium text-sm truncate"> isLoading={isLoading}
{state.current_sound?.name || 'No track selected'} showPlaylistButton={true}
</h3> volume={state.volume}
{state.current_sound && onPlayPause={handlePlayPause}
(state.current_sound.extract_url || state.current_sound.id) && ( onStop={handleStop}
<DropdownMenu> onPrevious={handlePrevious}
<DropdownMenuTrigger asChild> onNext={handleNext}
<Button variant="ghost" size="sm" className="h-4 w-4 p-0"> onModeChange={handleModeChange}
<MoreVertical className="h-3 w-3" /> onTogglePlaylist={() => setShowPlaylist(!showPlaylist)}
</Button> onVolumeChange={handleVolumeChange}
</DropdownMenuTrigger> onMute={handleMute}
<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={progressPercentage}
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: ${modeLabel}`}
>
{modeIcon}
</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">
{modeLabel}
</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 */} {/* Playlist */}
{showPlaylist && state.playlist && ( {showPlaylist && state.playlist && (
@@ -627,163 +403,33 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
<div className="flex-1 flex"> <div className="flex-1 flex">
{/* Main Player Area */} {/* Main Player Area */}
<div className="flex-1 flex flex-col items-center justify-center p-8"> <div className="flex-1 flex flex-col items-center justify-center p-8">
{/* Large Album Art */} <PlayerTrackInfo
<div className="max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8"> currentSound={state.current_sound}
{state.current_sound?.thumbnail ? ( onDownloadSound={handleDownloadSound}
<img variant="maximized"
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 <PlayerProgress
className={cn( position={state.position}
'h-32 w-32 text-muted-foreground', duration={state.duration || 0}
state.current_sound?.thumbnail ? 'hidden' : 'block', onSeek={handleSeek}
)} variant="maximized"
/> />
</div>
{/* Track Info */} <PlayerControls
<div className="text-center mb-8"> status={state.status}
<div className="flex items-center justify-center gap-3 mb-2"> mode={state.mode}
<h1 className="text-2xl font-bold"> isLoading={isLoading}
{state.current_sound?.name || 'No track selected'} volume={state.volume}
</h1> onPlayPause={handlePlayPause}
{state.current_sound && onStop={handleStop}
(state.current_sound.extract_url || state.current_sound.id) && ( onPrevious={handlePrevious}
<DropdownMenu> onNext={handleNext}
<DropdownMenuTrigger asChild> onModeChange={handleModeChange}
<Button variant="ghost" size="sm" className="h-6 w-6 p-0"> onVolumeChange={handleVolumeChange}
<MoreVertical className="h-4 w-4" /> onMute={handleMute}
</Button> variant="maximized"
</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={progressPercentage}
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}>
{modeIcon}
</Button>
<Badge variant="secondary">{modeLabel}</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> </div>
{/* Playlist Sidebar */} {/* Playlist Sidebar */}

View File

@@ -0,0 +1,302 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import type { PlayerMode, PlayerState } from '@/lib/api/services/player'
import {
ArrowRight,
ArrowRightToLine,
List,
Pause,
Play,
Repeat,
Repeat1,
Shuffle,
SkipBack,
SkipForward,
Square,
Volume2,
VolumeX,
} from 'lucide-react'
import { memo, useMemo } from 'react'
interface PlayerControlsProps {
status: PlayerState['status']
mode: PlayerMode
isLoading: boolean
showPlaylistButton?: boolean
volume?: number
onPlayPause: () => void
onStop: () => void
onPrevious: () => void
onNext: () => void
onModeChange: () => void
onTogglePlaylist?: () => void
onVolumeChange?: (volume: number[]) => void
onMute?: () => void
variant?: 'normal' | 'maximized' | 'minimized'
}
export const PlayerControls = memo(function PlayerControls({
status,
mode,
isLoading,
showPlaylistButton = false,
volume,
onPlayPause,
onStop,
onPrevious,
onNext,
onModeChange,
onTogglePlaylist,
onVolumeChange,
onMute,
variant = 'normal',
}: PlayerControlsProps) {
const isMinimized = variant === 'minimized'
const isMaximized = variant === 'maximized'
const modeIcon = useMemo(() => {
switch (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" />
}
}, [mode])
const modeLabel = useMemo(() =>
mode.replace('_', ' '),
[mode]
)
if (isMinimized) {
return (
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={onPrevious}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onPlayPause}
disabled={isLoading}
className="h-8 w-8 p-0"
>
{status === 'playing' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={onStop}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<Square className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onNext}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<SkipForward className="h-4 w-4" />
</Button>
</div>
)
}
if (isMaximized) {
return (
<div>
{/* Large Controls */}
<div className="flex items-center gap-4 mb-8">
<Button
size="lg"
variant="ghost"
onClick={onPrevious}
disabled={isLoading}
>
<SkipBack className="h-6 w-6" />
</Button>
<Button
size="lg"
onClick={onPlayPause}
disabled={isLoading}
className="h-16 w-16 rounded-full"
>
{status === 'playing' ? (
<Pause className="h-8 w-8" />
) : (
<Play className="h-8 w-8" />
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={onStop}
disabled={isLoading}
>
<Square className="h-6 w-6" />
</Button>
<Button
size="lg"
variant="ghost"
onClick={onNext}
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={onModeChange}>
{modeIcon}
</Button>
<Badge variant="secondary">{modeLabel}</Badge>
</div>
{volume !== undefined && onVolumeChange && onMute && (
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={onMute}>
{volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<div className="w-24">
<Slider
value={[volume]}
max={100}
step={1}
onValueChange={onVolumeChange}
className="w-full"
/>
</div>
<span className="text-sm text-muted-foreground w-8">
{Math.round(volume)}%
</span>
</div>
)}
</div>
</div>
)
}
// Normal variant
return (
<>
{/* Main Controls */}
<div className="flex items-center justify-center gap-2 mb-4">
<Button
size="sm"
variant="ghost"
onClick={onModeChange}
className="h-8 w-8 p-0"
title={`Mode: ${modeLabel}`}
>
{modeIcon}
</Button>
<Button
size="sm"
variant="ghost"
onClick={onPrevious}
disabled={isLoading}
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={onPlayPause}
disabled={isLoading}
className="h-10 w-10 rounded-full"
>
{status === 'playing' ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={onStop}
disabled={isLoading}
>
<Square className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onNext}
disabled={isLoading}
>
<SkipForward className="h-4 w-4" />
</Button>
{showPlaylistButton && onTogglePlaylist && (
<Button
size="sm"
variant="ghost"
onClick={onTogglePlaylist}
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">
{modeLabel}
</Badge>
{volume !== undefined && onVolumeChange && onMute && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={onMute}
className="h-8 w-8 p-0"
>
{volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<div className="w-16">
<Slider
value={[volume]}
max={100}
step={1}
onValueChange={onVolumeChange}
className="w-full"
/>
</div>
</div>
)}
</div>
</>
)
})

View File

@@ -0,0 +1,53 @@
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/utils'
import { memo, useMemo } from 'react'
import { NumberFlowDuration } from '../ui/number-flow-duration'
interface PlayerProgressProps {
position: number
duration: number
onSeek: (position: number[]) => void
variant?: 'normal' | 'maximized'
}
export const PlayerProgress = memo(function PlayerProgress({
position,
duration,
onSeek,
variant = 'normal',
}: PlayerProgressProps) {
const isMaximized = variant === 'maximized'
const progressPercentage = useMemo(() =>
(position / (duration || 1)) * 100,
[position, duration]
)
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
const newPosition = Math.round(percentage * (duration || 0))
onSeek([newPosition])
}
return (
<div className={isMaximized ? 'w-full max-w-md mb-8' : 'mb-4'}>
<Progress
value={progressPercentage}
className={cn(
'w-full cursor-pointer',
isMaximized ? 'h-3' : 'h-2'
)}
onClick={handleProgressClick}
/>
<div className={cn(
'flex justify-between text-muted-foreground mt-1',
isMaximized ? 'text-sm mt-2' : 'text-xs'
)}>
<NumberFlowDuration duration={position} />
<NumberFlowDuration duration={duration || 0} />
</div>
</div>
)
})

View File

@@ -0,0 +1,103 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { filesService } from '@/lib/api/services/files'
import type { PlayerState } from '@/lib/api/services/player'
import { cn } from '@/lib/utils'
import { Download, ExternalLink, MoreVertical, Music } from 'lucide-react'
import { memo } from 'react'
interface PlayerTrackInfoProps {
currentSound: PlayerState['current_sound']
onDownloadSound: () => void
variant?: 'normal' | 'maximized'
}
export const PlayerTrackInfo = memo(function PlayerTrackInfo({
currentSound,
onDownloadSound,
variant = 'normal',
}: PlayerTrackInfoProps) {
const isMaximized = variant === 'maximized'
return (
<>
{/* Album Art / Thumbnail */}
<div className={isMaximized ? 'max-w-300 max-h-200 aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8' : 'mb-4'}>
{currentSound?.thumbnail ? (
<div className={isMaximized ? 'w-full h-full' : 'w-full aspect-auto bg-muted rounded-lg flex items-center justify-center overflow-hidden'}>
<img
src={filesService.getThumbnailUrl(currentSound.id)}
alt={currentSound.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(
isMaximized ? 'h-32 w-32 text-muted-foreground' : 'h-8 w-8 text-muted-foreground',
currentSound?.thumbnail ? 'hidden' : 'block',
)}
/>
</div>
) : isMaximized ? (
<Music className="h-32 w-32 text-muted-foreground" />
) : null}
</div>
{/* Track Info */}
<div className={cn('text-center', isMaximized ? 'mb-8' : 'mb-4')}>
<div className={cn('flex items-center justify-center gap-2', isMaximized && 'gap-3 mb-2')}>
<h3 className={cn('font-medium truncate', isMaximized ? 'text-2xl font-bold' : 'text-sm')}>
{currentSound?.name || 'No track selected'}
</h3>
{currentSound &&
(currentSound.extract_url || currentSound.id) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={isMaximized ? 'h-6 w-6 p-0' : 'h-4 w-4 p-0'}
>
<MoreVertical className={isMaximized ? 'h-4 w-4' : 'h-3 w-3'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{currentSound.extract_url && (
<DropdownMenuItem asChild>
<a
href={currentSound.extract_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
Source
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onDownloadSound}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</>
)
})

View File

@@ -37,7 +37,7 @@ export function SchedulersTable({ tasks, onTaskDeleted }: SchedulersTableProps)
// Confirm deletion // Confirm deletion
const confirmMessage = `Are you sure you want to delete the task "${task.name}"?${ const confirmMessage = `Are you sure you want to delete the task "${task.name}"?${
task.status === 'scheduled' || task.status === 'running' task.status === 'pending' || task.status === 'running'
? '\n\nThis task is currently active and will be stopped immediately.' ? '\n\nThis task is currently active and will be stopped immediately.'
: '' : ''
}\n\nThis action cannot be undone.` }\n\nThis action cannot be undone.`

View File

@@ -0,0 +1,88 @@
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import type { PlayerState } from '@/lib/api/services/player'
import { useCallback, useEffect, useRef, useState } from 'react'
// Type for selecting specific parts of the player state
type PlayerStateSelector<T> = (state: PlayerState) => T
// Custom hook for subscribing to specific parts of player state
export function usePlayerState<T>(
selector: PlayerStateSelector<T>,
equalityFn?: (a: T, b: T) => boolean
): T | null {
const [selectedState, setSelectedState] = useState<T | null>(null)
const selectorRef = useRef(selector)
const equalityRef = useRef(equalityFn)
// Keep refs updated
selectorRef.current = selector
equalityRef.current = equalityFn
useEffect(() => {
const handlePlayerState = (...args: unknown[]) => {
const newState = args[0] as PlayerState
const newSelectedState = selectorRef.current(newState)
setSelectedState(prevSelected => {
// Use custom equality function if provided, otherwise use shallow comparison
if (equalityRef.current) {
if (prevSelected !== null && equalityRef.current(prevSelected, newSelectedState)) {
return prevSelected
}
} else if (prevSelected === newSelectedState) {
return prevSelected
}
return newSelectedState
})
}
playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
return () => {
playerEvents.off(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
}
}, [])
return selectedState
}
// Specific hooks for common state selections
export function usePlayerProgress() {
return usePlayerState(
useCallback((state: PlayerState) => ({
position: state.position,
duration: state.duration || 0,
}), [])
)
}
export function usePlayerTrackInfo() {
return usePlayerState(
useCallback((state: PlayerState) => state.current_sound, [])
)
}
export function usePlayerControls() {
return usePlayerState(
useCallback((state: PlayerState) => ({
status: state.status,
mode: state.mode,
}), [])
)
}
export function usePlayerVolume() {
return usePlayerState(
useCallback((state: PlayerState) => state.volume, [])
)
}
export function usePlayerPlaylist() {
return usePlayerState(
useCallback((state: PlayerState) => ({
playlist: state.playlist,
index: state.index,
}), [])
)
}