diff --git a/bun.lock b/bun.lock index 92d607e..24439fb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 6e80276..cd49257 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 1339625..74e3f8d 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -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} + ) } \ No newline at end of file diff --git a/src/components/player/Player.tsx b/src/components/player/Player.tsx new file mode 100644 index 0000000..d8443dd --- /dev/null +++ b/src/components/player/Player.tsx @@ -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({ + status: 'stopped', + mode: 'continuous', + volume: 50, + position: 0 + }) + const [displayMode, setDisplayMode] = useState('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, 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 + case 'loop_one': + return + case 'random': + return + default: + return + } + } + + 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 = () => ( + + +
+ + + + + +
+
+
+ ) + + const renderNormalPlayer = () => ( + + + {/* Window Controls */} +
+ + +
+ + {/* Album Art / Thumbnail */} +
+ {state.current_sound?.thumbnail ? ( +
+ {state.current_sound.name} { + // 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} +
+ + {/* Track Info */} +
+
+ {state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && ( + + + + + + {state.current_sound.extract_url && ( + + + + Source + + + )} + + + File + + + + )} +

+ {state.current_sound?.name || 'No track selected'} +

+
+ {state.playlist && ( +

+ {state.playlist.name} +

+ )} +
+ + {/* Progress Bar */} +
+ { + 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]) + }} + /> +
+ {formatDuration(state.position)} + {formatDuration(state.duration || 0)} +
+
+ + {/* Main Controls */} +
+ + + + + + +
+ + {/* Secondary Controls */} +
+ + {state.mode.replace('_', ' ')} + + +
+ +
+ +
+
+
+ + {/* Playlist */} + {showPlaylist && state.playlist && ( +
+ executeAction(() => playerService.playAtIndex(index), 'play track')} + /> +
+ )} +
+
+ ) + + const renderMaximizedPlayer = () => ( +
+ {/* Header */} +
+

Now Playing

+ +
+ +
+ {/* Main Player Area */} +
+ {/* Large Album Art */} +
+ {state.current_sound?.thumbnail ? ( + {state.current_sound.name} { + // 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} + +
+ + {/* Track Info */} +
+
+ {state.current_sound && (state.current_sound.extract_url || state.current_sound.id) && ( + + + + + + {state.current_sound.extract_url && ( + + + + Source + + + )} + + + File + + + + )} +

+ {state.current_sound?.name || 'No track selected'} +

+
+ {state.playlist && ( +

+ {state.playlist.name} +

+ )} +
+ + {/* Progress Bar */} +
+ { + 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]) + }} + /> +
+ {formatDuration(state.position)} + {formatDuration(state.duration || 0)} +
+
+ + {/* Large Controls */} +
+ + + + +
+ + {/* Secondary Controls */} +
+
+ + + {state.mode.replace('_', ' ')} + +
+ +
+ +
+ +
+ + {Math.round(isMuted ? 0 : state.volume)}% + +
+
+
+ + {/* Playlist Sidebar */} + {state.playlist && ( +
+
+

Playlist

+

+ {state.playlist.sounds.length} tracks +

+
+
+ executeAction(() => playerService.playAtIndex(index), 'play track')} + variant="maximized" + /> +
+
+ )} +
+
+ ) + + if (!state) return null + + return ( +
+ {displayMode === 'minimized' && renderMinimizedPlayer()} + {displayMode === 'normal' && renderNormalPlayer()} + {displayMode === 'maximized' && renderMaximizedPlayer()} +
+ ) +} \ No newline at end of file diff --git a/src/components/player/Playlist.tsx b/src/components/player/Playlist.tsx new file mode 100644 index 0000000..23d54cb --- /dev/null +++ b/src/components/player/Playlist.tsx @@ -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 ( +
+
+

+ {playlist.name} +

+ + {playlist.sounds.length} tracks + +
+ + +
+ {playlist.sounds.map((sound, index) => ( +
onTrackSelect(index)} + > + {/* Track Number or Play Icon */} +
+ {currentIndex === index ? ( + + ) : ( + + {index + 1} + + )} +
+ + {/* Thumbnail */} +
+ {sound.thumbnail ? ( + {sound.name} { + // 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} + +
+ + {/* Track Info */} +
+

+ {sound.name} +

+

+ {sound.filename} +

+
+ + {/* Duration and Type */} +
+ + {formatDuration(sound.duration)} + + + {sound.type} + +
+
+ ))} +
+
+ + {/* Playlist Stats */} +
+
+ {playlist.sounds.length} tracks + {formatDuration(playlist.duration)} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..2a03bd3 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/src/lib/api/services/extractions.ts b/src/lib/api/services/extractions.ts new file mode 100644 index 0000000..78e127a --- /dev/null +++ b/src/lib/api/services/extractions.ts @@ -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 { + const response = await apiClient.post(`/api/v1/extractions/?url=${encodeURIComponent(url)}`) + return response + } + + /** + * Get extraction by ID + */ + async getExtraction(extractionId: number): Promise { + const response = await apiClient.get(`/api/v1/extractions/${extractionId}`) + return response + } + + /** + * Get user's extractions + */ + async getUserExtractions(): Promise { + const response = await apiClient.get('/api/v1/extractions/') + return response.extractions + } +} + +export const extractionsService = new ExtractionsService() \ No newline at end of file diff --git a/src/lib/api/services/files.ts b/src/lib/api/services/files.ts new file mode 100644 index 0000000..6834ebe --- /dev/null +++ b/src/lib/api/services/files.ts @@ -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 { + 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 { + 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 { + 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() \ No newline at end of file diff --git a/src/lib/api/services/index.ts b/src/lib/api/services/index.ts index 0d4fc89..0a79720 100644 --- a/src/lib/api/services/index.ts +++ b/src/lib/api/services/index.ts @@ -1,2 +1,5 @@ export * from './auth' -export * from './sounds' \ No newline at end of file +export * from './sounds' +export * from './player' +export * from './files' +export * from './extractions' \ No newline at end of file diff --git a/src/lib/api/services/player.ts b/src/lib/api/services/player.ts new file mode 100644 index 0000000..d9d7332 --- /dev/null +++ b/src/lib/api/services/player.ts @@ -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 { + return apiClient.post('/api/v1/player/play') + } + + /** + * Play sound at specific index + */ + async playAtIndex(index: number): Promise { + return apiClient.post(`/api/v1/player/play/${index}`) + } + + /** + * Pause playback + */ + async pause(): Promise { + return apiClient.post('/api/v1/player/pause') + } + + /** + * Stop playback + */ + async stop(): Promise { + return apiClient.post('/api/v1/player/stop') + } + + /** + * Skip to next track + */ + async next(): Promise { + return apiClient.post('/api/v1/player/next') + } + + /** + * Go to previous track + */ + async previous(): Promise { + return apiClient.post('/api/v1/player/previous') + } + + /** + * Seek to specific position + */ + async seek(position: number): Promise { + return apiClient.post('/api/v1/player/seek', { position }) + } + + /** + * Set playback volume + */ + async setVolume(volume: number): Promise { + return apiClient.post('/api/v1/player/volume', { volume }) + } + + /** + * Set playback mode + */ + async setMode(mode: PlayerMode): Promise { + return apiClient.post('/api/v1/player/mode', { mode }) + } + + /** + * Reload current playlist + */ + async reloadPlaylist(): Promise { + return apiClient.post('/api/v1/player/reload-playlist') + } + + /** + * Get current player state + */ + async getState(): Promise { + return apiClient.get('/api/v1/player/state') + } +} + +export const playerService = new PlayerService() \ No newline at end of file diff --git a/src/pages/ExtractionsPage.tsx b/src/pages/ExtractionsPage.tsx index e3f1824..6690428 100644 --- a/src/pages/ExtractionsPage.tsx +++ b/src/pages/ExtractionsPage.tsx @@ -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([]) + 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 Pending + case 'processing': + return Processing + case 'completed': + return Completed + case 'failed': + return Failed + } + } + + const getServiceBadge = (service: string | undefined) => { + if (!service) return null + + const serviceColors: Record = { + 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 ( + + {service.toUpperCase()} + + ) + } + return ( -
-

Audio Extractions

-

- Audio extraction management interface coming soon... -

+
+
+
+

Audio Extractions

+

+ Extract audio from YouTube, SoundCloud, and other platforms +

+
+ + + + + + + + Create New Extraction + +
+
+ + setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !isCreating) { + handleCreateExtraction() + } + }} + /> +

+ Supports YouTube, SoundCloud, Vimeo, TikTok, Twitter, Instagram, and more +

+
+
+ + +
+
+
+
+
+ + {isLoading ? ( +
+ + Loading extractions... +
+ ) : extractions.length === 0 ? ( + + +
+ +

No extractions yet

+

+ Start by adding a URL to extract audio from your favorite platforms +

+ +
+
+
+ ) : ( + + + Recent Extractions ({extractions.length}) + + + + + + Title + Service + Status + Created + Actions + + + + {extractions.map((extraction) => ( + + +
+
+ {extraction.title || 'Extracting...'} +
+
+ {extraction.url} +
+
+
+ + {getServiceBadge(extraction.service)} + + + {getStatusBadge(extraction.status)} + {extraction.error && ( +
+ {extraction.error} +
+ )} +
+ +
+ + {(() => { + try { + const date = new Date(extraction.created_at) + if (isNaN(date.getTime())) { + return 'Invalid date' + } + return formatDistanceToNow(date, { addSuffix: true }) + } catch { + return 'Invalid date' + } + })()} +
+
+ +
+ + {extraction.status === 'completed' && extraction.sound_id && ( + + )} +
+
+
+ ))} +
+
+
+
+ )}
)