Files
Audio-Classifier/frontend/app/page.tsx
2025-12-23 08:46:33 +01:00

295 lines
12 KiB
TypeScript

"use client"
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getTracks, getStats } from "@/lib/api"
import type { FilterParams, Track } from "@/lib/types"
import FilterPanel from "@/components/FilterPanel"
import AudioPlayer from "@/components/AudioPlayer"
// Helper function to format Discogs genre labels (e.g., "Pop---Ballad" -> ["Pop", "Ballad"])
function formatGenre(genre: string): { category: string; subgenre: string } {
const parts = genre.split('---')
return {
category: parts[0] || genre,
subgenre: parts[1] || ''
}
}
// Extract unique values for filter options
function extractFilterOptions(tracks: Track[]) {
const genres = new Set<string>()
const moods = new Set<string>()
const instruments = new Set<string>()
const keys = new Set<string>()
tracks.forEach(track => {
// Extract genre category (before "---")
const genreCategory = formatGenre(track.classification.genre.primary).category
genres.add(genreCategory)
// Extract primary mood
if (track.classification.mood.primary) {
moods.add(track.classification.mood.primary)
}
// Extract instruments
track.classification.instruments?.forEach(instrument => {
instruments.add(instrument)
})
// Extract key
if (track.features.key) {
keys.add(track.features.key)
}
})
return {
genres: Array.from(genres).sort(),
moods: Array.from(moods).sort(),
instruments: Array.from(instruments).sort(),
keys: Array.from(keys).sort(),
}
}
export default function Home() {
const [filters, setFilters] = useState<FilterParams>({})
const [page, setPage] = useState(0)
const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
const limit = 50
const { data: tracksData, isLoading: isLoadingTracks } = useQuery({
queryKey: ['tracks', filters, page],
queryFn: () => getTracks({ ...filters, skip: page * limit, limit }),
})
const { data: stats } = useQuery({
queryKey: ['stats'],
queryFn: getStats,
})
// Extract filter options from displayed tracks
// For better UX, we use the current page's tracks to populate filters
// TODO: Add a dedicated API endpoint to get all unique filter values
const filterOptions = useMemo(() => {
if (!tracksData?.tracks || tracksData.tracks.length === 0) {
return { genres: [], moods: [], instruments: [], keys: [] }
}
return extractFilterOptions(tracksData.tracks)
}, [tracksData])
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<h1 className="text-3xl font-bold text-gray-900">Audio Classifier</h1>
<p className="text-gray-600">Intelligent music library management</p>
</div>
</header>
{/* Main Content */}
<main className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 ${currentTrack ? 'pb-32' : ''}`}>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white p-4 rounded-lg shadow">
<p className="text-gray-600 text-sm">Total Tracks</p>
<p className="text-2xl font-bold">{stats.total_tracks}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<p className="text-gray-600 text-sm">Avg BPM</p>
<p className="text-2xl font-bold">{stats.average_bpm}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<p className="text-gray-600 text-sm">Total Hours</p>
<p className="text-2xl font-bold">{stats.total_duration_hours}h</p>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<p className="text-gray-600 text-sm">Genres</p>
<p className="text-2xl font-bold">{stats.genres.length}</p>
</div>
</div>
)}
{/* Filter Panel */}
<FilterPanel
filters={filters}
onFiltersChange={(newFilters) => {
setFilters(newFilters)
setPage(0) // Reset to first page when filters change
}}
availableGenres={filterOptions.genres}
availableMoods={filterOptions.moods}
availableInstruments={filterOptions.instruments}
availableKeys={filterOptions.keys}
/>
{/* Tracks List */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-xl font-semibold">Music Library</h2>
<p className="text-gray-600 text-sm">
{tracksData?.total || 0} tracks total
</p>
</div>
{isLoadingTracks ? (
<div className="p-8 text-center text-gray-600">Loading...</div>
) : tracksData?.tracks.length === 0 ? (
<div className="p-8 text-center text-gray-600">
No tracks found. Start by analyzing your audio library!
</div>
) : (
<div className="divide-y">
{tracksData?.tracks.map((track) => (
<div key={track.id} className="p-4 hover:bg-gray-50">
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="font-medium text-gray-900">{track.filename}</h3>
{/* Primary metadata */}
<div className="mt-1 flex flex-wrap gap-2">
{(() => {
const genre = formatGenre(track.classification.genre.primary)
return (
<>
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800 font-semibold">
{genre.category}
</span>
{genre.subgenre && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-50 text-blue-700">
{genre.subgenre}
</span>
)}
</>
)
})()}
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-purple-100 text-purple-800">
{track.classification.mood.primary}
</span>
<span className="text-xs text-gray-500">
{Math.round(track.features.tempo_bpm)} BPM
</span>
<span className="text-xs text-gray-500">
{track.features.key}
</span>
<span className="text-xs text-gray-500">
{Math.floor(track.duration_seconds / 60)}:{String(Math.floor(track.duration_seconds % 60)).padStart(2, '0')}
</span>
</div>
{/* Secondary genres */}
{track.classification.genre.secondary && track.classification.genre.secondary.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
<span className="text-xs text-gray-400">Also:</span>
{track.classification.genre.secondary.slice(0, 3).map((g, i) => {
const genre = formatGenre(g)
return (
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600">
{genre.category}{genre.subgenre && `${genre.subgenre}`}
</span>
)
})}
</div>
)}
{/* Secondary moods */}
{track.classification.mood.secondary && track.classification.mood.secondary.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
<span className="text-xs text-gray-400">Moods:</span>
{track.classification.mood.secondary.map((mood, i) => (
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-600">
{mood}
</span>
))}
</div>
)}
{/* Instruments */}
{track.classification.instruments && track.classification.instruments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
<span className="text-xs text-gray-400">Instruments:</span>
{track.classification.instruments.slice(0, 6).map((instrument, i) => (
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-green-50 text-green-700">
{instrument}
</span>
))}
{track.classification.instruments.length > 6 && (
<span className="text-xs text-gray-400">+{track.classification.instruments.length - 6} more</span>
)}
</div>
)}
</div>
<div className="ml-4 flex gap-2">
<button
onClick={() => setCurrentTrack(track)}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
{currentTrack?.id === track.id ? '▶ Playing' : 'Play'}
</button>
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/download/${track.id}`}
download
className="px-3 py-1 text-sm bg-gray-600 text-white rounded hover:bg-gray-700"
>
Download
</a>
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{tracksData && tracksData.total > limit && (
<div className="p-4 border-t flex justify-between items-center">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page + 1} of {Math.ceil(tracksData.total / limit)}
</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={(page + 1) * limit >= tracksData.total}
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
{/* Instructions */}
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="font-semibold text-blue-900 mb-2">Getting Started</h3>
<ol className="list-decimal list-inside space-y-1 text-blue-800 text-sm">
<li>Make sure the backend is running (<code>docker-compose up</code>)</li>
<li>Use the API to analyze your audio library:
<pre className="mt-2 bg-blue-100 p-2 rounded text-xs">
{`curl -X POST http://localhost:8000/api/analyze/folder \\
-H "Content-Type: application/json" \\
-d '{"path": "/audio/your_music", "recursive": true}'`}
</pre>
</li>
<li>Refresh this page to see your analyzed tracks</li>
</ol>
</div>
</main>
{/* Fixed Audio Player at bottom */}
{currentTrack && (
<div className="fixed bottom-0 left-0 right-0 z-50 shadow-2xl">
<AudioPlayer track={currentTrack} onClose={() => setCurrentTrack(null)} />
</div>
)}
</div>
)
}