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

@@ -13,6 +13,7 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
@@ -245,6 +246,8 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",

View File

@@ -9,6 +9,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Player } from './player/Player'
interface AppLayoutProps {
children: React.ReactNode
@@ -57,6 +58,7 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) {
{children}
</div>
</SidebarInset>
<Player />
</SidebarProvider>
)
}

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

View File

@@ -0,0 +1,134 @@
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Music, Play } from 'lucide-react'
import { type PlayerPlaylist } from '@/lib/api/services/player'
import { filesService } from '@/lib/api/services/files'
import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
interface PlaylistProps {
playlist: PlayerPlaylist
currentIndex?: number
onTrackSelect: (index: number) => void
variant?: 'normal' | 'maximized'
}
export function Playlist({
playlist,
currentIndex,
onTrackSelect,
variant = 'normal'
}: PlaylistProps) {
const maxHeight = variant === 'maximized' ? 'max-h-[calc(100vh-200px)]' : 'max-h-60'
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">
{playlist.name}
</h4>
<Badge variant="secondary" className="text-xs">
{playlist.sounds.length} tracks
</Badge>
</div>
<ScrollArea className={cn('w-full', maxHeight)}>
<div className="space-y-1">
{playlist.sounds.map((sound, index) => (
<div
key={sound.id}
className={cn(
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors',
'hover:bg-muted/50',
currentIndex === index && 'bg-primary/10 border border-primary/20'
)}
onClick={() => onTrackSelect(index)}
>
{/* Track Number or Play Icon */}
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
{currentIndex === index ? (
<Play className="h-3 w-3 text-primary" />
) : (
<span className="text-xs text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Thumbnail */}
<div className={cn(
'flex-shrink-0 bg-muted rounded flex items-center justify-center overflow-hidden',
variant === 'maximized' ? 'w-10 h-10' : 'w-8 h-8'
)}>
{sound.thumbnail ? (
<img
src={filesService.getThumbnailUrl(sound.id)}
alt={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(
'text-muted-foreground',
variant === 'maximized' ? 'h-5 w-5' : 'h-4 w-4',
sound.thumbnail ? 'hidden' : 'block'
)}
/>
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className={cn(
'font-medium truncate',
variant === 'maximized' ? 'text-sm' : 'text-xs',
currentIndex === index && 'text-primary'
)}>
{sound.name}
</p>
<p className={cn(
'text-muted-foreground truncate',
variant === 'maximized' ? 'text-xs' : 'text-[10px]'
)}>
{sound.filename}
</p>
</div>
{/* Duration and Type */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<span className={cn(
'text-muted-foreground',
variant === 'maximized' ? 'text-xs' : 'text-[10px]'
)}>
{formatDuration(sound.duration)}
</span>
<Badge
variant="outline"
className={cn(
variant === 'maximized' ? 'text-[10px] px-1' : 'text-[8px] px-1 py-0'
)}
>
{sound.type}
</Badge>
</div>
</div>
))}
</div>
</ScrollArea>
{/* Playlist Stats */}
<div className="pt-2 border-t">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{playlist.sounds.length} tracks</span>
<span>{formatDuration(playlist.duration)}</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,52 @@
import { apiClient } from '../client'
export interface ExtractionInfo {
id: number
url: string
status: 'pending' | 'processing' | 'completed' | 'failed'
title?: string
service?: string
service_id?: string
sound_id?: number
user_id: number
error?: string
created_at: string
updated_at: string
}
export interface CreateExtractionResponse {
message: string
extraction: ExtractionInfo
}
export interface GetExtractionsResponse {
extractions: ExtractionInfo[]
}
export class ExtractionsService {
/**
* Create a new extraction job
*/
async createExtraction(url: string): Promise<CreateExtractionResponse> {
const response = await apiClient.post<CreateExtractionResponse>(`/api/v1/extractions/?url=${encodeURIComponent(url)}`)
return response
}
/**
* Get extraction by ID
*/
async getExtraction(extractionId: number): Promise<ExtractionInfo> {
const response = await apiClient.get<ExtractionInfo>(`/api/v1/extractions/${extractionId}`)
return response
}
/**
* Get user's extractions
*/
async getUserExtractions(): Promise<ExtractionInfo[]> {
const response = await apiClient.get<GetExtractionsResponse>('/api/v1/extractions/')
return response.extractions
}
}
export const extractionsService = new ExtractionsService()

View File

@@ -0,0 +1,86 @@
import { apiClient } from '../client'
import { API_CONFIG } from '../config'
export class FilesService {
/**
* Download a sound file
*/
async downloadSound(soundId: number): Promise<void> {
try {
// Use fetch directly to handle file download
const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/download`, {
method: 'GET',
credentials: 'include',
})
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`)
}
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition')
let filename = `sound_${soundId}.mp3`
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/)
if (filenameMatch) {
filename = filenameMatch[1]
}
}
// Create blob and download
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
// Create temporary download link
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
// Cleanup
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to download sound:', error)
throw error
}
}
/**
* Get thumbnail URL for a sound
*/
getThumbnailUrl(soundId: number): string {
return `${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`
}
/**
* Check if a sound has a thumbnail
*/
async hasThumbnail(soundId: number): Promise<boolean> {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/api/v1/files/sounds/${soundId}/thumbnail`, {
method: 'HEAD', // Only check headers, don't download
credentials: 'include',
})
return response.ok
} catch {
return false
}
}
/**
* Preload a thumbnail image
*/
async preloadThumbnail(soundId: number): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
img.src = this.getThumbnailUrl(soundId)
})
}
}
export const filesService = new FilesService()

View File

@@ -1,2 +1,5 @@
export * from './auth'
export * from './sounds'
export * from './sounds'
export * from './player'
export * from './files'
export * from './extractions'

View File

@@ -0,0 +1,132 @@
import { apiClient } from '../client'
export type PlayerStatus = 'playing' | 'paused' | 'stopped'
export type PlayerMode = 'continuous' | 'loop' | 'loop_one' | 'random' | 'single'
export interface PlayerSound {
id: number
name: string
filename: string
duration: number
size: number
type: string
thumbnail?: string
play_count: number
extract_url?: string
}
export interface PlayerPlaylist {
id: number
name: string
length: number
duration: number
sounds: PlayerSound[]
}
export interface PlayerState {
status: PlayerStatus
mode: PlayerMode
volume: number
position: number
duration?: number
index?: number
current_sound?: PlayerSound
playlist?: PlayerPlaylist
}
export interface PlayerSeekRequest {
position: number
}
export interface PlayerVolumeRequest {
volume: number
}
export interface PlayerModeRequest {
mode: PlayerMode
}
export interface MessageResponse {
message: string
}
export class PlayerService {
/**
* Play current sound
*/
async play(): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/play')
}
/**
* Play sound at specific index
*/
async playAtIndex(index: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>(`/api/v1/player/play/${index}`)
}
/**
* Pause playback
*/
async pause(): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/pause')
}
/**
* Stop playback
*/
async stop(): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/stop')
}
/**
* Skip to next track
*/
async next(): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/next')
}
/**
* Go to previous track
*/
async previous(): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/previous')
}
/**
* Seek to specific position
*/
async seek(position: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/seek', { position })
}
/**
* Set playback volume
*/
async setVolume(volume: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/volume', { volume })
}
/**
* Set playback mode
*/
async setMode(mode: PlayerMode): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/mode', { mode })
}
/**
* Reload current playlist
*/
async reloadPlaylist(): Promise<MessageResponse> {
return apiClient.post<MessageResponse>('/api/v1/player/reload-playlist')
}
/**
* Get current player state
*/
async getState(): Promise<PlayerState> {
return apiClient.get<PlayerState>('/api/v1/player/state')
}
}
export const playerService = new PlayerService()

View File

@@ -1,6 +1,99 @@
import { useState, useEffect } from 'react'
import { AppLayout } from '@/components/AppLayout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Download, ExternalLink, Calendar, Clock, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
import { extractionsService, type ExtractionInfo } from '@/lib/api/services/extractions'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
export function ExtractionsPage() {
const [extractions, setExtractions] = useState<ExtractionInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [url, setUrl] = useState('')
const [isCreating, setIsCreating] = useState(false)
// Load extractions
const loadExtractions = async () => {
try {
setIsLoading(true)
const data = await extractionsService.getUserExtractions()
setExtractions(data)
} catch (error) {
console.error('Failed to load extractions:', error)
toast.error('Failed to load extractions')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadExtractions()
}, [])
// Create new extraction
const handleCreateExtraction = async () => {
if (!url.trim()) {
toast.error('Please enter a URL')
return
}
try {
setIsCreating(true)
const response = await extractionsService.createExtraction(url.trim())
toast.success(response.message)
setUrl('')
setIsDialogOpen(false)
// Refresh the list
await loadExtractions()
} catch (error) {
console.error('Failed to create extraction:', error)
toast.error('Failed to create extraction')
} finally {
setIsCreating(false)
}
}
const getStatusBadge = (status: ExtractionInfo['status']) => {
switch (status) {
case 'pending':
return <Badge variant="secondary" className="gap-1"><Clock className="h-3 w-3" />Pending</Badge>
case 'processing':
return <Badge variant="outline" className="gap-1"><Loader2 className="h-3 w-3 animate-spin" />Processing</Badge>
case 'completed':
return <Badge variant="default" className="gap-1"><CheckCircle className="h-3 w-3" />Completed</Badge>
case 'failed':
return <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" />Failed</Badge>
}
}
const getServiceBadge = (service: string | undefined) => {
if (!service) return null
const serviceColors: Record<string, string> = {
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
soundcloud: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
vimeo: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
}
const colorClass = serviceColors[service.toLowerCase()] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
return (
<Badge variant="outline" className={colorClass}>
{service.toUpperCase()}
</Badge>
)
}
return (
<AppLayout
breadcrumb={{
@@ -10,11 +103,162 @@ export function ExtractionsPage() {
]
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<h1 className="text-2xl font-bold mb-4">Audio Extractions</h1>
<p className="text-muted-foreground">
Audio extraction management interface coming soon...
</p>
<div className="flex-1 rounded-xl bg-muted/50 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Audio Extractions</h1>
<p className="text-muted-foreground">
Extract audio from YouTube, SoundCloud, and other platforms
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Add Extraction
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Extraction</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="url">URL</Label>
<Input
id="url"
placeholder="https://www.youtube.com/watch?v=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isCreating) {
handleCreateExtraction()
}
}}
/>
<p className="text-sm text-muted-foreground mt-1">
Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter, Instagram, and more
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateExtraction} disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Extraction'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading extractions...
</div>
) : extractions.length === 0 ? (
<Card>
<CardContent className="py-8">
<div className="text-center">
<Download className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No extractions yet</h3>
<p className="text-muted-foreground mb-4">
Start by adding a URL to extract audio from your favorite platforms
</p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
Add Your First Extraction
</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Recent Extractions ({extractions.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Service</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extractions.map((extraction) => (
<TableRow key={extraction.id}>
<TableCell>
<div>
<div className="font-medium">
{extraction.title || 'Extracting...'}
</div>
<div className="text-sm text-muted-foreground truncate max-w-64">
{extraction.url}
</div>
</div>
</TableCell>
<TableCell>
{getServiceBadge(extraction.service)}
</TableCell>
<TableCell>
{getStatusBadge(extraction.status)}
{extraction.error && (
<div className="text-xs text-destructive mt-1 max-w-48 truncate" title={extraction.error}>
{extraction.error}
</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
{(() => {
try {
const date = new Date(extraction.created_at)
if (isNaN(date.getTime())) {
return 'Invalid date'
}
return formatDistanceToNow(date, { addSuffix: true })
} catch {
return 'Invalid date'
}
})()}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" asChild>
<a href={extraction.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
{extraction.status === 'completed' && extraction.sound_id && (
<Button variant="ghost" size="sm" title="View in Sounds">
<Download className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
</AppLayout>
)