"use client";
import { useAudioState, AudioFeatures } from "@/lib/audio-state-context";
import { cn } from "@/utils/cn";
import { useMemo, useState } from "react";
import {
X,
AudioWaveform,
Music,
Zap,
Heart,
Footprints,
Gauge,
Smile,
Frown,
Coffee,
Flame,
PartyPopper,
Guitar,
Radio,
} from "lucide-react";
interface VibeOverlayProps {
className?: string;
currentTrackFeatures?: AudioFeatures | null;
variant?: "floating" | "inline";
onClose?: () => void;
}
// Extended features for detailed analysis
interface ExtendedFeatures extends AudioFeatures {
arousal?: number | null;
instrumentalness?: number | null;
acousticness?: number | null;
// All 7 ML mood predictions
moodHappy?: number | null;
moodSad?: number | null;
moodRelaxed?: number | null;
moodAggressive?: number | null;
moodParty?: number | null;
moodAcoustic?: number | null;
moodElectronic?: number | null;
analysisMode?: string | null;
}
// Feature configuration with icons and descriptions
const FEATURE_CONFIG = [
{
key: "energy",
label: "Energy",
icon: Zap,
min: 0,
max: 1,
description: "Intensity and power",
lowLabel: "Calm",
highLabel: "Intense",
unit: null as string | null,
},
{
key: "valence",
label: "Mood",
icon: Heart,
min: 0,
max: 1,
description: "Emotional positivity",
lowLabel: "Melancholic",
highLabel: "Happy",
unit: null as string | null,
},
{
key: "danceability",
label: "Groove",
icon: Footprints,
min: 0,
max: 1,
description: "Rhythm & movement",
lowLabel: "Freeform",
highLabel: "Danceable",
unit: null as string | null,
},
{
key: "bpm",
label: "Tempo",
icon: Gauge,
min: 60,
max: 180,
description: "Beats per minute",
lowLabel: "Slow",
highLabel: "Fast",
unit: "BPM" as string | null,
},
{
key: "arousal",
label: "Arousal",
icon: AudioWaveform,
min: 0,
max: 1,
description: "Excitement level",
lowLabel: "Peaceful",
highLabel: "Energetic",
unit: null as string | null,
},
];
// ML Mood predictions (Enhanced mode only)
const ML_MOOD_CONFIG = [
{ key: "moodHappy", label: "Happy", icon: Smile, color: "text-yellow-400" },
{ key: "moodSad", label: "Sad", icon: Frown, color: "text-blue-400" },
{
key: "moodRelaxed",
label: "Relaxed",
icon: Coffee,
color: "text-green-400",
},
{
key: "moodAggressive",
label: "Aggressive",
icon: Flame,
color: "text-red-400",
},
{
key: "moodParty",
label: "Party",
icon: PartyPopper,
color: "text-pink-400",
},
{
key: "moodAcoustic",
label: "Acoustic",
icon: Guitar,
color: "text-amber-400",
},
{
key: "moodElectronic",
label: "Electronic",
icon: Radio,
color: "text-purple-400",
},
];
function normalizeValue(
value: number | null | undefined,
min: number,
max: number
): number {
if (value === null || value === undefined) return 0;
return Math.max(0, Math.min(1, (value - min) / (max - min)));
}
function getMatchColor(diff: number): string {
if (diff < 0.15) return "text-green-400";
if (diff < 0.3) return "text-brand";
return "text-red-400";
}
function getMatchBgColor(diff: number): string {
if (diff < 0.15) return "bg-green-500/20";
if (diff < 0.3) return "bg-brand/20";
return "bg-red-500/20";
}
export function VibeOverlay({
className,
currentTrackFeatures,
variant = "floating",
onClose,
}: VibeOverlayProps) {
const { vibeMode, vibeSourceFeatures } = useAudioState();
const [isExpanded, setIsExpanded] = useState(true);
// Calculate match scores for each feature
const featureComparisons = useMemo(() => {
if (!vibeSourceFeatures || !currentTrackFeatures) return null;
return FEATURE_CONFIG.map((feature) => {
const sourceVal = (vibeSourceFeatures as ExtendedFeatures)?.[
feature.key as keyof ExtendedFeatures
];
const currentVal = (currentTrackFeatures as ExtendedFeatures)?.[
feature.key as keyof ExtendedFeatures
];
const sourceNorm = normalizeValue(
sourceVal as number,
feature.min,
feature.max
);
const currentNorm = normalizeValue(
currentVal as number,
feature.min,
feature.max
);
const diff = Math.abs(sourceNorm - currentNorm);
const match = Math.round((1 - diff) * 100);
return {
...feature,
sourceValue: sourceVal,
currentValue: currentVal,
sourceNorm,
currentNorm,
diff,
match,
hasData: sourceVal != null && currentVal != null,
};
}).filter((f) => f.hasData);
}, [vibeSourceFeatures, currentTrackFeatures]);
// Overall match score
const overallMatch = useMemo(() => {
if (!featureComparisons || featureComparisons.length === 0) return null;
const totalMatch = featureComparisons.reduce(
(sum, f) => sum + f.match,
0
);
return Math.round(totalMatch / featureComparisons.length);
}, [featureComparisons]);
// Don't render if not in vibe mode
if (!vibeMode) return null;
const isFloating = variant === "floating";
return (
{/* Header */}
isFloating && setIsExpanded(!isExpanded)}
>
{overallMatch !== null && (
= 80
? "text-green-400"
: overallMatch >= 60
? "text-brand"
: "text-red-400"
)}
>
{overallMatch}%
)}
{isFloating && onClose && (
{
e.stopPropagation();
onClose();
}}
className="p-1 hover:bg-white/10 rounded-full transition-colors"
>
)}
{/* Content */}
{isExpanded && (
{/* What is this? */}
Comparing current track to your vibe source.
{(vibeSourceFeatures as ExtendedFeatures)
?.analysisMode === "enhanced"
? " Using ML mood predictions for accurate matching."
: " Using audio signal analysis for matching."}
{/* Feature Bars */}
{featureComparisons?.map((feature) => {
const Icon = feature.icon;
return (
{feature.label}
{feature.match}%
{/* Comparison Bar */}
{/* Source marker (yellow dashed) */}
{/* Current value bar */}
{/* Current marker */}
{/* Labels */}
{feature.lowLabel}
{feature.unit &&
feature.currentValue && (
{Math.round(
feature.currentValue as number
)}{" "}
{feature.unit}
)}
{feature.highLabel}
);
})}
{/* ML Moods (Enhanced mode only) */}
{(vibeSourceFeatures as ExtendedFeatures)?.analysisMode ===
"enhanced" && (
ML Mood Analysis
{ML_MOOD_CONFIG.map((mood) => {
const sourceVal = (
vibeSourceFeatures as ExtendedFeatures
)?.[mood.key as keyof ExtendedFeatures] as
| number
| null;
const currentVal = (
currentTrackFeatures as ExtendedFeatures
)?.[mood.key as keyof ExtendedFeatures] as
| number
| null;
const hasData =
sourceVal != null && currentVal != null;
const diff = hasData
? Math.abs(sourceVal - currentVal)
: 0;
const match = hasData
? Math.round((1 - diff) * 100)
: null;
const Icon = mood.icon;
if (!hasData) return null;
return (
= 80
? "bg-green-500/10"
: match !== null &&
match >= 60
? "bg-white/5"
: "bg-red-500/10"
)}
title={`Source: ${Math.round(
sourceVal * 100
)}% | Current: ${Math.round(
currentVal * 100
)}%`}
>
{mood.label}
{match !== null && (
= 80
? "text-green-400"
: match >= 60
? "text-gray-300"
: "text-red-400"
)}
>
{match}%
)}
);
})}
)}
{/* Legend */}
{/* Match Explanation */}
{overallMatch !== null && (
= 80
? "bg-green-500/10 text-green-400"
: overallMatch >= 60
? "bg-brand/10 text-brand"
: "bg-red-500/10 text-red-400"
)}
>
{overallMatch >= 80 &&
"Excellent match - very similar vibe"}
{overallMatch >= 60 &&
overallMatch < 80 &&
"Good match - similar energy"}
{overallMatch < 60 &&
"Different vibe - exploring variety"}
)}
)}
);
}
// Compact version for mobile overlay player - shows as album art replacement
export function VibeComparisonArt({
currentTrackFeatures,
className,
}: {
currentTrackFeatures?: AudioFeatures | null;
className?: string;
}) {
const { vibeMode, vibeSourceFeatures } = useAudioState();
// Calculate feature comparisons
const comparisons = useMemo(() => {
if (!vibeSourceFeatures || !currentTrackFeatures) return null;
return FEATURE_CONFIG.slice(0, 4)
.map((feature) => {
const sourceVal = (vibeSourceFeatures as ExtendedFeatures)?.[
feature.key as keyof ExtendedFeatures
];
const currentVal = (currentTrackFeatures as ExtendedFeatures)?.[
feature.key as keyof ExtendedFeatures
];
const sourceNorm = normalizeValue(
sourceVal as number,
feature.min,
feature.max
);
const currentNorm = normalizeValue(
currentVal as number,
feature.min,
feature.max
);
const diff = Math.abs(sourceNorm - currentNorm);
return {
...feature,
sourceNorm,
currentNorm,
diff,
match: Math.round((1 - diff) * 100),
hasData: sourceVal != null && currentVal != null,
};
})
.filter((f) => f.hasData);
}, [vibeSourceFeatures, currentTrackFeatures]);
const overallMatch = useMemo(() => {
if (!comparisons || comparisons.length === 0) return null;
const totalMatch = comparisons.reduce((sum, f) => sum + f.match, 0);
return Math.round(totalMatch / comparisons.length);
}, [comparisons]);
if (!vibeMode || !comparisons) return null;
// Radar chart dimensions
const size = 280;
const center = size / 2;
const maxRadius = 110;
const getPolygonPoints = (values: number[]) => {
const angleStep = (2 * Math.PI) / values.length;
return values
.map((value, i) => {
const angle = angleStep * i - Math.PI / 2;
const radius = value * maxRadius;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
return `${x},${y}`;
})
.join(" ");
};
const sourceValues = comparisons.map((c) => c.sourceNorm);
const currentValues = comparisons.map((c) => c.currentNorm);
return (
{/* Animated background glow */}
{/* Radar Chart */}
{/* Background circles */}
{[0.25, 0.5, 0.75, 1].map((scale) => (
))}
{/* Axis lines */}
{comparisons.map((_, i) => {
const angleStep = (2 * Math.PI) / comparisons.length;
const angle = angleStep * i - Math.PI / 2;
const x = center + maxRadius * Math.cos(angle);
const y = center + maxRadius * Math.sin(angle);
return (
);
})}
{/* Source polygon (yellow, dashed) */}
{/* Current polygon (white/gradient, solid) */}
{/* Gradient definition */}
{/* Feature labels */}
{comparisons.map((feature, i) => {
const angleStep = (2 * Math.PI) / comparisons.length;
const angle = angleStep * i - Math.PI / 2;
const labelRadius = maxRadius + 25;
const x = center + labelRadius * Math.cos(angle);
const y = center + labelRadius * Math.sin(angle);
const Icon = feature.icon;
return (
{/* Icon background */}
= 70
? "rgba(74, 222, 128, 0.5)"
: "rgba(255,255,255,0.2)"
}
strokeWidth="1"
/>
{/* Feature label */}
{feature.label}
{/* Match percentage */}
= 70
? "fill-green-400"
: feature.match >= 50
? "fill-yellow-400"
: "fill-red-400"
)}
>
{feature.match}%
);
})}
{/* Center match score */}
= 70
? "#4ade80"
: overallMatch && overallMatch >= 50
? "#facc15"
: "#f87171"
}
strokeWidth="2"
/>
= 70
? "fill-green-400"
: overallMatch && overallMatch >= 50
? "fill-yellow-400"
: "fill-red-400"
)}
>
{overallMatch}%
Match
{/* Legend */}
);
}