feat: implement new compact player components for controls, progress, and track info
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 {
|
||||
type MessageResponse,
|
||||
type PlayerState,
|
||||
@@ -14,26 +5,17 @@ import {
|
||||
} from '@/lib/api/services/player'
|
||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Maximize2,
|
||||
Music,
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
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 {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
const { isMobile, state: sidebarState } = useSidebar()
|
||||
const [state, setState] = useState<PlayerState>({
|
||||
status: 'stopped',
|
||||
mode: 'continuous',
|
||||
@@ -114,156 +96,50 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
|
||||
}
|
||||
}, [state.volume, executeAction])
|
||||
|
||||
// // Don't show if no current sound
|
||||
// if (!state.current_sound) {
|
||||
// return null
|
||||
// }
|
||||
const handleExpand = useCallback(() => {
|
||||
const expandFn = (
|
||||
window as unknown as { __expandPlayerFromSidebar?: () => void }
|
||||
).__expandPlayerFromSidebar
|
||||
if (expandFn) expandFn()
|
||||
}, [])
|
||||
|
||||
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>
|
||||
<CompactPlayerControls
|
||||
status={state.status}
|
||||
volume={state.volume}
|
||||
isLoading={isLoading}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onVolumeToggle={handleVolumeToggle}
|
||||
variant="collapsed"
|
||||
/>
|
||||
|
||||
{/* 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 = (
|
||||
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>
|
||||
<CompactPlayerTrackInfo
|
||||
currentSound={state.current_sound}
|
||||
playlistName={state.playlist?.name}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<Progress
|
||||
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>
|
||||
<CompactPlayerProgress
|
||||
position={state.position}
|
||||
duration={state.duration || 0}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePrevious}
|
||||
disabled={isLoading}
|
||||
className="h-7 w-7 p-0"
|
||||
title="Previous"
|
||||
>
|
||||
<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>
|
||||
<CompactPlayerControls
|
||||
status={state.status}
|
||||
volume={state.volume}
|
||||
isLoading={isLoading}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onVolumeToggle={handleVolumeToggle}
|
||||
variant="expanded"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user