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(null); const waveformRef = useRef(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(null); const [hoverX, setHoverX] = useState(0); // Group attributions by chunk const chunkAttributions = useMemo(() => { const chunks: Map = 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) => { 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) => { 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 (

{title}

{stemType && ( {stemType} )}
{/* Waveform */}
{/* 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 (
); })} {/* Playhead */} {duration > 0 && (
)} {/* Hover indicator */} {hoverTime !== null && duration > 0 && (
)} {/* Waveform placeholder bars */}
{Array.from({ length: 60 }).map((_, i) => (
))}
{/* Time display */}
{formatTime(currentTime)} {formatTime(duration)}
{/* Matches panel - shown on hover */}
{hoveredChunk && ( <>
Matches for {formatTime(hoveredChunk.startTime)} - {formatTime(hoveredChunk.endTime)} {hoveredChunk.matches.length} matches
{hoveredChunk.matches.slice(0, 5).map((match, i) => (
0.7 ? "destructive" : "secondary"} className="text-[10px] px-1" > {(match.score * 100).toFixed(0)}% {match.title}
))} {hoveredChunk.matches.length === 0 && ( No matching tracks found )}
)}
{/* Hidden audio element */}