Files
sbd2-frontend/src/components/player/CompactPlayer.tsx
JSC 4e50e7e79d
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
Refactor and enhance UI components across multiple pages
- Improved import organization and formatting in PlaylistsPage, RegisterPage, SoundsPage, SettingsPage, and UsersPage for better readability.
- Added error handling and user feedback with toast notifications in SoundsPage and SettingsPage.
- Enhanced user experience by implementing debounced search functionality in PlaylistsPage and SoundsPage.
- Updated the layout and structure of forms in SettingsPage and UsersPage for better usability.
- Improved accessibility and semantics by ensuring proper labeling and descriptions in forms.
- Fixed minor bugs related to state management and API calls in various components.
2025-08-14 23:51:47 +02:00

253 lines
7.3 KiB
TypeScript

import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { filesService } from '@/lib/api/services/files'
import {
type MessageResponse,
type PlayerState,
playerService,
} from '@/lib/api/services/player'
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import {
Maximize2,
Music,
Pause,
Play,
SkipBack,
SkipForward,
Volume2,
VolumeX,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
interface CompactPlayerProps {
className?: string
}
export function CompactPlayer({ className }: CompactPlayerProps) {
const [state, setState] = useState<PlayerState>({
status: 'stopped',
mode: 'continuous',
volume: 80,
previous_volume: 80,
position: 0,
})
const [isLoading, setIsLoading] = useState(false)
// Load initial state
useEffect(() => {
const loadState = async () => {
try {
const initialState = await playerService.getState()
setState(initialState)
} catch (error) {
console.error('Failed to load player state:', error)
}
}
loadState()
}, [])
// Listen for player state updates
useEffect(() => {
const handlePlayerState = (newState: PlayerState) => {
setState(newState)
}
playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
return () => {
playerEvents.off(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState)
}
}, [])
const executeAction = useCallback(
async (
action: () => Promise<void | MessageResponse>,
actionName: string,
) => {
setIsLoading(true)
try {
await action()
} catch (error) {
console.error(`Failed to ${actionName}:`, error)
toast.error(`Failed to ${actionName}`)
} finally {
setIsLoading(false)
}
},
[],
)
const handlePlayPause = useCallback(() => {
if (state.status === 'playing') {
executeAction(playerService.pause, 'pause')
} else {
executeAction(playerService.play, 'play')
}
}, [state.status, executeAction])
const handlePrevious = useCallback(() => {
executeAction(playerService.previous, 'go to previous track')
}, [executeAction])
const handleNext = useCallback(() => {
executeAction(playerService.next, 'go to next track')
}, [executeAction])
const handleVolumeToggle = useCallback(() => {
if (state.volume === 0) {
// Unmute
executeAction(playerService.unmute, 'unmute')
} else {
// Mute
executeAction(playerService.mute, 'mute')
}
}, [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">
<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>
</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 = (
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"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{formatDuration(state.position)}</span>
<span>{formatDuration(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>
<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>
)
}