532 lines
25 KiB
TypeScript
532 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useAudioState, AudioFeatures } from "@/lib/audio-state-context";
|
|
import { cn } from "@/utils/cn";
|
|
import { useMemo, useState, useEffect, useRef, memo } from "react";
|
|
import { X, Maximize2, Minimize2 } from "lucide-react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
ResponsiveContainer,
|
|
RadarChart,
|
|
PolarGrid,
|
|
PolarAngleAxis,
|
|
Radar,
|
|
} from "recharts";
|
|
|
|
// Extended feature interface for all enhanced vibe data
|
|
interface ExtendedFeatures extends AudioFeatures {
|
|
danceabilityMl?: number | null;
|
|
}
|
|
|
|
// Feature configurations for radar chart
|
|
const RADAR_FEATURES = [
|
|
{ key: "energy", label: "Energy", min: 0, max: 1 },
|
|
{ key: "valence", label: "Mood", min: 0, max: 1 },
|
|
{ key: "arousal", label: "Arousal", min: 0, max: 1 },
|
|
{ key: "danceability", label: "Dance", min: 0, max: 1 },
|
|
{ key: "bpm", label: "Tempo", min: 60, max: 200 },
|
|
{ key: "moodHappy", label: "Happy", min: 0, max: 1 },
|
|
{ key: "moodSad", label: "Sad", min: 0, max: 1 },
|
|
{ key: "moodRelaxed", label: "Relaxed", min: 0, max: 1 },
|
|
{ key: "moodAggressive", label: "Aggressive", min: 0, max: 1 },
|
|
{ key: "moodParty", label: "Party", min: 0, max: 1 },
|
|
{ key: "moodAcoustic", label: "Acoustic", min: 0, max: 1 },
|
|
{ key: "moodElectronic", label: "Electronic", min: 0, max: 1 },
|
|
];
|
|
|
|
const ML_MOODS = [
|
|
{ key: "moodHappy", label: "Happy", color: "#ecb200" },
|
|
{ key: "moodSad", label: "Sad", color: "#5c8dd6" },
|
|
{ key: "moodRelaxed", label: "Relaxed", color: "#1db954" },
|
|
{ key: "moodAggressive", label: "Aggressive", color: "#e35656" },
|
|
{ key: "moodParty", label: "Party", color: "#e056a0" },
|
|
{ key: "moodAcoustic", label: "Acoustic", color: "#d4a656" },
|
|
{ key: "moodElectronic", label: "Electronic", color: "#a056e0" },
|
|
];
|
|
|
|
interface EnhancedVibeOverlayProps {
|
|
className?: string;
|
|
currentTrackFeatures?: ExtendedFeatures | null;
|
|
variant?: "floating" | "inline";
|
|
onClose?: () => void;
|
|
}
|
|
|
|
// Helper to normalize values
|
|
function normalizeValue(
|
|
value: number | null | undefined,
|
|
min: number,
|
|
max: number
|
|
): number {
|
|
if (value == null) return 0;
|
|
return Math.max(0, Math.min(1, (value - min) / (max - min)));
|
|
}
|
|
|
|
// Helper to get feature value safely
|
|
function getFeatureValue(
|
|
features: ExtendedFeatures | null | undefined,
|
|
key: string
|
|
): number | null {
|
|
if (!features) return null;
|
|
const value = (features as Record<string, unknown>)[key];
|
|
if (typeof value === "number") return value;
|
|
return null;
|
|
}
|
|
|
|
// Audio waveform visualization component - slower, smoother animation
|
|
const AudioWaveform = memo(function AudioWaveform({
|
|
energy,
|
|
bpm,
|
|
}: {
|
|
energy: number;
|
|
bpm: number;
|
|
}) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const animationRef = useRef<number | null>(null);
|
|
const timeRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const resize = () => {
|
|
canvas.width = canvas.offsetWidth * 2;
|
|
canvas.height = canvas.offsetHeight * 2;
|
|
ctx.scale(2, 2);
|
|
};
|
|
resize();
|
|
|
|
const animate = () => {
|
|
const width = canvas.offsetWidth;
|
|
const height = canvas.offsetHeight;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Calculate wave parameters based on audio features - SLOWER
|
|
const baseAmplitude = height * 0.35 * Math.max(0.3, energy);
|
|
const frequency = (bpm / 120) * 0.015; // Reduced frequency
|
|
const speed = (bpm / 120) * 0.015; // Slower speed (was 0.05)
|
|
|
|
// Draw multiple layered waves with #ecb200 accent
|
|
for (let layer = 0; layer < 3; layer++) {
|
|
const layerOffset = layer * 0.4;
|
|
const layerAmplitude = baseAmplitude * (1 - layer * 0.2);
|
|
const alpha = 0.5 - layer * 0.12;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, height / 2);
|
|
|
|
for (let x = 0; x <= width; x += 2) {
|
|
const y =
|
|
height / 2 +
|
|
Math.sin(
|
|
(x * frequency + timeRef.current * speed + layerOffset) *
|
|
Math.PI
|
|
) *
|
|
layerAmplitude +
|
|
Math.sin(
|
|
(x * frequency * 1.5 + timeRef.current * speed * 0.8) *
|
|
Math.PI
|
|
) *
|
|
(layerAmplitude * 0.25);
|
|
ctx.lineTo(x, y);
|
|
}
|
|
|
|
ctx.strokeStyle = "#ecb200";
|
|
ctx.globalAlpha = alpha;
|
|
ctx.lineWidth = 2 - layer * 0.4;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw glow effect
|
|
ctx.globalAlpha = 0.15;
|
|
ctx.filter = "blur(10px)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, height / 2);
|
|
for (let x = 0; x <= width; x += 2) {
|
|
const y =
|
|
height / 2 +
|
|
Math.sin((x * frequency + timeRef.current * speed) * Math.PI) *
|
|
baseAmplitude;
|
|
ctx.lineTo(x, y);
|
|
}
|
|
ctx.strokeStyle = "#ecb200";
|
|
ctx.lineWidth = 8;
|
|
ctx.stroke();
|
|
ctx.filter = "none";
|
|
ctx.globalAlpha = 1;
|
|
|
|
timeRef.current += 1;
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
};
|
|
|
|
animate();
|
|
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, [energy, bpm]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-14"
|
|
style={{ opacity: 0.7 }}
|
|
/>
|
|
);
|
|
});
|
|
|
|
export function EnhancedVibeOverlay({
|
|
className,
|
|
currentTrackFeatures,
|
|
variant = "floating",
|
|
onClose,
|
|
}: EnhancedVibeOverlayProps) {
|
|
const { vibeMode, vibeSourceFeatures } = useAudioState();
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
const [viewMode, setViewMode] = useState<"full" | "minimal">("full");
|
|
|
|
// Use vibeSourceFeatures as the source
|
|
const sourceFeatures = vibeSourceFeatures as ExtendedFeatures | null;
|
|
// Current features should be from the actual current track, NOT fallback to source
|
|
const currentFeatures = currentTrackFeatures as ExtendedFeatures | null;
|
|
// For display, use currentFeatures if available, otherwise show source (for first track)
|
|
const displayFeatures = currentFeatures || sourceFeatures;
|
|
|
|
// Prepare radar chart data
|
|
const radarData = useMemo(() => {
|
|
return RADAR_FEATURES.map((feature) => {
|
|
const sourceVal = getFeatureValue(sourceFeatures, feature.key);
|
|
// For current, use displayFeatures (falls back to source if current is null)
|
|
const currentVal = getFeatureValue(displayFeatures, feature.key);
|
|
|
|
return {
|
|
feature: feature.label,
|
|
source: normalizeValue(sourceVal, feature.min, feature.max) * 100,
|
|
current: normalizeValue(currentVal, feature.min, feature.max) * 100,
|
|
fullMark: 100,
|
|
};
|
|
});
|
|
}, [sourceFeatures, displayFeatures]);
|
|
|
|
// Calculate overall match score using cosine similarity (same as backend)
|
|
const matchScore = useMemo(() => {
|
|
if (!sourceFeatures || !currentFeatures) return null;
|
|
|
|
// Build feature vectors for cosine similarity
|
|
const sourceVector: number[] = [];
|
|
const currentVector: number[] = [];
|
|
|
|
RADAR_FEATURES.forEach((feature) => {
|
|
const sourceVal = getFeatureValue(sourceFeatures, feature.key);
|
|
const currentVal = getFeatureValue(currentFeatures, feature.key);
|
|
|
|
// Use 0.5 as default for missing values (neutral)
|
|
const sourceNorm = normalizeValue(sourceVal ?? (feature.min + feature.max) / 2, feature.min, feature.max);
|
|
const currentNorm = normalizeValue(currentVal ?? (feature.min + feature.max) / 2, feature.min, feature.max);
|
|
|
|
// Weight ML mood features higher (1.3x) like backend does
|
|
const weight = feature.key.startsWith("mood") ? 1.3 : 1.0;
|
|
sourceVector.push(sourceNorm * weight);
|
|
currentVector.push(currentNorm * weight);
|
|
});
|
|
|
|
// Calculate cosine similarity
|
|
let dotProduct = 0;
|
|
let magSource = 0;
|
|
let magCurrent = 0;
|
|
|
|
for (let i = 0; i < sourceVector.length; i++) {
|
|
dotProduct += sourceVector[i] * currentVector[i];
|
|
magSource += sourceVector[i] * sourceVector[i];
|
|
magCurrent += currentVector[i] * currentVector[i];
|
|
}
|
|
|
|
const magnitude = Math.sqrt(magSource) * Math.sqrt(magCurrent);
|
|
if (magnitude === 0) return null;
|
|
|
|
const similarity = dotProduct / magnitude;
|
|
return Math.round(similarity * 100);
|
|
}, [sourceFeatures, currentFeatures]);
|
|
|
|
// Get audio features for waveform - use current if available, fallback to source
|
|
const energy = getFeatureValue(currentFeatures, "energy") ?? getFeatureValue(sourceFeatures, "energy") ?? 0.5;
|
|
const bpm = getFeatureValue(currentFeatures, "bpm") ?? getFeatureValue(sourceFeatures, "bpm") ?? 120;
|
|
|
|
// Don't render if not in vibe mode
|
|
if (!vibeMode) return null;
|
|
|
|
const isFloating = variant === "floating";
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
className={cn(
|
|
// Spotify-inspired dark theme
|
|
"bg-[#121212] text-white relative overflow-hidden",
|
|
isFloating
|
|
? "fixed bottom-24 right-4 z-50 rounded-xl shadow-2xl w-[380px] border border-[#282828]"
|
|
: "rounded-xl w-full border border-[#282828]",
|
|
className
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className={cn(
|
|
"flex items-center justify-between px-4 py-3 border-b border-[#282828] cursor-pointer",
|
|
isFloating && "hover:bg-[#1a1a1a] transition-colors"
|
|
)}
|
|
onClick={() => isFloating && setIsExpanded(!isExpanded)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<motion.div
|
|
animate={{
|
|
boxShadow: [
|
|
"0 0 8px #ecb200",
|
|
"0 0 16px #ecb200",
|
|
"0 0 8px #ecb200",
|
|
],
|
|
}}
|
|
transition={{ duration: 2, repeat: Infinity }}
|
|
className="w-2.5 h-2.5 rounded-full bg-[#ecb200]"
|
|
/>
|
|
<span className="text-sm font-semibold tracking-wide">
|
|
Vibe Analysis
|
|
</span>
|
|
{matchScore !== null && (
|
|
<span
|
|
className={cn(
|
|
"text-xs font-bold px-2.5 py-1 rounded-full",
|
|
matchScore >= 80
|
|
? "bg-[#1db954]/20 text-[#1db954]"
|
|
: matchScore >= 60
|
|
? "bg-[#ecb200]/20 text-[#ecb200]"
|
|
: "bg-[#e35656]/20 text-[#e35656]"
|
|
)}
|
|
>
|
|
{matchScore}% Match
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setViewMode(viewMode === "full" ? "minimal" : "full");
|
|
}}
|
|
className="p-2 rounded-full hover:bg-[#282828] transition-colors"
|
|
title={viewMode === "full" ? "Minimal view" : "Full view"}
|
|
>
|
|
{viewMode === "full" ? (
|
|
<Minimize2 className="w-4 h-4 text-[#b3b3b3]" />
|
|
) : (
|
|
<Maximize2 className="w-4 h-4 text-[#b3b3b3]" />
|
|
)}
|
|
</button>
|
|
|
|
{isFloating && onClose && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}}
|
|
className="p-2 hover:bg-[#282828] rounded-full transition-colors"
|
|
>
|
|
<X className="w-4 h-4 text-[#b3b3b3]" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<AnimatePresence mode="wait">
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{/* Waveform visualization */}
|
|
<div className="px-4 pt-3 pb-1">
|
|
<AudioWaveform energy={energy} bpm={bpm} />
|
|
</div>
|
|
|
|
{viewMode === "full" ? (
|
|
<div className="p-4 space-y-4">
|
|
{/* Radar Chart */}
|
|
<div className="h-[260px] w-full bg-[#181818] rounded-lg p-2">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<RadarChart
|
|
data={radarData}
|
|
margin={{ top: 20, right: 30, bottom: 20, left: 30 }}
|
|
>
|
|
<PolarGrid
|
|
stroke="#282828"
|
|
strokeDasharray="3 3"
|
|
/>
|
|
<PolarAngleAxis
|
|
dataKey="feature"
|
|
tick={{
|
|
fill: "#b3b3b3",
|
|
fontSize: 10,
|
|
fontWeight: 500,
|
|
}}
|
|
tickLine={false}
|
|
/>
|
|
{/* Source track (dashed yellow) */}
|
|
<Radar
|
|
name="Source"
|
|
dataKey="source"
|
|
stroke="#ecb200"
|
|
fill="#ecb200"
|
|
fillOpacity={0.1}
|
|
strokeWidth={2}
|
|
strokeDasharray="5 5"
|
|
/>
|
|
{/* Current track (solid white) */}
|
|
<Radar
|
|
name="Current"
|
|
dataKey="current"
|
|
stroke="#ffffff"
|
|
fill="#ffffff"
|
|
fillOpacity={0.15}
|
|
strokeWidth={2}
|
|
/>
|
|
</RadarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Feature cards */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="bg-[#181818] rounded-lg p-3">
|
|
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-1">
|
|
Analysis Mode
|
|
</div>
|
|
<div className="text-sm font-semibold capitalize text-white">
|
|
{sourceFeatures?.analysisMode || "Standard"}
|
|
</div>
|
|
</div>
|
|
<div className="bg-[#181818] rounded-lg p-3">
|
|
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-1">
|
|
Tempo
|
|
</div>
|
|
<div className="text-sm font-semibold text-white">
|
|
{bpm ? `${Math.round(bpm)} BPM` : "N/A"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ML Mood Spectrum - shows current track's moods */}
|
|
<div className="bg-[#181818] rounded-lg p-4">
|
|
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-3">
|
|
Mood Spectrum {!currentFeatures && "(Source Track)"}
|
|
</div>
|
|
<div className="space-y-3">
|
|
{ML_MOODS.map((mood) => {
|
|
const value = getFeatureValue(
|
|
displayFeatures,
|
|
mood.key
|
|
);
|
|
// Show the bar even if value is 0, only hide if null/undefined
|
|
const percentage = value != null ? Math.round(value * 100) : 0;
|
|
const hasValue = value != null;
|
|
|
|
return (
|
|
<div key={mood.key} className="flex items-center gap-3">
|
|
<div
|
|
className="w-2 h-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: mood.color }}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-center mb-1.5">
|
|
<span className="text-xs text-[#b3b3b3]">
|
|
{mood.label}
|
|
</span>
|
|
<span className="text-xs font-medium tabular-nums text-white">
|
|
{hasValue ? `${percentage}%` : "—"}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-[#282828] rounded-full h-1 overflow-hidden">
|
|
<motion.div
|
|
initial={{ width: 0 }}
|
|
animate={{
|
|
width: hasValue ? `${Math.max(percentage, 2)}%` : "0%",
|
|
}}
|
|
transition={{
|
|
duration: 0.6,
|
|
ease: "easeOut",
|
|
}}
|
|
className="h-full rounded-full"
|
|
style={{ backgroundColor: mood.color }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Minimal view - just radar */
|
|
<div className="p-4">
|
|
<div className="h-[180px] w-full bg-[#181818] rounded-lg p-2">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<RadarChart data={radarData}>
|
|
<PolarGrid stroke="#282828" />
|
|
<PolarAngleAxis
|
|
dataKey="feature"
|
|
tick={{ fill: "#b3b3b3", fontSize: 9 }}
|
|
tickLine={false}
|
|
/>
|
|
<Radar
|
|
name="Source"
|
|
dataKey="source"
|
|
stroke="#ecb200"
|
|
fill="#ecb200"
|
|
fillOpacity={0.1}
|
|
strokeWidth={2}
|
|
strokeDasharray="5 5"
|
|
/>
|
|
<Radar
|
|
name="Current"
|
|
dataKey="current"
|
|
stroke="#ffffff"
|
|
fill="#ffffff"
|
|
fillOpacity={0.15}
|
|
strokeWidth={2}
|
|
/>
|
|
</RadarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="flex items-center justify-center gap-6 py-3 border-t border-[#282828]">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-0.5 bg-[#ecb200]" style={{ borderStyle: "dashed" }} />
|
|
<span className="text-[10px] text-[#b3b3b3]">Source</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-2 rounded-sm bg-white/30" />
|
|
<span className="text-[10px] text-[#b3b3b3]">Current</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|