Spaces:
Sleeping
Sleeping
| import { useRef, useState, useEffect, useMemo } from "react"; | |
| import { Play, Pause, Volume2, VolumeX } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Slider } from "@/components/ui/slider"; | |
| import { Card, CardContent } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| interface ChunkMatch { | |
| score: number; | |
| title: string; | |
| artist: string; | |
| stemType?: string; | |
| matchedStartTime?: number; | |
| matchedEndTime?: number; | |
| matchType?: "exact" | "similar"; | |
| } | |
| interface ChunkAttribution { | |
| chunkIndex: number; | |
| startTime: number; | |
| endTime: number; | |
| matches: ChunkMatch[]; | |
| } | |
| interface AudioPlayerWithMatchesProps { | |
| src: string; | |
| title: string; | |
| stemType?: string; | |
| attributions: Array<{ | |
| id: number; | |
| score: number; | |
| metadata: { | |
| chunkIndex?: number; | |
| startTime?: number; | |
| endTime?: number; | |
| matchedTitle?: string; | |
| matchedArtist?: string; | |
| matchedStemType?: string; | |
| matchedStartTime?: number; | |
| matchedEndTime?: number; | |
| matchType?: "exact" | "similar"; | |
| } | null; | |
| }>; | |
| } | |
| export function AudioPlayerWithMatches({ | |
| src, | |
| title, | |
| stemType, | |
| attributions | |
| }: AudioPlayerWithMatchesProps) { | |
| const audioRef = useRef<HTMLAudioElement>(null); | |
| const waveformRef = useRef<HTMLDivElement>(null); | |
| const [isPlaying, setIsPlaying] = useState(false); | |
| const [currentTime, setCurrentTime] = useState(0); | |
| const [duration, setDuration] = useState(0); | |
| const [isMuted, setIsMuted] = useState(false); | |
| const [hoverTime, setHoverTime] = useState<number | null>(null); | |
| const [hoverX, setHoverX] = useState<number>(0); | |
| // Group attributions by chunk | |
| const chunkAttributions = useMemo(() => { | |
| const chunks: Map<number, ChunkAttribution> = new Map(); | |
| for (const attr of attributions) { | |
| const meta = attr.metadata; | |
| if (!meta || meta.chunkIndex === undefined) continue; | |
| const chunkIndex = meta.chunkIndex; | |
| const startTime = meta.startTime || 0; | |
| // For first 10 seconds, only show exact matches (skip style matches) | |
| const isFirstChunk = startTime < 1; | |
| if (isFirstChunk && meta.matchType !== "exact") { | |
| continue; // Skip style matches in first chunk | |
| } | |
| if (!chunks.has(chunkIndex)) { | |
| chunks.set(chunkIndex, { | |
| chunkIndex, | |
| startTime, | |
| endTime: meta.endTime || 0, | |
| matches: [], | |
| }); | |
| } | |
| const chunk = chunks.get(chunkIndex)!; | |
| chunk.matches.push({ | |
| score: attr.score, | |
| title: meta.matchedTitle || "Unknown", | |
| artist: meta.matchedArtist || "Unknown", | |
| stemType: meta.matchedStemType, | |
| matchedStartTime: meta.matchedStartTime, | |
| matchedEndTime: meta.matchedEndTime, | |
| matchType: meta.matchType, | |
| }); | |
| } | |
| // Sort matches by score within each chunk | |
| for (const chunk of chunks.values()) { | |
| chunk.matches.sort((a, b) => b.score - a.score); | |
| } | |
| return Array.from(chunks.values()).sort((a, b) => a.startTime - b.startTime); | |
| }, [attributions]); | |
| // Get matches for current hover position | |
| const hoveredChunk = useMemo(() => { | |
| if (hoverTime === null) return null; | |
| return chunkAttributions.find( | |
| c => hoverTime >= c.startTime && hoverTime < c.endTime | |
| ); | |
| }, [hoverTime, chunkAttributions]); | |
| useEffect(() => { | |
| const audio = audioRef.current; | |
| if (!audio) return; | |
| const handleTimeUpdate = () => setCurrentTime(audio.currentTime); | |
| const handleDurationChange = () => setDuration(audio.duration); | |
| const handleEnded = () => setIsPlaying(false); | |
| audio.addEventListener("timeupdate", handleTimeUpdate); | |
| audio.addEventListener("durationchange", handleDurationChange); | |
| audio.addEventListener("ended", handleEnded); | |
| return () => { | |
| audio.removeEventListener("timeupdate", handleTimeUpdate); | |
| audio.removeEventListener("durationchange", handleDurationChange); | |
| audio.removeEventListener("ended", handleEnded); | |
| }; | |
| }, []); | |
| const togglePlay = () => { | |
| const audio = audioRef.current; | |
| if (!audio) return; | |
| if (isPlaying) { | |
| audio.pause(); | |
| } else { | |
| audio.play(); | |
| } | |
| setIsPlaying(!isPlaying); | |
| }; | |
| const toggleMute = () => { | |
| const audio = audioRef.current; | |
| if (!audio) return; | |
| audio.muted = !isMuted; | |
| setIsMuted(!isMuted); | |
| }; | |
| const handleSeek = (value: number[]) => { | |
| const audio = audioRef.current; | |
| if (!audio || !value[0]) return; | |
| audio.currentTime = value[0]; | |
| setCurrentTime(value[0]); | |
| }; | |
| const handleWaveformHover = (e: React.MouseEvent<HTMLDivElement>) => { | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const percentage = x / rect.width; | |
| const time = percentage * duration; | |
| setHoverTime(time); | |
| setHoverX(x); | |
| }; | |
| const handleWaveformLeave = () => { | |
| setHoverTime(null); | |
| }; | |
| const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => { | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const percentage = x / rect.width; | |
| const time = percentage * duration; | |
| handleSeek([time]); | |
| }; | |
| const formatTime = (seconds: number) => { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins}:${secs.toString().padStart(2, "0")}`; | |
| }; | |
| // Get color based on match type: green for exact, orange for style | |
| const getChunkColor = (chunk: ChunkAttribution) => { | |
| const topMatch = chunk.matches[0]; | |
| if (!topMatch) return "bg-gray-500/20"; | |
| // Check if any match is exact | |
| const hasExact = chunk.matches.some(m => m.matchType === "exact"); | |
| if (hasExact) { | |
| // Green shades for exact matches | |
| if (topMatch.score > 0.9) return "bg-green-500/70"; | |
| if (topMatch.score > 0.8) return "bg-green-500/50"; | |
| return "bg-green-500/40"; | |
| } else { | |
| // Orange shades for style matches | |
| if (topMatch.score > 0.9) return "bg-orange-500/60"; | |
| if (topMatch.score > 0.8) return "bg-orange-500/50"; | |
| return "bg-orange-500/40"; | |
| } | |
| }; | |
| return ( | |
| <Card className="relative"> | |
| <CardContent className="p-4 overflow-visible"> | |
| <div className="flex items-center gap-4 mb-3"> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| onClick={togglePlay} | |
| className="shrink-0" | |
| > | |
| {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} | |
| </Button> | |
| <div className="flex-1 min-w-0"> | |
| <h4 className="font-medium truncate">{title}</h4> | |
| {stemType && ( | |
| <Badge variant="secondary" className="text-xs"> | |
| {stemType} | |
| </Badge> | |
| )} | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={toggleMute} | |
| className="shrink-0" | |
| > | |
| {isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />} | |
| </Button> | |
| </div> | |
| {/* Waveform */} | |
| <div className="relative"> | |
| <div | |
| ref={waveformRef} | |
| className="relative h-16 bg-secondary/30 rounded-lg cursor-pointer overflow-hidden" | |
| onMouseMove={handleWaveformHover} | |
| onMouseLeave={handleWaveformLeave} | |
| onClick={handleWaveformClick} | |
| > | |
| {/* Chunk regions with match intensity coloring */} | |
| {duration > 0 && chunkAttributions.map((chunk) => { | |
| const left = (chunk.startTime / duration) * 100; | |
| const width = ((chunk.endTime - chunk.startTime) / duration) * 100; | |
| return ( | |
| <div | |
| key={chunk.chunkIndex} | |
| className={`absolute top-0 h-full ${getChunkColor(chunk)} border-r border-background/20`} | |
| style={{ left: `${left}%`, width: `${width}%` }} | |
| /> | |
| ); | |
| })} | |
| {/* Playhead */} | |
| {duration > 0 && ( | |
| <div | |
| className="absolute top-0 h-full w-0.5 bg-primary z-10" | |
| style={{ left: `${(currentTime / duration) * 100}%` }} | |
| /> | |
| )} | |
| {/* Hover indicator */} | |
| {hoverTime !== null && duration > 0 && ( | |
| <div | |
| className="absolute top-0 h-full w-0.5 bg-foreground/50 z-10" | |
| style={{ left: `${(hoverTime / duration) * 100}%` }} | |
| /> | |
| )} | |
| {/* Waveform placeholder bars */} | |
| <div className="absolute inset-0 flex items-center justify-around px-1"> | |
| {Array.from({ length: 60 }).map((_, i) => ( | |
| <div | |
| key={i} | |
| className="w-1 bg-foreground/20 rounded-full" | |
| style={{ | |
| height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 20}%`, | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Time display */} | |
| <div className="flex justify-between text-xs text-muted-foreground mt-2"> | |
| <span>{formatTime(currentTime)}</span> | |
| <span>{formatTime(duration)}</span> | |
| </div> | |
| {/* Matches panel - shown on hover */} | |
| <div className={`mt-3 rounded-lg border bg-muted/50 transition-all duration-200 overflow-hidden ${hoveredChunk ? 'max-h-40 p-3' : 'max-h-0 p-0 border-transparent'}`}> | |
| {hoveredChunk && ( | |
| <> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-xs font-medium"> | |
| Matches for {formatTime(hoveredChunk.startTime)} - {formatTime(hoveredChunk.endTime)} | |
| </span> | |
| <Badge variant="outline" className="text-[10px]"> | |
| {hoveredChunk.matches.length} matches | |
| </Badge> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| {hoveredChunk.matches.slice(0, 5).map((match, i) => ( | |
| <div key={i} className="flex items-center gap-1.5 bg-background rounded-md px-2 py-1 border"> | |
| <Badge | |
| variant={match.score > 0.7 ? "destructive" : "secondary"} | |
| className="text-[10px] px-1" | |
| > | |
| {(match.score * 100).toFixed(0)}% | |
| </Badge> | |
| <span className="text-xs truncate max-w-[120px]">{match.title}</span> | |
| </div> | |
| ))} | |
| {hoveredChunk.matches.length === 0 && ( | |
| <span className="text-xs text-muted-foreground">No matching tracks found</span> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {/* Hidden audio element */} | |
| <audio ref={audioRef} src={src} preload="metadata" /> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |