feat: add audio extraction management interface and services

- Implemented ExtractionsPage component for managing audio extractions.
- Added ExtractionsService for handling extraction API calls.
- Created Playlist component for displaying audio tracks.
- Introduced ScrollArea component for better UI scrolling experience.
- Developed FilesService for file download and thumbnail management.
- Added PlayerService for controlling audio playback and state.
- Updated API services index to include new services.
This commit is contained in:
JSC
2025-08-03 20:43:42 +02:00
parent b42b802c37
commit 6cbf0e5e6d
11 changed files with 1388 additions and 6 deletions

View File

@@ -0,0 +1,679 @@
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
Play,
Pause,
Square,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Repeat,
Repeat1,
Shuffle,
List,
Minimize2,
Maximize2,
Music,
ExternalLink,
Download,
MoreVertical
} from 'lucide-react'
import { playerService, type PlayerState, type PlayerMode } from '@/lib/api/services/player'
import { filesService } from '@/lib/api/services/files'
import { playerEvents, PLAYER_EVENTS } from '@/lib/events'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { Playlist } from './Playlist'
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized'
interface PlayerProps {
className?: string
}
export function Player({ className }: PlayerProps) {
const [state, setState] = useState<PlayerState>({
status: 'stopped',
mode: 'continuous',
volume: 50,
position: 0
})
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>('normal')
const [showPlaylist, setShowPlaylist] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isMuted, setIsMuted] = useState(false)
const [previousVolume, setPreviousVolume] = useState(50)
// 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>, 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 handleStop = useCallback(() => {
executeAction(playerService.stop, 'stop')
}, [executeAction])
const handlePrevious = useCallback(() => {
executeAction(playerService.previous, 'go to previous track')
}, [executeAction])
const handleNext = useCallback(() => {
executeAction(playerService.next, 'go to next track')
}, [executeAction])
const handleSeek = useCallback((position: number[]) => {
const newPosition = position[0]
executeAction(() => playerService.seek(newPosition), 'seek')
}, [executeAction])
const handleVolumeChange = useCallback((volume: number[]) => {
const newVolume = volume[0]
executeAction(() => playerService.setVolume(newVolume), 'change volume')
if (newVolume > 0 && isMuted) {
setIsMuted(false)
}
}, [executeAction, isMuted])
const handleMute = useCallback(() => {
if (isMuted) {
// Unmute
executeAction(() => playerService.setVolume(previousVolume), 'unmute')
setIsMuted(false)
} else {
// Mute
setPreviousVolume(state.volume)
executeAction(() => playerService.setVolume(0), 'mute')
setIsMuted(true)
}
}, [isMuted, previousVolume, state.volume, executeAction])
const handleModeChange = useCallback(() => {
const modes: PlayerMode[] = ['continuous', 'loop', 'loop_one', 'random', 'single']
const currentIndex = modes.indexOf(state.mode)
const nextMode = modes[(currentIndex + 1) % modes.length]
executeAction(() => playerService.setMode(nextMode), 'change mode')
}, [state.mode, executeAction])
const handleDownloadSound = useCallback(async () => {
if (!state.current_sound) return
try {
await filesService.downloadSound(state.current_sound.id)
toast.success('Download started')
} catch (error) {
console.error('Failed to download sound:', error)
toast.error('Failed to download sound')
}
}, [state.current_sound])
const getModeIcon = () => {
switch (state.mode) {
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 <Repeat className="h-4 w-4 opacity-50" />
}
}
const getPlayerPosition = () => {
switch (displayMode) {
case 'minimized':
return 'fixed bottom-4 right-4 z-50'
case 'maximized':
return 'fixed inset-0 z-50 bg-background'
default:
return 'fixed bottom-4 right-4 z-50'
}
}
const renderMinimizedPlayer = () => (
<Card className="w-48 bg-background/90 backdrop-blur-sm">
<CardContent className="p-2">
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={handlePrevious}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<SkipBack className="h-4 w-4" />
</Button>
<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>
<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
size="sm"
variant="ghost"
onClick={() => setDisplayMode('normal')}
className="h-8 w-8 p-0 ml-auto"
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
const renderNormalPlayer = () => (
<Card className="w-80 bg-background/90 backdrop-blur-sm">
<CardContent className="p-4">
{/* Window Controls */}
<div className="flex items-center justify-end gap-1 mb-4 -mt-2 -mr-2">
<Button
size="sm"
variant="ghost"
onClick={() => setDisplayMode('minimized')}
className="h-6 w-6 p-0 hover:bg-muted"
title="Minimize"
>
<Minimize2 className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDisplayMode('maximized')}
className="h-6 w-6 p-0 hover:bg-muted"
title="Fullscreen"
>
<Maximize2 className="h-3 w-3" />
</Button>
</div>
{/* Album Art / Thumbnail */}
<div className="mb-4">
{state.current_sound?.thumbnail ? (
<div className="w-full aspect-square 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 */}
<div className="mb-4 text-center">
<div className="flex items-center justify-center gap-2">
{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>
)}
<h3 className="font-medium text-sm truncate">
{state.current_sound?.name || 'No track selected'}
</h3>
</div>
{state.playlist && (
<p className="text-xs text-muted-foreground truncate">
{state.playlist.name}
</p>
)}
</div>
{/* Progress Bar */}
<div className="mb-4">
<Progress
value={(state.position / (state.duration || 1)) * 100}
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">
<span>{formatDuration(state.position)}</span>
<span>{formatDuration(state.duration || 0)}</span>
</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: ${state.mode.replace('_', ' ')}`}
>
{getModeIcon()}
</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">
{state.mode.replace('_', ' ')}
</Badge>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleMute}
className="h-8 w-8 p-0"
>
{isMuted || state.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<div className="w-16">
<Slider
value={[isMuted ? 0 : state.volume]}
max={100}
step={1}
onValueChange={handleVolumeChange}
className="w-full"
/>
</div>
</div>
</div>
{/* Playlist */}
{showPlaylist && state.playlist && (
<div className="mt-4 pt-4 border-t">
<Playlist
playlist={state.playlist}
currentIndex={state.index}
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
/>
</div>
)}
</CardContent>
</Card>
)
const renderMaximizedPlayer = () => (
<div className="h-full flex flex-col bg-background/95 backdrop-blur-md">
{/* Header */}
<div className="p-4 border-b flex items-center justify-between">
<h2 className="text-lg font-semibold">Now Playing</h2>
<Button
size="sm"
variant="ghost"
onClick={() => setDisplayMode('normal')}
>
<Minimize2 className="h-4 w-4 mr-2" />
Exit Fullscreen
</Button>
</div>
<div className="flex-1 flex">
{/* Main Player Area */}
<div className="flex-1 flex flex-col items-center justify-center p-8">
{/* Large Album Art */}
<div className="w-80 aspect-square bg-muted rounded-lg flex items-center justify-center overflow-hidden mb-8">
{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-32 w-32 text-muted-foreground",
state.current_sound?.thumbnail ? "hidden" : "block"
)}
/>
</div>
{/* Track Info */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-2">
{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>
)}
<h1 className="text-2xl font-bold">
{state.current_sound?.name || 'No track selected'}
</h1>
</div>
{state.playlist && (
<p className="text-lg text-muted-foreground">
{state.playlist.name}
</p>
)}
</div>
{/* Progress Bar */}
<div className="w-full max-w-md mb-8">
<Progress
value={(state.position / (state.duration || 1)) * 100}
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">
<span>{formatDuration(state.position)}</span>
<span>{formatDuration(state.duration || 0)}</span>
</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}
>
{getModeIcon()}
</Button>
<Badge variant="secondary">
{state.mode.replace('_', ' ')}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleMute}
>
{isMuted || state.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<div className="w-24">
<Slider
value={[isMuted ? 0 : state.volume]}
max={100}
step={1}
onValueChange={handleVolumeChange}
className="w-full"
/>
</div>
<span className="text-sm text-muted-foreground w-8">
{Math.round(isMuted ? 0 : state.volume)}%
</span>
</div>
</div>
</div>
{/* Playlist Sidebar */}
{state.playlist && (
<div className="w-96 border-l bg-muted/10 backdrop-blur-sm">
<div className="p-4 border-b">
<h3 className="font-semibold">Playlist</h3>
<p className="text-sm text-muted-foreground">
{state.playlist.sounds.length} tracks
</p>
</div>
<div className="p-4">
<Playlist
playlist={state.playlist}
currentIndex={state.index}
onTrackSelect={(index) => executeAction(() => playerService.playAtIndex(index), 'play track')}
variant="maximized"
/>
</div>
</div>
)}
</div>
</div>
)
if (!state) return null
return (
<div className={cn(getPlayerPosition(), className)}>
{displayMode === 'minimized' && renderMinimizedPlayer()}
{displayMode === 'normal' && renderNormalPlayer()}
{displayMode === 'maximized' && renderMaximizedPlayer()}
</div>
)
}