feat: implement new compact player components for controls, progress, and track info

This commit is contained in:
JSC
2025-09-22 20:28:27 +02:00
parent 9db9915e56
commit a25fb9b5eb
4 changed files with 259 additions and 162 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 {
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
// }
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 handleExpand = useCallback(() => {
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 */}
<div className="mb-3">
<Progress
value={(state.position / (state.duration || 1)) * 100}
className="w-full h-1"
return (
<div className={cn('w-full', className)}>
{/* Collapsed state - only play/pause button */}
<CompactPlayerControls
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 */}
<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>
{/* Expanded state - full player */}
<div className="group-data-[collapsible=icon]:hidden">
<CompactPlayerTrackInfo
currentSound={state.current_sound}
playlistName={state.playlist?.name}
onExpand={handleExpand}
/>
<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>
<CompactPlayerProgress
position={state.position}
duration={state.duration || 0}
/>
<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>
)

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>
)
})