Compare commits

...

5 Commits

Author SHA1 Message Date
e567a2c5ab Player full fonctionnel ! 2025-12-23 09:27:01 +01:00
051d0431ce WIP Player
waveform ok, autoplay, download
2025-12-23 09:04:27 +01:00
359c8ccccc WIP Player 2025-12-23 08:46:33 +01:00
6c47f0760e Frontend : filtres 2025-12-22 15:53:26 +01:00
b0ba1e886c Amélioration affichage genre : 'Pop---Rock' -> 'Pop' ; 'Rock' 2025-12-22 15:12:14 +01:00
6 changed files with 709 additions and 21 deletions

View File

@@ -22,6 +22,9 @@ async def get_tracks(
energy_min: Optional[float] = Query(None, ge=0, le=1), energy_min: Optional[float] = Query(None, ge=0, le=1),
energy_max: Optional[float] = Query(None, ge=0, le=1), energy_max: Optional[float] = Query(None, ge=0, le=1),
has_vocals: Optional[bool] = None, has_vocals: Optional[bool] = None,
key: Optional[str] = None,
instrument: Optional[str] = None,
tempo_range: Optional[str] = Query(None, regex="^(slow|medium|fast)$"),
sort_by: str = Query("analyzed_at", regex="^(analyzed_at|tempo_bpm|duration_seconds|filename|energy)$"), sort_by: str = Query("analyzed_at", regex="^(analyzed_at|tempo_bpm|duration_seconds|filename|energy)$"),
sort_desc: bool = True, sort_desc: bool = True,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -38,6 +41,9 @@ async def get_tracks(
energy_min: Minimum energy energy_min: Minimum energy
energy_max: Maximum energy energy_max: Maximum energy
has_vocals: Filter by vocal presence has_vocals: Filter by vocal presence
key: Filter by musical key
instrument: Filter by instrument
tempo_range: Filter by tempo range (slow: <100, medium: 100-140, fast: >140)
sort_by: Field to sort by sort_by: Field to sort by
sort_desc: Sort descending sort_desc: Sort descending
db: Database session db: Database session
@@ -45,6 +51,16 @@ async def get_tracks(
Returns: Returns:
Paginated list of tracks with total count Paginated list of tracks with total count
""" """
# Convert tempo_range to bpm_min/bpm_max
if tempo_range:
if tempo_range == "slow":
bpm_max = 100.0 if bpm_max is None else min(bpm_max, 100.0)
elif tempo_range == "medium":
bpm_min = 100.0 if bpm_min is None else max(bpm_min, 100.0)
bpm_max = 140.0 if bpm_max is None else min(bpm_max, 140.0)
elif tempo_range == "fast":
bpm_min = 140.0 if bpm_min is None else max(bpm_min, 140.0)
tracks, total = crud.get_tracks( tracks, total = crud.get_tracks(
db=db, db=db,
skip=skip, skip=skip,
@@ -56,6 +72,8 @@ async def get_tracks(
energy_min=energy_min, energy_min=energy_min,
energy_max=energy_max, energy_max=energy_max,
has_vocals=has_vocals, has_vocals=has_vocals,
key=key,
instrument=instrument,
sort_by=sort_by, sort_by=sort_by,
sort_desc=sort_desc, sort_desc=sort_desc,
) )

View File

@@ -2,7 +2,7 @@
from typing import List, Optional, Dict, Tuple from typing import List, Optional, Dict, Tuple
from uuid import UUID from uuid import UUID
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_, and_, func from sqlalchemy import or_, and_, func, any_
from .schema import AudioTrack from .schema import AudioTrack
from ..core.analyzer import AudioAnalysis from ..core.analyzer import AudioAnalysis
@@ -103,6 +103,8 @@ def get_tracks(
energy_min: Optional[float] = None, energy_min: Optional[float] = None,
energy_max: Optional[float] = None, energy_max: Optional[float] = None,
has_vocals: Optional[bool] = None, has_vocals: Optional[bool] = None,
key: Optional[str] = None,
instrument: Optional[str] = None,
sort_by: str = "analyzed_at", sort_by: str = "analyzed_at",
sort_desc: bool = True, sort_desc: bool = True,
) -> Tuple[List[AudioTrack], int]: ) -> Tuple[List[AudioTrack], int]:
@@ -112,13 +114,15 @@ def get_tracks(
db: Database session db: Database session
skip: Number of records to skip skip: Number of records to skip
limit: Maximum number of records to return limit: Maximum number of records to return
genre: Filter by genre genre: Filter by genre (searches in genre_primary, supports category matching)
mood: Filter by mood mood: Filter by mood
bpm_min: Minimum BPM bpm_min: Minimum BPM
bpm_max: Maximum BPM bpm_max: Maximum BPM
energy_min: Minimum energy (0-1) energy_min: Minimum energy (0-1)
energy_max: Maximum energy (0-1) energy_max: Maximum energy (0-1)
has_vocals: Filter by vocal presence has_vocals: Filter by vocal presence
key: Filter by musical key
instrument: Filter by instrument
sort_by: Field to sort by sort_by: Field to sort by
sort_desc: Sort descending if True sort_desc: Sort descending if True
@@ -129,10 +133,12 @@ def get_tracks(
# Apply filters # Apply filters
if genre: if genre:
# Match genre category (e.g., "Pop" matches "Pop---Ballad", "Pop---Indie Pop", etc.)
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.genre_primary.like(f"{genre}%"),
AudioTrack.genre_primary == genre, AudioTrack.genre_primary == genre,
AudioTrack.genre_secondary.contains([genre]) AudioTrack.genre_secondary.any(genre)
) )
) )
@@ -140,7 +146,7 @@ def get_tracks(
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.mood_primary == mood, AudioTrack.mood_primary == mood,
AudioTrack.mood_secondary.contains([mood]) AudioTrack.mood_secondary.any(mood)
) )
) )
@@ -159,6 +165,12 @@ def get_tracks(
if has_vocals is not None: if has_vocals is not None:
query = query.filter(AudioTrack.has_vocals == has_vocals) query = query.filter(AudioTrack.has_vocals == has_vocals)
if key:
query = query.filter(AudioTrack.key == key)
if instrument:
query = query.filter(AudioTrack.instruments.any(instrument))
# Get total count before pagination # Get total count before pagination
total = query.count() total = query.count()
@@ -213,7 +225,7 @@ def search_tracks(
search_query = search_query.filter( search_query = search_query.filter(
or_( or_(
AudioTrack.genre_primary == genre, AudioTrack.genre_primary == genre,
AudioTrack.genre_secondary.contains([genre]) AudioTrack.genre_secondary.any(genre)
) )
) )
@@ -221,7 +233,7 @@ def search_tracks(
search_query = search_query.filter( search_query = search_query.filter(
or_( or_(
AudioTrack.mood_primary == mood, AudioTrack.mood_primary == mood,
AudioTrack.mood_secondary.contains([mood]) AudioTrack.mood_secondary.any(mood)
) )
) )
@@ -265,7 +277,7 @@ def get_similar_tracks(
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.genre_primary == ref_track.genre_primary, AudioTrack.genre_primary == ref_track.genre_primary,
AudioTrack.genre_secondary.contains([ref_track.genre_primary]) AudioTrack.genre_secondary.any(ref_track.genre_primary)
) )
) )
@@ -274,7 +286,7 @@ def get_similar_tracks(
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.mood_primary == ref_track.mood_primary, AudioTrack.mood_primary == ref_track.mood_primary,
AudioTrack.mood_secondary.contains([ref_track.mood_primary]) AudioTrack.mood_secondary.any(ref_track.mood_primary)
) )
) )

View File

@@ -1,13 +1,61 @@
"use client" "use client"
import { useState } from "react" import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { getTracks, getStats } from "@/lib/api" import { getTracks, getStats } from "@/lib/api"
import type { FilterParams } from "@/lib/types" 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() { export default function Home() {
const [filters, setFilters] = useState<FilterParams>({}) const [filters, setFilters] = useState<FilterParams>({})
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
const limit = 50 const limit = 50
const { data: tracksData, isLoading: isLoadingTracks } = useQuery({ const { data: tracksData, isLoading: isLoadingTracks } = useQuery({
@@ -20,6 +68,16 @@ export default function Home() {
queryFn: getStats, 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 ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Header */} {/* Header */}
@@ -31,7 +89,7 @@ export default function Home() {
</header> </header>
{/* Main Content */} {/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-32">
{/* Stats */} {/* Stats */}
{stats && ( {stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
@@ -54,6 +112,19 @@ export default function Home() {
</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 */} {/* Tracks List */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="p-4 border-b"> <div className="p-4 border-b">
@@ -79,9 +150,21 @@ export default function Home() {
{/* Primary metadata */} {/* Primary metadata */}
<div className="mt-1 flex flex-wrap gap-2"> <div className="mt-1 flex flex-wrap gap-2">
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800"> {(() => {
{track.classification.genre.primary} const genre = formatGenre(track.classification.genre.primary)
</span> 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"> <span className="inline-flex items-center px-2 py-1 rounded text-xs bg-purple-100 text-purple-800">
{track.classification.mood.primary} {track.classification.mood.primary}
</span> </span>
@@ -96,10 +179,25 @@ export default function Home() {
</span> </span>
</div> </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 */} {/* Secondary moods */}
{track.classification.mood.secondary && track.classification.mood.secondary.length > 0 && ( {track.classification.mood.secondary && track.classification.mood.secondary.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
<span className="text-xs text-gray-400">Also:</span> <span className="text-xs text-gray-400">Moods:</span>
{track.classification.mood.secondary.map((mood, i) => ( {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"> <span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-600">
{mood} {mood}
@@ -124,14 +222,12 @@ export default function Home() {
)} )}
</div> </div>
<div className="ml-4 flex gap-2"> <div className="ml-4 flex gap-2">
<a <button
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/stream/${track.id}`} onClick={() => setCurrentTrack(track)}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700" className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
> >
Play {currentTrack?.id === track.id ? '▶ Playing' : 'Play'}
</a> </button>
<a <a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/download/${track.id}`} href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/download/${track.id}`}
download download
@@ -186,6 +282,11 @@ export default function Home() {
</ol> </ol>
</div> </div>
</main> </main>
{/* Fixed Audio Player at bottom - always visible */}
<div className="fixed bottom-0 left-0 right-0 z-50">
<AudioPlayer track={currentTrack} />
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,306 @@
"use client"
import { useState, useRef, useEffect } from "react"
import type { Track } from "@/lib/types"
interface AudioPlayerProps {
track: Track | null
}
export default function AudioPlayer({ track }: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1)
const [isMuted, setIsMuted] = useState(false)
const [waveformPeaks, setWaveformPeaks] = useState<number[]>([])
const [isLoadingWaveform, setIsLoadingWaveform] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
// Load audio and waveform when track changes
useEffect(() => {
if (!track) {
setIsPlaying(false)
setCurrentTime(0)
setWaveformPeaks([])
return
}
setCurrentTime(0)
loadWaveform(track.id)
if (audioRef.current) {
audioRef.current.load()
// Autoplay when track loads
audioRef.current.play().then(() => {
setIsPlaying(true)
}).catch((error: unknown) => {
console.error("Autoplay failed:", error)
setIsPlaying(false)
})
}
}, [track?.id])
// Update current time as audio plays
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const updateTime = () => setCurrentTime(audio.currentTime)
const updateDuration = () => {
if (audio.duration && isFinite(audio.duration)) {
setDuration(audio.duration)
}
}
const handleEnded = () => setIsPlaying(false)
audio.addEventListener("timeupdate", updateTime)
audio.addEventListener("loadedmetadata", updateDuration)
audio.addEventListener("durationchange", updateDuration)
audio.addEventListener("ended", handleEnded)
// Initialize duration if already loaded
if (audio.duration && isFinite(audio.duration)) {
setDuration(audio.duration)
}
return () => {
audio.removeEventListener("timeupdate", updateTime)
audio.removeEventListener("loadedmetadata", updateDuration)
audio.removeEventListener("durationchange", updateDuration)
audio.removeEventListener("ended", handleEnded)
}
}, [track?.id])
const loadWaveform = async (trackId: string) => {
setIsLoadingWaveform(true)
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/audio/waveform/${trackId}`
)
if (response.ok) {
const data = await response.json()
setWaveformPeaks(data.peaks || [])
}
} catch (error) {
console.error("Failed to load waveform:", error)
} finally {
setIsLoadingWaveform(false)
}
}
const togglePlay = () => {
if (!audioRef.current || !track) return
if (isPlaying) {
audioRef.current.pause()
} else {
audioRef.current.play()
}
setIsPlaying(!isPlaying)
}
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value)
setVolume(newVolume)
if (audioRef.current) {
audioRef.current.volume = newVolume
}
if (newVolume === 0) {
setIsMuted(true)
} else if (isMuted) {
setIsMuted(false)
}
}
const toggleMute = () => {
if (!audioRef.current) return
if (isMuted) {
audioRef.current.volume = volume
setIsMuted(false)
} else {
audioRef.current.volume = 0
setIsMuted(true)
}
}
const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!audioRef.current || !progressRef.current || !track) return
const rect = progressRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = x / rect.width
const newTime = percentage * duration
audioRef.current.currentTime = newTime
setCurrentTime(newTime)
}
const formatTime = (seconds: number) => {
if (!isFinite(seconds)) return "0:00"
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, "0")}`
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="bg-gray-50 border-t border-gray-300 shadow-lg" style={{ height: '80px' }}>
{/* Hidden audio element */}
{track && <audio ref={audioRef} src={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/stream/${track.id}`} />}
<div className="h-full flex items-center gap-3 px-4">
{/* Play/Pause button */}
<button
onClick={togglePlay}
disabled={!track}
className="w-10 h-10 flex items-center justify-center bg-orange-500 hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-full transition-colors flex-shrink-0"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
<svg className="w-4 h-4 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</button>
{/* Track info */}
<div className="flex-shrink-0 w-48">
{track ? (
<>
<div className="text-sm font-medium text-gray-900 truncate">
{track.filename}
</div>
<div className="text-xs text-gray-500">
{track.classification.genre.primary.split("---")[0]} {Math.round(track.features.tempo_bpm)} BPM
</div>
</>
) : (
<div className="text-sm text-gray-400">No track selected</div>
)}
</div>
{/* Time */}
<div className="text-xs text-gray-500 flex-shrink-0 w-16">
{formatTime(currentTime)}
</div>
{/* Waveform */}
<div className="flex-1 min-w-0">
<div
ref={progressRef}
className="relative h-12 cursor-pointer overflow-hidden flex items-center bg-gray-100 rounded"
onClick={handleWaveformClick}
>
{isLoadingWaveform ? (
<div className="flex items-center justify-center h-full w-full">
<span className="text-xs text-gray-400">Loading...</span>
</div>
) : waveformPeaks.length > 0 ? (
<div className="flex items-center h-full w-full gap-[1px] px-1">
{waveformPeaks
.filter((_: number, index: number) => index % 4 === 0) // Take every 4th peak to reduce from 800 to 200
.map((peak: number, index: number) => {
const originalIndex = index * 4
const isPlayed = (originalIndex / waveformPeaks.length) * 100 <= progress
return (
<div
key={index}
className="flex-1 flex items-center justify-center"
style={{
minWidth: "1px",
maxWidth: "4px",
height: "100%",
}}
>
<div
className={`w-full rounded-sm transition-colors ${
isPlayed ? "bg-orange-500" : "bg-gray-400"
}`}
style={{
height: `${Math.max(peak * 70, 4)}%`,
}}
/>
</div>
)
})}
</div>
) : (
<div className="flex items-center h-full w-full px-2">
<div className="w-full h-1 bg-gray-300 rounded-full">
<div
className="h-full bg-orange-500 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
</div>
{/* Time remaining */}
<div className="text-xs text-gray-500 flex-shrink-0 w-16 text-right">
{formatTime(duration)}
</div>
{/* Volume control */}
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={toggleMute}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-gray-900 transition-colors rounded hover:bg-gray-200"
aria-label={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
) : volume < 0.5 ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 9v6h4l5 5V4l-5 5H7z"/>
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-orange-500"
style={{
background: `linear-gradient(to right, #f97316 0%, #f97316 ${(isMuted ? 0 : volume) * 100}%, #d1d5db ${(isMuted ? 0 : volume) * 100}%, #d1d5db 100%)`
}}
aria-label="Volume"
/>
</div>
{/* Download button */}
{track && (
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/download/${track.id}`}
download
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-gray-900 transition-colors rounded hover:bg-gray-200 flex-shrink-0"
aria-label="Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,248 @@
"use client"
import { useState, useEffect } from "react"
import type { FilterParams } from "@/lib/types"
interface FilterPanelProps {
filters: FilterParams
onFiltersChange: (filters: FilterParams) => void
availableGenres: string[]
availableMoods: string[]
availableInstruments: string[]
availableKeys: string[]
}
export default function FilterPanel({
filters,
onFiltersChange,
availableGenres,
availableMoods,
availableInstruments,
availableKeys,
}: FilterPanelProps) {
const [localFilters, setLocalFilters] = useState<FilterParams>(filters)
// Update local filters when parent changes
useEffect(() => {
setLocalFilters(filters)
}, [filters])
const handleFilterChange = (key: keyof FilterParams, value: any) => {
const newFilters = { ...localFilters, [key]: value }
setLocalFilters(newFilters)
onFiltersChange(newFilters)
}
const clearFilters = () => {
const emptyFilters: FilterParams = {}
setLocalFilters(emptyFilters)
onFiltersChange(emptyFilters)
}
const hasActiveFilters = Object.keys(localFilters).length > 0
return (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-900">Filters</h2>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-blue-600 hover:text-blue-800"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Genre Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Genre
</label>
<select
value={localFilters.genre || ""}
onChange={(e) => handleFilterChange("genre", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Genres</option>
{availableGenres.map((genre) => (
<option key={genre} value={genre}>
{genre}
</option>
))}
</select>
</div>
{/* Mood Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mood
</label>
<select
value={localFilters.mood || ""}
onChange={(e) => handleFilterChange("mood", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Moods</option>
{availableMoods.map((mood) => (
<option key={mood} value={mood}>
{mood}
</option>
))}
</select>
</div>
{/* Instrument Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrument
</label>
<select
value={localFilters.instrument || ""}
onChange={(e) => handleFilterChange("instrument", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Instruments</option>
{availableInstruments.map((instrument) => (
<option key={instrument} value={instrument}>
{instrument}
</option>
))}
</select>
</div>
{/* Key Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Key
</label>
<select
value={localFilters.key || ""}
onChange={(e) => handleFilterChange("key", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Keys</option>
{availableKeys.map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
{/* Tempo Range Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tempo
</label>
<select
value={localFilters.tempo_range || ""}
onChange={(e) => handleFilterChange("tempo_range", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Tempos</option>
<option value="slow">Slow (&lt;100 BPM)</option>
<option value="medium">Medium (100-140 BPM)</option>
<option value="fast">Fast (&gt;140 BPM)</option>
</select>
</div>
{/* Sort By */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sort By
</label>
<select
value={localFilters.sort_by || "analyzed_at"}
onChange={(e) => handleFilterChange("sort_by", e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="analyzed_at">Date Added</option>
<option value="filename">Filename</option>
<option value="tempo_bpm">BPM</option>
<option value="duration_seconds">Duration</option>
<option value="energy">Energy</option>
</select>
</div>
{/* Sort Order */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Order
</label>
<select
value={localFilters.sort_desc ? "desc" : "asc"}
onChange={(e) => handleFilterChange("sort_desc", e.target.value === "desc")}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="mt-4 flex flex-wrap gap-2">
{localFilters.genre && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
Genre: {localFilters.genre}
<button
onClick={() => handleFilterChange("genre", undefined)}
className="ml-1 hover:text-blue-900"
>
×
</button>
</span>
)}
{localFilters.mood && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-purple-100 text-purple-800">
Mood: {localFilters.mood}
<button
onClick={() => handleFilterChange("mood", undefined)}
className="ml-1 hover:text-purple-900"
>
×
</button>
</span>
)}
{localFilters.instrument && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-green-100 text-green-800">
Instrument: {localFilters.instrument}
<button
onClick={() => handleFilterChange("instrument", undefined)}
className="ml-1 hover:text-green-900"
>
×
</button>
</span>
)}
{localFilters.key && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-yellow-100 text-yellow-800">
Key: {localFilters.key}
<button
onClick={() => handleFilterChange("key", undefined)}
className="ml-1 hover:text-yellow-900"
>
×
</button>
</span>
)}
{localFilters.tempo_range && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-orange-100 text-orange-800">
Tempo: {localFilters.tempo_range}
<button
onClick={() => handleFilterChange("tempo_range", undefined)}
className="ml-1 hover:text-orange-900"
>
×
</button>
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -58,6 +58,9 @@ export interface FilterParams {
energy_min?: number energy_min?: number
energy_max?: number energy_max?: number
has_vocals?: boolean has_vocals?: boolean
key?: string
instrument?: string
tempo_range?: 'slow' | 'medium' | 'fast' // Lent (<100), Moyen (100-140), Rapide (>140)
sort_by?: 'analyzed_at' | 'tempo_bpm' | 'duration_seconds' | 'filename' | 'energy' sort_by?: 'analyzed_at' | 'tempo_bpm' | 'duration_seconds' | 'filename' | 'energy'
sort_desc?: boolean sort_desc?: boolean
} }