Merge branch 'optimised_player'
This commit is contained in:
@@ -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) {
|
const expandFn = (
|
||||||
// return null
|
window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||||
// }
|
).__expandPlayerFromSidebar
|
||||||
|
if (expandFn) expandFn()
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full', className)}>
|
<div className={cn('w-full', className)}>
|
||||||
{/* Collapsed state - only play/pause button */}
|
{/* Collapsed state - only play/pause button */}
|
||||||
<div className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center hidden">
|
<CompactPlayerControls
|
||||||
<Tooltip>
|
status={state.status}
|
||||||
<TooltipTrigger asChild>
|
volume={state.volume}
|
||||||
<Button
|
isLoading={isLoading}
|
||||||
size="sm"
|
onPlayPause={handlePlayPause}
|
||||||
variant="ghost"
|
onPrevious={handlePrevious}
|
||||||
onClick={handlePlayPause}
|
onNext={handleNext}
|
||||||
disabled={isLoading}
|
onVolumeToggle={handleVolumeToggle}
|
||||||
className="h-8 w-8 p-0"
|
variant="collapsed"
|
||||||
>
|
/>
|
||||||
{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 */}
|
{/* Expanded state - full player */}
|
||||||
<div className="group-data-[collapsible=icon]:hidden">
|
<div className="group-data-[collapsible=icon]:hidden">
|
||||||
{/* Track Info */}
|
<CompactPlayerTrackInfo
|
||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
currentSound={state.current_sound}
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-muted rounded flex items-center justify-center overflow-hidden">
|
playlistName={state.playlist?.name}
|
||||||
{state.current_sound?.thumbnail ? (
|
onExpand={handleExpand}
|
||||||
<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 = (
|
|
||||||
window as unknown as { __expandPlayerFromSidebar?: () => void }
|
|
||||||
).__expandPlayerFromSidebar
|
|
||||||
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 */}
|
<CompactPlayerProgress
|
||||||
<div className="mb-3">
|
position={state.position}
|
||||||
<Progress
|
duration={state.duration || 0}
|
||||||
value={(state.position / (state.duration || 1)) * 100}
|
/>
|
||||||
className="w-full h-1"
|
|
||||||
/>
|
|
||||||
<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 */}
|
<CompactPlayerControls
|
||||||
<div className="flex items-center justify-between gap-1">
|
status={state.status}
|
||||||
<Button
|
volume={state.volume}
|
||||||
size="sm"
|
isLoading={isLoading}
|
||||||
variant="ghost"
|
onPlayPause={handlePlayPause}
|
||||||
onClick={handlePrevious}
|
onPrevious={handlePrevious}
|
||||||
disabled={isLoading}
|
onNext={handleNext}
|
||||||
className="h-7 w-7 p-0"
|
onVolumeToggle={handleVolumeToggle}
|
||||||
title="Previous"
|
variant="expanded"
|
||||||
>
|
/>
|
||||||
<SkipBack className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
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
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
title="Next"
|
|
||||||
>
|
|
||||||
<SkipForward className="h-3 w-3" />
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
128
src/components/player/CompactPlayerControls.tsx
Normal file
128
src/components/player/CompactPlayerControls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
31
src/components/player/CompactPlayerProgress.tsx
Normal file
31
src/components/player/CompactPlayerProgress.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
62
src/components/player/CompactPlayerTrackInfo.tsx
Normal file
62
src/components/player/CompactPlayerTrackInfo.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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(
|
|
||||||
'h-8 w-8 text-muted-foreground',
|
|
||||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track Info */}
|
<PlayerProgress
|
||||||
<div className="mb-4 text-center">
|
position={state.position}
|
||||||
<div className="flex items-center justify-center gap-2">
|
duration={state.duration || 0}
|
||||||
<h3 className="font-medium text-sm truncate">
|
onSeek={handleSeek}
|
||||||
{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 */}
|
<PlayerControls
|
||||||
<div className="mb-4">
|
status={state.status}
|
||||||
<Progress
|
mode={state.mode}
|
||||||
value={progressPercentage}
|
isLoading={isLoading}
|
||||||
className="w-full h-2 cursor-pointer"
|
showPlaylistButton={true}
|
||||||
onClick={e => {
|
volume={state.volume}
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
onPlayPause={handlePlayPause}
|
||||||
const clickX = e.clientX - rect.left
|
onStop={handleStop}
|
||||||
const percentage = clickX / rect.width
|
onPrevious={handlePrevious}
|
||||||
const newPosition = Math.round(percentage * (state.duration || 0))
|
onNext={handleNext}
|
||||||
handleSeek([newPosition])
|
onModeChange={handleModeChange}
|
||||||
}}
|
onTogglePlaylist={() => setShowPlaylist(!showPlaylist)}
|
||||||
/>
|
onVolumeChange={handleVolumeChange}
|
||||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
onMute={handleMute}
|
||||||
<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
|
|
||||||
className={cn(
|
|
||||||
'h-32 w-32 text-muted-foreground',
|
|
||||||
state.current_sound?.thumbnail ? 'hidden' : 'block',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track Info */}
|
<PlayerProgress
|
||||||
<div className="text-center mb-8">
|
position={state.position}
|
||||||
<div className="flex items-center justify-center gap-3 mb-2">
|
duration={state.duration || 0}
|
||||||
<h1 className="text-2xl font-bold">
|
onSeek={handleSeek}
|
||||||
{state.current_sound?.name || 'No track selected'}
|
variant="maximized"
|
||||||
</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 */}
|
<PlayerControls
|
||||||
<div className="w-full max-w-md mb-8">
|
status={state.status}
|
||||||
<Progress
|
mode={state.mode}
|
||||||
value={progressPercentage}
|
isLoading={isLoading}
|
||||||
className="w-full h-3 cursor-pointer"
|
volume={state.volume}
|
||||||
onClick={e => {
|
onPlayPause={handlePlayPause}
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
onStop={handleStop}
|
||||||
const clickX = e.clientX - rect.left
|
onPrevious={handlePrevious}
|
||||||
const percentage = clickX / rect.width
|
onNext={handleNext}
|
||||||
const newPosition = Math.round(
|
onModeChange={handleModeChange}
|
||||||
percentage * (state.duration || 0),
|
onVolumeChange={handleVolumeChange}
|
||||||
)
|
onMute={handleMute}
|
||||||
handleSeek([newPosition])
|
variant="maximized"
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
<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 */}
|
||||||
|
|||||||
302
src/components/player/PlayerControls.tsx
Normal file
302
src/components/player/PlayerControls.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
53
src/components/player/PlayerProgress.tsx
Normal file
53
src/components/player/PlayerProgress.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
103
src/components/player/PlayerTrackInfo.tsx
Normal file
103
src/components/player/PlayerTrackInfo.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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.`
|
||||||
|
|||||||
88
src/hooks/usePlayerState.ts
Normal file
88
src/hooks/usePlayerState.ts
Normal 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,
|
||||||
|
}), [])
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user