Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { useAudioPlayback } from "@/lib/audio-playback-context";
|
||||
import { useAudioControls } from "@/lib/audio-controls-context";
|
||||
import { api } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize2,
|
||||
Music as MusicIcon,
|
||||
Shuffle,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Loader2,
|
||||
AudioWaveform,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { cn, isLocalUrl } from "@/utils/cn";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
|
||||
/**
|
||||
* FullPlayer - UI-only component for desktop bottom player
|
||||
* Does NOT manage audio element - that's handled by AudioElement component
|
||||
*/
|
||||
export function FullPlayer() {
|
||||
// Use split contexts to avoid re-rendering on every currentTime update
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
volume,
|
||||
isMuted,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
playerMode,
|
||||
vibeMode,
|
||||
vibeSourceFeatures,
|
||||
queue,
|
||||
currentIndex,
|
||||
} = useAudioState();
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
currentTime,
|
||||
duration: playbackDuration,
|
||||
canSeek,
|
||||
downloadProgress,
|
||||
} = useAudioPlayback();
|
||||
|
||||
const {
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
setPlayerMode,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setUpcoming,
|
||||
startVibeMode,
|
||||
stopVibeMode,
|
||||
} = useAudioControls();
|
||||
|
||||
const [isVibeLoading, setIsVibeLoading] = useState(false);
|
||||
const [isVibePanelExpanded, setIsVibePanelExpanded] = useState(false);
|
||||
|
||||
// Get current track's audio features for vibe comparison
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
// Handle Vibe Mode toggle - finds tracks that sound like the current track
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
// If vibe mode is on, turn it off
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, start vibe mode
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks("vibe", currentTrack.id, 50);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
// Get the source track's features from the API response
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
bpm: sf?.bpm,
|
||||
energy: sf?.energy,
|
||||
valence: sf?.valence,
|
||||
arousal: sf?.arousal,
|
||||
danceability: sf?.danceability,
|
||||
keyScale: sf?.keyScale,
|
||||
instrumentalness: sf?.instrumentalness,
|
||||
analysisMode: sf?.analysisMode,
|
||||
// ML Mood predictions
|
||||
moodHappy: sf?.moodHappy,
|
||||
moodSad: sf?.moodSad,
|
||||
moodRelaxed: sf?.moodRelaxed,
|
||||
moodAggressive: sf?.moodAggressive,
|
||||
moodParty: sf?.moodParty,
|
||||
moodAcoustic: sf?.moodAcoustic,
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
// Start vibe mode with the queue IDs (include current track)
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
|
||||
// Add vibe tracks as upcoming (after current song finishes)
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued up next`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-[#ecb200]" />,
|
||||
});
|
||||
} else {
|
||||
toast.error("Couldn't find matching tracks in your library");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start vibe match:", error);
|
||||
toast.error("Failed to match vibe");
|
||||
} finally {
|
||||
setIsVibeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const duration = (() => {
|
||||
// Prefer canonical durations for long-form media to avoid stale/misreported playbackDuration.
|
||||
if (playbackType === "podcast" && currentPodcast?.duration) {
|
||||
return currentPodcast.duration;
|
||||
}
|
||||
if (playbackType === "audiobook" && currentAudiobook?.duration) {
|
||||
return currentAudiobook.duration;
|
||||
}
|
||||
return (
|
||||
playbackDuration ||
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0
|
||||
);
|
||||
})();
|
||||
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
// For audiobooks/podcasts, show saved progress even before playback starts
|
||||
// This provides immediate visual feedback of where the user left off
|
||||
const displayTime = (() => {
|
||||
// If we're actively playing or have seeked, use the live currentTime
|
||||
if (currentTime > 0) return currentTime;
|
||||
|
||||
// Otherwise, show saved progress for audiobooks/podcasts
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.currentTime;
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
|
||||
return currentTime;
|
||||
})();
|
||||
|
||||
const progress = duration > 0 ? Math.min(100, Math.max(0, (displayTime / duration) * 100)) : 0;
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Don't allow seeking if canSeek is false (uncached podcast)
|
||||
if (!canSeek) {
|
||||
console.log("[FullPlayer] Seeking disabled - podcast not cached");
|
||||
return;
|
||||
}
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const time = percentage * duration;
|
||||
seek(time);
|
||||
};
|
||||
|
||||
// Determine if seeking is allowed
|
||||
const seekEnabled = hasMedia && canSeek;
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseInt(e.target.value) / 100;
|
||||
setVolume(newVolume);
|
||||
};
|
||||
|
||||
// Get current media info
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
let coverUrl: string | null = null;
|
||||
let albumLink: string | null = null;
|
||||
let artistLink: string | null = null;
|
||||
let mediaLink: string | null = null;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
title = currentTrack.title;
|
||||
subtitle = currentTrack.artist?.name || "Unknown Artist";
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 100)
|
||||
: null;
|
||||
albumLink = currentTrack.album?.id ? `/album/${currentTrack.album.id}` : null;
|
||||
artistLink = currentTrack.artist?.id ? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}` : null;
|
||||
mediaLink = albumLink;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
subtitle = currentAudiobook.author;
|
||||
coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 100)
|
||||
: null;
|
||||
mediaLink = `/audiobooks/${currentAudiobook.id}`;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
title = currentPodcast.title;
|
||||
subtitle = currentPodcast.podcastTitle;
|
||||
coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 100)
|
||||
: null;
|
||||
const podcastId = currentPodcast.id.split(":")[0];
|
||||
mediaLink = `/podcasts/${podcastId}`;
|
||||
} else {
|
||||
// Idle state - no media playing
|
||||
title = "Not Playing";
|
||||
subtitle = "Select something to play";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0">
|
||||
{/* Floating Vibe Overlay - shows when tab is clicked */}
|
||||
{vibeMode && isVibePanelExpanded && (
|
||||
<div className="absolute bottom-full right-4 mb-2 z-50">
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vibe Tab - shows when vibe mode is active */}
|
||||
{vibeMode && (
|
||||
<button
|
||||
onClick={() => setIsVibePanelExpanded(!isVibePanelExpanded)}
|
||||
className={cn(
|
||||
"absolute -top-8 right-4 z-10",
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-t-lg",
|
||||
"bg-[#181818] border border-b-0 border-white/10",
|
||||
"text-xs font-medium transition-colors",
|
||||
isVibePanelExpanded ? "text-brand" : "text-white/70 hover:text-brand"
|
||||
)}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
{isVibePanelExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-24 bg-black border-t border-white/[0.08]">
|
||||
{/* Subtle top glow */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
<div className="flex items-center h-full px-6 gap-6">
|
||||
{/* Artwork & Info */}
|
||||
<div className="flex items-center gap-4 w-80">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} className="relative w-14 h-14 flex-shrink-0 group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent rounded-full blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="relative w-14 h-14 flex-shrink-0">
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} className="block hover:underline">
|
||||
<h4 className="text-white font-semibold truncate text-sm">{title}</h4>
|
||||
</Link>
|
||||
) : (
|
||||
<h4 className="text-white font-semibold truncate text-sm">{title}</h4>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link href={artistLink} className="block hover:underline">
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
</Link>
|
||||
) : mediaLink ? (
|
||||
<Link href={mediaLink} className="block hover:underline">
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Shuffle */}
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
isShuffle
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 30s */}
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia ? "text-gray-400 hover:text-white" : "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={previous}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={isBuffering ? undefined : isPlaying ? pause : resume}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200 relative group",
|
||||
hasMedia && !isBuffering
|
||||
? "bg-white text-black hover:scale-110 shadow-lg shadow-white/20 hover:shadow-white/30"
|
||||
: isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia || isBuffering}
|
||||
title={isBuffering ? "Buffering..." : isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{hasMedia && !isBuffering && (
|
||||
<div className="absolute inset-0 rounded-full bg-white blur-md opacity-0 group-hover:opacity-50 transition-opacity duration-200" />
|
||||
)}
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin relative z-10" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5 relative z-10" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5 relative z-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 30s */}
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia ? "text-gray-400 hover:text-white" : "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
repeatMode !== "off"
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
title={
|
||||
repeatMode === "off"
|
||||
? "Repeat: Off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat: All (loop queue)"
|
||||
: "Repeat: One (play current track twice)"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-4 h-4" />
|
||||
) : (
|
||||
<Repeat className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Vibe Mode Toggle */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
!hasMedia || playbackType !== "track"
|
||||
? "text-gray-600"
|
||||
: vibeMode
|
||||
? "text-[#ecb200] hover:text-[#d4a000]"
|
||||
: "text-gray-400 hover:text-[#ecb200]"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track" || isVibeLoading}
|
||||
title={vibeMode ? "Turn off vibe mode" : "Match this vibe - find similar sounding tracks"}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<span className={cn(
|
||||
"text-xs text-right font-medium tabular-nums",
|
||||
hasMedia ? "text-gray-400" : "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}>
|
||||
{formatTime(displayTime)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 h-1 bg-white/[0.15] rounded-full relative",
|
||||
seekEnabled ? "cursor-pointer group" : "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleSeek : undefined}
|
||||
title={
|
||||
!hasMedia
|
||||
? undefined
|
||||
: !canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached"
|
||||
: "Click to seek"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
seekEnabled ? "bg-white group-hover:bg-white" : hasMedia ? "bg-white/50" : "bg-gray-600"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
{seekEnabled && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg shadow-white/50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs font-medium tabular-nums",
|
||||
hasMedia ? "text-gray-400" : "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}>
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume & Expand */}
|
||||
<div className="flex items-center gap-3 w-52 justify-end">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="w-5 h-5" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-full h-1 bg-white/[0.15] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:shadow-white/30 [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Info */}
|
||||
<KeyboardShortcutsTooltip />
|
||||
|
||||
<button
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className={cn(
|
||||
"transition-all duration-200 border-l border-white/[0.08] pl-3",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white hover:scale-110"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { useAudioPlayback } from "@/lib/audio-playback-context";
|
||||
import { useAudioControls } from "@/lib/audio-controls-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { howlerEngine } from "@/lib/howler-engine";
|
||||
import { audioSeekEmitter } from "@/lib/audio-seek-emitter";
|
||||
import { useEffect, useLayoutEffect, useRef, memo, useCallback, useMemo } from "react";
|
||||
|
||||
function podcastDebugEnabled(): boolean {
|
||||
try {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage?.getItem("lidifyPodcastDebug") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function podcastDebugLog(message: string, data?: Record<string, unknown>) {
|
||||
if (!podcastDebugEnabled()) return;
|
||||
console.log(`[PodcastDebug] ${message}`, data || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* HowlerAudioElement - Unified audio playback using Howler.js
|
||||
*
|
||||
* Handles: web playback, progress saving for audiobooks/podcasts
|
||||
* Browser media controls are handled separately by useMediaSession hook
|
||||
*/
|
||||
export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
// State context
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
volume,
|
||||
isMuted,
|
||||
repeatMode,
|
||||
setCurrentAudiobook,
|
||||
setCurrentTrack,
|
||||
setCurrentPodcast,
|
||||
setPlaybackType,
|
||||
queue,
|
||||
} = useAudioState();
|
||||
|
||||
// Playback context
|
||||
const {
|
||||
isPlaying,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setIsPlaying,
|
||||
isBuffering,
|
||||
setIsBuffering,
|
||||
setTargetSeekPosition,
|
||||
canSeek,
|
||||
setCanSeek,
|
||||
setDownloadProgress,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Controls context
|
||||
const { pause, next } = useAudioControls();
|
||||
|
||||
// Refs
|
||||
const lastTrackIdRef = useRef<string | null>(null);
|
||||
const lastPlayingStateRef = useRef<boolean>(isPlaying);
|
||||
const progressSaveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastProgressSaveRef = useRef<number>(0);
|
||||
const isUserInitiatedRef = useRef<boolean>(false);
|
||||
const isLoadingRef = useRef<boolean>(false);
|
||||
const loadIdRef = useRef<number>(0);
|
||||
const cachePollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const seekCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const cacheStatusPollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const seekReloadListenerRef = useRef<(() => void) | null>(null);
|
||||
const seekReloadInProgressRef = useRef<boolean>(false);
|
||||
// Track when a seek operation is in progress to prevent load effect from interfering
|
||||
const isSeekingRef = useRef<boolean>(false);
|
||||
// Track load listeners for cleanup to prevent memory leaks
|
||||
const loadListenerRef = useRef<(() => void) | null>(null);
|
||||
const loadErrorListenerRef = useRef<(() => void) | null>(null);
|
||||
const cachePollingLoadListenerRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Reset duration when nothing is playing
|
||||
useEffect(() => {
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) {
|
||||
setDuration(0);
|
||||
}
|
||||
}, [currentTrack, currentAudiobook, currentPodcast, setDuration]);
|
||||
|
||||
// Subscribe to Howler events
|
||||
useEffect(() => {
|
||||
const handleTimeUpdate = (data: { time: number }) => {
|
||||
setCurrentTime(data.time);
|
||||
};
|
||||
|
||||
const handleLoad = (data: { duration: number }) => {
|
||||
const fallbackDuration =
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0;
|
||||
setDuration(data.duration || fallbackDuration);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
// Save final progress for audiobooks/podcasts
|
||||
if (playbackType === "audiobook" && currentAudiobook) {
|
||||
saveAudiobookProgress(true);
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
savePodcastProgress(true);
|
||||
}
|
||||
|
||||
// Handle track advancement based on repeat mode
|
||||
if (playbackType === "track") {
|
||||
if (repeatMode === "one") {
|
||||
howlerEngine.seek(0);
|
||||
howlerEngine.play();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (data: { error: any }) => {
|
||||
console.error("[HowlerAudioElement] Playback error:", data.error);
|
||||
setIsPlaying(false);
|
||||
isUserInitiatedRef.current = false;
|
||||
|
||||
if (playbackType === "track") {
|
||||
if (queue.length > 1) {
|
||||
console.log("[HowlerAudioElement] Track failed, trying next in queue");
|
||||
lastTrackIdRef.current = null;
|
||||
isLoadingRef.current = false;
|
||||
next();
|
||||
} else {
|
||||
console.log("[HowlerAudioElement] Track failed, no more in queue - clearing");
|
||||
lastTrackIdRef.current = null;
|
||||
isLoadingRef.current = false;
|
||||
setCurrentTrack(null);
|
||||
setPlaybackType(null);
|
||||
}
|
||||
} else if (playbackType === "audiobook") {
|
||||
setCurrentAudiobook(null);
|
||||
setPlaybackType(null);
|
||||
} else if (playbackType === "podcast") {
|
||||
setCurrentPodcast(null);
|
||||
setPlaybackType(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!isUserInitiatedRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
isUserInitiatedRef.current = false;
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (isLoadingRef.current) return;
|
||||
if (seekReloadInProgressRef.current) return;
|
||||
|
||||
if (!isUserInitiatedRef.current) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
isUserInitiatedRef.current = false;
|
||||
};
|
||||
|
||||
howlerEngine.on("timeupdate", handleTimeUpdate);
|
||||
howlerEngine.on("load", handleLoad);
|
||||
howlerEngine.on("end", handleEnd);
|
||||
howlerEngine.on("loaderror", handleError);
|
||||
howlerEngine.on("playerror", handleError);
|
||||
howlerEngine.on("play", handlePlay);
|
||||
howlerEngine.on("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
howlerEngine.off("timeupdate", handleTimeUpdate);
|
||||
howlerEngine.off("load", handleLoad);
|
||||
howlerEngine.off("end", handleEnd);
|
||||
howlerEngine.off("loaderror", handleError);
|
||||
howlerEngine.off("playerror", handleError);
|
||||
howlerEngine.off("play", handlePlay);
|
||||
howlerEngine.off("pause", handlePause);
|
||||
};
|
||||
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTime, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
|
||||
|
||||
// Save audiobook progress
|
||||
const saveAudiobookProgress = useCallback(
|
||||
async (isFinished: boolean = false) => {
|
||||
if (!currentAudiobook) return;
|
||||
|
||||
const currentTime = howlerEngine.getCurrentTime();
|
||||
const duration =
|
||||
howlerEngine.getDuration() || currentAudiobook.duration;
|
||||
|
||||
if (currentTime === lastProgressSaveRef.current && !isFinished)
|
||||
return;
|
||||
lastProgressSaveRef.current = currentTime;
|
||||
|
||||
try {
|
||||
await api.updateAudiobookProgress(
|
||||
currentAudiobook.id,
|
||||
isFinished ? duration : currentTime,
|
||||
duration,
|
||||
isFinished
|
||||
);
|
||||
|
||||
setCurrentAudiobook({
|
||||
...currentAudiobook,
|
||||
progress: {
|
||||
currentTime: isFinished ? duration : currentTime,
|
||||
progress:
|
||||
duration > 0
|
||||
? ((isFinished ? duration : currentTime) /
|
||||
duration) *
|
||||
100
|
||||
: 0,
|
||||
isFinished,
|
||||
lastPlayedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save audiobook progress:",
|
||||
err
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentAudiobook, setCurrentAudiobook]
|
||||
);
|
||||
|
||||
// Save podcast progress
|
||||
const savePodcastProgress = useCallback(
|
||||
async (isFinished: boolean = false) => {
|
||||
if (!currentPodcast) return;
|
||||
|
||||
if (isBuffering && !isFinished) return;
|
||||
|
||||
const currentTime = howlerEngine.getCurrentTime();
|
||||
const duration =
|
||||
howlerEngine.getDuration() || currentPodcast.duration;
|
||||
|
||||
if (currentTime <= 0 && !isFinished) return;
|
||||
|
||||
try {
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
await api.updatePodcastProgress(
|
||||
podcastId,
|
||||
episodeId,
|
||||
isFinished ? duration : currentTime,
|
||||
duration,
|
||||
isFinished
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save podcast progress:",
|
||||
err
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentPodcast, isBuffering]
|
||||
);
|
||||
|
||||
// Load and play audio when track changes
|
||||
useEffect(() => {
|
||||
const currentMediaId =
|
||||
currentTrack?.id ||
|
||||
currentAudiobook?.id ||
|
||||
currentPodcast?.id ||
|
||||
null;
|
||||
|
||||
if (!currentMediaId) {
|
||||
howlerEngine.stop();
|
||||
lastTrackIdRef.current = null;
|
||||
isLoadingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMediaId === lastTrackIdRef.current) {
|
||||
// Skip if a seek operation is in progress - the seek handler will manage playback
|
||||
if (isSeekingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPlay = lastPlayingStateRef.current || isPlaying;
|
||||
const isCurrentlyPlaying = howlerEngine.isPlaying();
|
||||
|
||||
if (shouldPlay && !isCurrentlyPlaying) {
|
||||
howlerEngine.seek(0);
|
||||
howlerEngine.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
isLoadingRef.current = true;
|
||||
lastTrackIdRef.current = currentMediaId;
|
||||
loadIdRef.current += 1;
|
||||
const thisLoadId = loadIdRef.current;
|
||||
|
||||
let streamUrl: string | null = null;
|
||||
let startTime = 0;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
streamUrl = api.getStreamUrl(currentTrack.id);
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
streamUrl = api.getAudiobookStreamUrl(currentAudiobook.id);
|
||||
startTime = currentAudiobook.progress?.currentTime || 0;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
streamUrl = api.getPodcastEpisodeStreamUrl(podcastId, episodeId);
|
||||
startTime = currentPodcast.progress?.currentTime || 0;
|
||||
podcastDebugLog("load podcast", {
|
||||
currentPodcastId: currentPodcast.id,
|
||||
podcastId,
|
||||
episodeId,
|
||||
title: currentPodcast.title,
|
||||
podcastTitle: currentPodcast.podcastTitle,
|
||||
startTime,
|
||||
loadId: thisLoadId,
|
||||
});
|
||||
}
|
||||
|
||||
if (streamUrl) {
|
||||
const wasHowlerPlayingBeforeLoad = howlerEngine.isPlaying();
|
||||
|
||||
const fallbackDuration =
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0;
|
||||
setDuration(fallbackDuration);
|
||||
|
||||
let format = "mp3";
|
||||
const filePath = currentTrack?.filePath || "";
|
||||
if (filePath) {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase();
|
||||
if (ext === "flac") format = "flac";
|
||||
else if (ext === "m4a" || ext === "aac") format = "mp4";
|
||||
else if (ext === "ogg" || ext === "opus") format = "webm";
|
||||
else if (ext === "wav") format = "wav";
|
||||
}
|
||||
|
||||
howlerEngine.load(streamUrl, false, format);
|
||||
if (playbackType === "podcast" && currentPodcast) {
|
||||
podcastDebugLog("howlerEngine.load()", {
|
||||
url: streamUrl,
|
||||
format,
|
||||
loadId: thisLoadId,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up any previous load listeners before adding new ones
|
||||
if (loadListenerRef.current) {
|
||||
howlerEngine.off("load", loadListenerRef.current);
|
||||
loadListenerRef.current = null;
|
||||
}
|
||||
if (loadErrorListenerRef.current) {
|
||||
howlerEngine.off("loaderror", loadErrorListenerRef.current);
|
||||
loadErrorListenerRef.current = null;
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
if (loadIdRef.current !== thisLoadId) return;
|
||||
|
||||
isLoadingRef.current = false;
|
||||
|
||||
if (startTime > 0) {
|
||||
howlerEngine.seek(startTime);
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast) {
|
||||
podcastDebugLog("loaded", {
|
||||
loadId: thisLoadId,
|
||||
durationHowler: howlerEngine.getDuration(),
|
||||
howlerTime: howlerEngine.getCurrentTime(),
|
||||
actualTime: howlerEngine.getActualCurrentTime(),
|
||||
startTime,
|
||||
canSeek,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldAutoPlay = lastPlayingStateRef.current || wasHowlerPlayingBeforeLoad;
|
||||
|
||||
if (shouldAutoPlay) {
|
||||
howlerEngine.play();
|
||||
if (!lastPlayingStateRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up both listeners
|
||||
howlerEngine.off("load", handleLoaded);
|
||||
howlerEngine.off("loaderror", handleLoadError);
|
||||
loadListenerRef.current = null;
|
||||
loadErrorListenerRef.current = null;
|
||||
};
|
||||
|
||||
const handleLoadError = () => {
|
||||
isLoadingRef.current = false;
|
||||
howlerEngine.off("load", handleLoaded);
|
||||
howlerEngine.off("loaderror", handleLoadError);
|
||||
loadListenerRef.current = null;
|
||||
loadErrorListenerRef.current = null;
|
||||
};
|
||||
|
||||
// Store refs for cleanup on unmount
|
||||
loadListenerRef.current = handleLoaded;
|
||||
loadErrorListenerRef.current = handleLoadError;
|
||||
|
||||
howlerEngine.on("load", handleLoaded);
|
||||
howlerEngine.on("loaderror", handleLoadError);
|
||||
} else {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
}, [currentTrack, currentAudiobook, currentPodcast, playbackType, setDuration]);
|
||||
|
||||
// Check podcast cache status and control canSeek
|
||||
useEffect(() => {
|
||||
if (playbackType !== "podcast") {
|
||||
setCanSeek(true);
|
||||
setDownloadProgress(null);
|
||||
if (cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPodcast) {
|
||||
setCanSeek(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
|
||||
const checkCacheStatus = async () => {
|
||||
try {
|
||||
const status = await api.getPodcastEpisodeCacheStatus(
|
||||
podcastId,
|
||||
episodeId
|
||||
);
|
||||
|
||||
if (status.cached) {
|
||||
setCanSeek(true);
|
||||
setDownloadProgress(null);
|
||||
if (cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
} else {
|
||||
setCanSeek(false);
|
||||
setDownloadProgress(
|
||||
status.downloadProgress ??
|
||||
(status.downloading ? 0 : null)
|
||||
);
|
||||
}
|
||||
|
||||
return status.cached;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to check cache status:",
|
||||
err
|
||||
);
|
||||
setCanSeek(true);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
checkCacheStatus();
|
||||
|
||||
cacheStatusPollingRef.current = setInterval(async () => {
|
||||
const isCached = await checkCacheStatus();
|
||||
if (isCached && cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [currentPodcast, playbackType, setCanSeek, setDownloadProgress]);
|
||||
|
||||
// Keep lastPlayingStateRef always in sync
|
||||
useLayoutEffect(() => {
|
||||
lastPlayingStateRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Handle play/pause changes from UI
|
||||
useEffect(() => {
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
isUserInitiatedRef.current = true;
|
||||
|
||||
if (isPlaying) {
|
||||
howlerEngine.play();
|
||||
} else {
|
||||
howlerEngine.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Handle volume changes
|
||||
useEffect(() => {
|
||||
howlerEngine.setVolume(volume);
|
||||
}, [volume]);
|
||||
|
||||
// Handle mute changes
|
||||
useEffect(() => {
|
||||
howlerEngine.setMuted(isMuted);
|
||||
}, [isMuted]);
|
||||
|
||||
// Poll for podcast cache and reload when ready
|
||||
const startCachePolling = useCallback(
|
||||
(podcastId: string, episodeId: string, targetTime: number) => {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
}
|
||||
|
||||
let pollCount = 0;
|
||||
const maxPolls = 60;
|
||||
|
||||
cachePollingRef.current = setInterval(async () => {
|
||||
pollCount++;
|
||||
|
||||
try {
|
||||
const status = await api.getPodcastEpisodeCacheStatus(
|
||||
podcastId,
|
||||
episodeId
|
||||
);
|
||||
podcastDebugLog("cache poll", {
|
||||
podcastId,
|
||||
episodeId,
|
||||
pollCount,
|
||||
cached: status.cached,
|
||||
downloading: status.downloading,
|
||||
downloadProgress: status.downloadProgress,
|
||||
targetTime,
|
||||
});
|
||||
|
||||
if (status.cached) {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
cachePollingRef.current = null;
|
||||
}
|
||||
|
||||
podcastDebugLog("cache ready -> howlerEngine.reload()", {
|
||||
podcastId,
|
||||
episodeId,
|
||||
targetTime,
|
||||
});
|
||||
// Clean up any previous cache polling load listener
|
||||
if (cachePollingLoadListenerRef.current) {
|
||||
howlerEngine.off("load", cachePollingLoadListenerRef.current);
|
||||
cachePollingLoadListenerRef.current = null;
|
||||
}
|
||||
|
||||
howlerEngine.reload();
|
||||
|
||||
const onLoad = () => {
|
||||
howlerEngine.off("load", onLoad);
|
||||
cachePollingLoadListenerRef.current = null;
|
||||
|
||||
howlerEngine.seek(targetTime);
|
||||
setCurrentTime(targetTime);
|
||||
howlerEngine.play();
|
||||
podcastDebugLog("post-reload seek+play", {
|
||||
podcastId,
|
||||
episodeId,
|
||||
targetTime,
|
||||
howlerTime: howlerEngine.getCurrentTime(),
|
||||
actualTime: howlerEngine.getActualCurrentTime(),
|
||||
});
|
||||
|
||||
setIsBuffering(false);
|
||||
setTargetSeekPosition(null);
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
cachePollingLoadListenerRef.current = onLoad;
|
||||
howlerEngine.on("load", onLoad);
|
||||
} else if (pollCount >= maxPolls) {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
cachePollingRef.current = null;
|
||||
}
|
||||
|
||||
console.warn("[HowlerAudioElement] Cache polling timeout");
|
||||
setIsBuffering(false);
|
||||
setTargetSeekPosition(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HowlerAudioElement] Cache polling error:", error);
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[setCurrentTime, setIsBuffering, setTargetSeekPosition, setIsPlaying]
|
||||
);
|
||||
|
||||
// Handle seeking via event emitter
|
||||
useEffect(() => {
|
||||
const handleSeek = async (time: number) => {
|
||||
const wasPlayingAtSeekStart = howlerEngine.isPlaying();
|
||||
|
||||
setCurrentTime(time);
|
||||
|
||||
if (playbackType === "podcast" && currentPodcast) {
|
||||
if (seekCheckTimeoutRef.current) {
|
||||
clearTimeout(seekCheckTimeoutRef.current);
|
||||
}
|
||||
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
try {
|
||||
const status = await api.getPodcastEpisodeCacheStatus(
|
||||
podcastId,
|
||||
episodeId
|
||||
);
|
||||
|
||||
if (status.cached) {
|
||||
podcastDebugLog("seek: cached=true, using reload+seek pattern", {
|
||||
time,
|
||||
podcastId,
|
||||
episodeId,
|
||||
});
|
||||
|
||||
if (seekReloadListenerRef.current) {
|
||||
howlerEngine.off("load", seekReloadListenerRef.current);
|
||||
seekReloadListenerRef.current = null;
|
||||
}
|
||||
|
||||
seekReloadInProgressRef.current = true;
|
||||
|
||||
howlerEngine.reload();
|
||||
|
||||
const onLoad = () => {
|
||||
howlerEngine.off("load", onLoad);
|
||||
seekReloadListenerRef.current = null;
|
||||
seekReloadInProgressRef.current = false;
|
||||
|
||||
howlerEngine.seek(time);
|
||||
setCurrentTime(time);
|
||||
|
||||
if (wasPlayingAtSeekStart) {
|
||||
howlerEngine.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
seekReloadListenerRef.current = onLoad;
|
||||
howlerEngine.on("load", onLoad);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[HowlerAudioElement] Could not check cache status:", e);
|
||||
}
|
||||
|
||||
howlerEngine.seek(time);
|
||||
|
||||
seekCheckTimeoutRef.current = setTimeout(() => {
|
||||
try {
|
||||
const actualPos = howlerEngine.getActualCurrentTime();
|
||||
const seekFailed = time > 30 && actualPos < 30;
|
||||
podcastDebugLog("seek check", {
|
||||
time,
|
||||
actualPos,
|
||||
seekFailed,
|
||||
podcastId,
|
||||
episodeId,
|
||||
});
|
||||
|
||||
if (seekFailed) {
|
||||
howlerEngine.pause();
|
||||
setIsBuffering(true);
|
||||
setTargetSeekPosition(time);
|
||||
setIsPlaying(false);
|
||||
startCachePolling(podcastId, episodeId, time);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[HowlerAudioElement] Seek check error:", e);
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// For audiobooks and tracks, set seeking flag to prevent load effect interference
|
||||
isSeekingRef.current = true;
|
||||
howlerEngine.seek(time);
|
||||
|
||||
// Reset seeking flag after a short delay to allow seek to complete
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const unsubscribe = audioSeekEmitter.subscribe(handleSeek);
|
||||
return unsubscribe;
|
||||
}, [setCurrentTime, playbackType, currentPodcast, setIsBuffering, setTargetSeekPosition, setIsPlaying, startCachePolling]);
|
||||
|
||||
// Cleanup cache polling, seek timeout, and seek-reload listener on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
}
|
||||
if (seekCheckTimeoutRef.current) {
|
||||
clearTimeout(seekCheckTimeoutRef.current);
|
||||
}
|
||||
if (seekReloadListenerRef.current) {
|
||||
howlerEngine.off("load", seekReloadListenerRef.current);
|
||||
seekReloadListenerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Periodic progress saving for audiobooks and podcasts
|
||||
useEffect(() => {
|
||||
if (playbackType !== "audiobook" && playbackType !== "podcast") {
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
progressSaveIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
if (playbackType === "audiobook") {
|
||||
saveAudiobookProgress();
|
||||
} else if (playbackType === "podcast") {
|
||||
savePodcastProgress();
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
// Clear any existing interval before creating a new one
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
}
|
||||
progressSaveIntervalRef.current = setInterval(() => {
|
||||
if (playbackType === "audiobook") {
|
||||
saveAudiobookProgress();
|
||||
} else if (playbackType === "podcast") {
|
||||
savePodcastProgress();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
progressSaveIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [playbackType, isPlaying, saveAudiobookProgress, savePodcastProgress]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
howlerEngine.stop();
|
||||
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
}
|
||||
// Clean up all listener refs to prevent memory leaks
|
||||
if (loadListenerRef.current) {
|
||||
howlerEngine.off("load", loadListenerRef.current);
|
||||
loadListenerRef.current = null;
|
||||
}
|
||||
if (loadErrorListenerRef.current) {
|
||||
howlerEngine.off("loaderror", loadErrorListenerRef.current);
|
||||
loadErrorListenerRef.current = null;
|
||||
}
|
||||
if (cachePollingLoadListenerRef.current) {
|
||||
howlerEngine.off("load", cachePollingLoadListenerRef.current);
|
||||
cachePollingLoadListenerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function KeyboardShortcutsTooltip() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const shortcuts = [
|
||||
{ key: "Space", action: "Play / Pause" },
|
||||
{ key: "→", action: "Seek forward 10s" },
|
||||
{ key: "←", action: "Seek backward 10s" },
|
||||
{ key: "↑", action: "Volume up 10%" },
|
||||
{ key: "↓", action: "Volume down 10%" },
|
||||
{ key: "M", action: "Toggle mute" },
|
||||
{ key: "N", action: "Next track" },
|
||||
{ key: "P", action: "Previous track" },
|
||||
{ key: "S", action: "Toggle shuffle" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="p-1.5 rounded transition-colors text-gray-400 hover:text-white"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isVisible && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 bg-[#1a1a1a] border border-white/10 rounded-lg shadow-2xl shadow-black/50 p-4 z-50 backdrop-blur-xl">
|
||||
{/* Pointer arrow */}
|
||||
<div className="absolute -bottom-1 right-3 w-2 h-2 bg-[#1a1a1a] border-r border-b border-white/10 rotate-45" />
|
||||
|
||||
<h3 className="text-white font-bold text-sm mb-3 flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-gray-400">
|
||||
{shortcut.action}
|
||||
</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-white font-mono text-xs min-w-[40px] text-center">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<p className="text-[10px] text-gray-500 leading-relaxed">
|
||||
Shortcuts work anywhere except when typing in text fields.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||
import { useMediaSession } from "@/hooks/useMediaSession";
|
||||
|
||||
/**
|
||||
* Invisible component that registers keyboard shortcuts and Media Session API
|
||||
* Should be placed at the root level of the app
|
||||
*/
|
||||
export function MediaControlsHandler() {
|
||||
useKeyboardShortcuts();
|
||||
useMediaSession();
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
"use client";
|
||||
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Maximize2,
|
||||
Music as MusicIcon,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
MonitorUp,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Loader2,
|
||||
AudioWaveform,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
|
||||
export function MiniPlayer() {
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
currentTime,
|
||||
duration: playbackDuration,
|
||||
canSeek,
|
||||
downloadProgress,
|
||||
vibeMode,
|
||||
queue,
|
||||
currentIndex,
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
setPlayerMode,
|
||||
setUpcoming,
|
||||
startVibeMode,
|
||||
stopVibeMode,
|
||||
} = useAudio();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
const [isVibeLoading, setIsVibeLoading] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isVibePanelExpanded, setIsVibePanelExpanded] = useState(false);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const lastMediaIdRef = useRef<string | null>(null);
|
||||
|
||||
// Get current track's audio features for vibe comparison
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
// Reset dismissed/minimized state when a new track starts playing
|
||||
const currentMediaId =
|
||||
currentTrack?.id || currentAudiobook?.id || currentPodcast?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMediaId && currentMediaId !== lastMediaIdRef.current) {
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
setIsDismissed(false);
|
||||
setIsMinimized(false);
|
||||
}
|
||||
}, [currentMediaId]);
|
||||
|
||||
// Handle Vibe Match toggle - finds tracks that sound like the current track
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
// If vibe mode is on, turn it off
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, start vibe mode
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks(
|
||||
"vibe",
|
||||
currentTrack.id,
|
||||
50
|
||||
);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
// Get the source track's features from the API response
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
bpm: sf?.bpm,
|
||||
energy: sf?.energy,
|
||||
valence: sf?.valence,
|
||||
arousal: sf?.arousal,
|
||||
danceability: sf?.danceability,
|
||||
keyScale: sf?.keyScale,
|
||||
instrumentalness: sf?.instrumentalness,
|
||||
analysisMode: sf?.analysisMode,
|
||||
// ML Mood predictions
|
||||
moodHappy: sf?.moodHappy,
|
||||
moodSad: sf?.moodSad,
|
||||
moodRelaxed: sf?.moodRelaxed,
|
||||
moodAggressive: sf?.moodAggressive,
|
||||
moodParty: sf?.moodParty,
|
||||
moodAcoustic: sf?.moodAcoustic,
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
// Start vibe mode with the queue IDs (include current track)
|
||||
const queueIds = [
|
||||
currentTrack.id,
|
||||
...response.tracks.map((t: any) => t.id),
|
||||
];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
|
||||
// Add vibe tracks as upcoming (after current song finishes)
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued up next`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-brand" />,
|
||||
});
|
||||
} else {
|
||||
toast.error("Couldn't find matching tracks in your library");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start vibe match:", error);
|
||||
toast.error("Failed to match vibe");
|
||||
} finally {
|
||||
setIsVibeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
// Get current media info
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
let coverUrl: string | null = null;
|
||||
let mediaLink: string | null = null;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
title = currentTrack.title;
|
||||
subtitle = currentTrack.artist?.name || "Unknown Artist";
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 100)
|
||||
: null;
|
||||
mediaLink = currentTrack.album?.id
|
||||
? `/album/${currentTrack.album.id}`
|
||||
: null;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
subtitle = currentAudiobook.author;
|
||||
coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 100)
|
||||
: null;
|
||||
mediaLink = `/audiobooks/${currentAudiobook.id}`;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
title = currentPodcast.title;
|
||||
subtitle = currentPodcast.podcastTitle;
|
||||
coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 100)
|
||||
: null;
|
||||
const podcastId = currentPodcast.id.split(":")[0];
|
||||
mediaLink = `/podcasts/${podcastId}`;
|
||||
} else {
|
||||
title = "Not Playing";
|
||||
subtitle = "Select something to play";
|
||||
}
|
||||
|
||||
// Check if controls should be enabled (only for tracks)
|
||||
const canSkip = playbackType === "track";
|
||||
|
||||
// Calculate progress percentage
|
||||
const duration = (() => {
|
||||
if (playbackType === "podcast" && currentPodcast?.duration) {
|
||||
return currentPodcast.duration;
|
||||
}
|
||||
if (playbackType === "audiobook" && currentAudiobook?.duration) {
|
||||
return currentAudiobook.duration;
|
||||
}
|
||||
return (
|
||||
playbackDuration ||
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0
|
||||
);
|
||||
})();
|
||||
const progress =
|
||||
duration > 0
|
||||
? Math.min(100, Math.max(0, (currentTime / duration) * 100))
|
||||
: 0;
|
||||
|
||||
// Handle progress bar click
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const newTime = percentage * duration;
|
||||
seek(newTime);
|
||||
};
|
||||
|
||||
const seekEnabled = hasMedia && canSeek;
|
||||
|
||||
// ============================================
|
||||
// MOBILE/TABLET: Spotify-style compact player
|
||||
// ============================================
|
||||
if (isMobileOrTablet) {
|
||||
// Don't render if no media
|
||||
if (!hasMedia) return null;
|
||||
|
||||
// Handle swipe gestures:
|
||||
// - Swipe RIGHT: minimize to tab
|
||||
// - Swipe LEFT + playing: open overlay
|
||||
// - Swipe LEFT + not playing: dismiss completely
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return;
|
||||
const deltaX = e.touches[0].clientX - touchStartX.current;
|
||||
// Track both directions, cap at ±150px
|
||||
setSwipeOffset(Math.max(-150, Math.min(150, deltaX)));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchStartX.current === null) return;
|
||||
|
||||
// Swipe RIGHT (positive) → minimize to tab
|
||||
if (swipeOffset > 80) {
|
||||
setIsMinimized(true);
|
||||
}
|
||||
// Swipe LEFT (negative) → open overlay OR dismiss
|
||||
else if (swipeOffset < -80) {
|
||||
if (isPlaying) {
|
||||
// If playing, open full-screen overlay
|
||||
setPlayerMode("overlay");
|
||||
} else {
|
||||
// If not playing, dismiss completely
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset
|
||||
setSwipeOffset(0);
|
||||
touchStartX.current = null;
|
||||
};
|
||||
|
||||
// Completely dismissed - don't render anything
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimized tab - small pill on RIGHT to bring player back
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className="fixed right-0 z-50 bg-gradient-to-l from-[#f5c518] via-[#e6a700] to-[#a855f7] rounded-l-full pl-3 pr-2 py-2 shadow-lg flex items-center gap-2 transition-transform hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 16px)",
|
||||
}}
|
||||
title="Show player"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-black" />
|
||||
{coverUrl ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden ring-2 ring-black/20">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<MusicIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate opacity for swipe feedback
|
||||
const swipeOpacity = 1 - Math.abs(swipeOffset) / 200;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed left-2 right-2 z-50 rounded-xl overflow-hidden shadow-xl transition-transform"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
transform: `translateX(${swipeOffset}px)`,
|
||||
opacity: swipeOpacity,
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Gradient background - richer, more vibrant colors */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#1a1a2e] via-[#2d1847] to-[#1a1a2e]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#f5c518]/30 via-[#a855f7]/40 to-[#f5c518]/30" />
|
||||
{/* Edge glow effects */}
|
||||
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-[#f5c518] via-[#e6a700] to-[#f5c518]" />
|
||||
<div className="absolute inset-y-0 right-0 w-1 bg-gradient-to-b from-[#a855f7] via-[#7c3aed] to-[#a855f7]" />
|
||||
|
||||
{/* Progress bar at top */}
|
||||
<div className="relative h-[2px] bg-white/20 w-full">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#f5c518] via-[#e6a700] to-[#a855f7] transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player content */}
|
||||
<div
|
||||
className="relative flex items-center gap-2.5 px-3 py-2 cursor-pointer"
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
>
|
||||
{/* Album Art */}
|
||||
<div className="relative w-10 h-10 flex-shrink-0 rounded-md overflow-hidden bg-black/30 shadow-md">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-[13px] font-medium truncate leading-tight">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-gray-300/70 text-[11px] truncate leading-tight">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls - Vibe & Play/Pause */}
|
||||
<div
|
||||
className="flex items-center gap-1 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Vibe Button */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"w-9 h-9 flex items-center justify-center rounded-full transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-600"
|
||||
: vibeMode
|
||||
? "text-[#f5c518]"
|
||||
: "text-white/80 hover:text-[#f5c518]"
|
||||
)}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-[18px] h-[18px] animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-[18px] h-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isBuffering) {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-full flex items-center justify-center transition shadow-md",
|
||||
isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-white text-black hover:scale-105"
|
||||
)}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-[18px] h-[18px] animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-[18px] h-[18px]" />
|
||||
) : (
|
||||
<Play className="w-[18px] h-[18px] ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DESKTOP: Full-featured mini player
|
||||
// ============================================
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Collapsible Vibe Panel - slides up from player */}
|
||||
{vibeMode && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 right-0 bottom-full transition-all duration-300 ease-out overflow-hidden border-t border-white/[0.08]",
|
||||
isVibePanelExpanded ? "max-h-[500px]" : "max-h-0"
|
||||
)}
|
||||
>
|
||||
<div className="bg-[#121212]">
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="inline"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vibe Tab - shows when vibe mode is active */}
|
||||
{vibeMode && (
|
||||
<button
|
||||
onClick={() => setIsVibePanelExpanded(!isVibePanelExpanded)}
|
||||
className={cn(
|
||||
"absolute -top-8 left-1/2 -translate-x-1/2 z-10",
|
||||
"flex items-center gap-1.5 px-3 py-1 rounded-t-lg",
|
||||
"bg-[#121212] border border-b-0 border-white/[0.08]",
|
||||
"text-xs font-medium transition-colors",
|
||||
isVibePanelExpanded
|
||||
? "text-brand"
|
||||
: "text-white/70 hover:text-brand"
|
||||
)}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
{isVibePanelExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="bg-gradient-to-t from-[#0a0a0a] via-[#0f0f0f] to-[#0a0a0a] border-t border-white/[0.08] relative backdrop-blur-xl">
|
||||
{/* Subtle top glow */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 h-1 bg-white/[0.15] transition-all",
|
||||
seekEnabled
|
||||
? "cursor-pointer group hover:h-2"
|
||||
: "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleProgressClick : undefined}
|
||||
title={
|
||||
!hasMedia
|
||||
? undefined
|
||||
: !canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached"
|
||||
: "Click to seek"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
seekEnabled
|
||||
? "bg-white"
|
||||
: hasMedia
|
||||
? "bg-white/50"
|
||||
: "bg-gray-600"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
{seekEnabled && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg shadow-white/50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Content */}
|
||||
<div className="px-3 py-2.5 pt-3">
|
||||
{/* Artwork & Track Info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Artwork */}
|
||||
{mediaLink ? (
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="relative flex-shrink-0 group w-12 h-12"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent rounded-full blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="relative flex-shrink-0 w-12 h-12">
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{mediaLink ? (
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<p className="text-white font-semibold truncate text-sm">
|
||||
{title}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-white font-semibold truncate text-sm">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-400 truncate text-xs">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mode Switch Buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setPlayerMode("full")}
|
||||
className="text-gray-400 hover:text-white transition p-1"
|
||||
title="Show bottom player"
|
||||
>
|
||||
<MonitorUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className={cn(
|
||||
"transition p-1",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
{/* Shuffle */}
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? isShuffle
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={canSkip ? "Shuffle" : "Shuffle (music only)"}
|
||||
>
|
||||
<Shuffle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 30s */}
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
disabled={!hasMedia}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors relative",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={previous}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={
|
||||
canSkip ? "Previous" : "Previous (music only)"
|
||||
}
|
||||
>
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={
|
||||
isBuffering
|
||||
? undefined
|
||||
: isPlaying
|
||||
? pause
|
||||
: resume
|
||||
}
|
||||
disabled={!hasMedia || isBuffering}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center transition",
|
||||
hasMedia && !isBuffering
|
||||
? "bg-white text-black hover:scale-105"
|
||||
: isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={canSkip ? "Next" : "Next (music only)"}
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 30s */}
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
disabled={!hasMedia}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors relative",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? repeatMode !== "off"
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={
|
||||
canSkip
|
||||
? repeatMode === "off"
|
||||
? "Repeat: Off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat: All"
|
||||
: "Repeat: One"
|
||||
: "Repeat (music only)"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Repeat className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Vibe Mode Toggle */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!hasMedia || !canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
!hasMedia || !canSkip
|
||||
? "text-gray-600 cursor-not-allowed"
|
||||
: vibeMode
|
||||
? "text-brand hover:text-brand-hover"
|
||||
: "text-gray-400 hover:text-brand"
|
||||
)}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe - find similar sounding tracks"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<KeyboardShortcutsTooltip />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
ChevronDown,
|
||||
Music as MusicIcon,
|
||||
Shuffle,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
AudioWaveform,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { toast } from "sonner";
|
||||
import { VibeComparisonArt } from "./VibeOverlay";
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
|
||||
export function OverlayPlayer() {
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
canSeek,
|
||||
downloadProgress,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
vibeMode,
|
||||
queue,
|
||||
currentIndex,
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
returnToPreviousMode,
|
||||
seek,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setUpcoming,
|
||||
startVibeMode,
|
||||
stopVibeMode,
|
||||
duration: playbackDuration,
|
||||
} = useAudio();
|
||||
|
||||
// Get current track's audio features for vibe comparison
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
// Swipe state for track skipping
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isVibeLoading, setIsVibeLoading] = useState(false);
|
||||
|
||||
const duration = (() => {
|
||||
if (playbackType === "podcast" && currentPodcast?.duration) {
|
||||
return currentPodcast.duration;
|
||||
}
|
||||
if (playbackType === "audiobook" && currentAudiobook?.duration) {
|
||||
return currentAudiobook.duration;
|
||||
}
|
||||
return (
|
||||
playbackDuration ||
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0
|
||||
);
|
||||
})();
|
||||
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) return null;
|
||||
|
||||
const displayTime = (() => {
|
||||
if (currentTime > 0) return currentTime;
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.currentTime;
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
return currentTime;
|
||||
})();
|
||||
|
||||
const progress = duration > 0 ? Math.min(100, Math.max(0, (displayTime / duration) * 100)) : 0;
|
||||
const seekEnabled = canSeek;
|
||||
const canSkip = playbackType === "track";
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const x = clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const time = percentage * duration;
|
||||
seek(time);
|
||||
};
|
||||
|
||||
// Swipe handlers for track skipping
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return;
|
||||
const deltaX = e.touches[0].clientX - touchStartX.current;
|
||||
setSwipeOffset(Math.max(-100, Math.min(100, deltaX)));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchStartX.current === null) return;
|
||||
|
||||
if (canSkip) {
|
||||
if (swipeOffset > 60) {
|
||||
previous();
|
||||
} else if (swipeOffset < -60) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
setSwipeOffset(0);
|
||||
touchStartX.current = null;
|
||||
};
|
||||
|
||||
// Handle Vibe toggle
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks("vibe", currentTrack.id, 50);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
bpm: sf?.bpm,
|
||||
energy: sf?.energy,
|
||||
valence: sf?.valence,
|
||||
arousal: sf?.arousal,
|
||||
danceability: sf?.danceability,
|
||||
keyScale: sf?.keyScale,
|
||||
instrumentalness: sf?.instrumentalness,
|
||||
analysisMode: sf?.analysisMode,
|
||||
// ML Mood predictions
|
||||
moodHappy: sf?.moodHappy,
|
||||
moodSad: sf?.moodSad,
|
||||
moodRelaxed: sf?.moodRelaxed,
|
||||
moodAggressive: sf?.moodAggressive,
|
||||
moodParty: sf?.moodParty,
|
||||
moodAcoustic: sf?.moodAcoustic,
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-[#f5c518]" />,
|
||||
});
|
||||
} else {
|
||||
toast.error("Couldn't find matching tracks");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start vibe match:", error);
|
||||
toast.error("Failed to match vibe");
|
||||
} finally {
|
||||
setIsVibeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get current media info
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
let coverUrl: string | null = null;
|
||||
let albumLink: string | null = null;
|
||||
let artistLink: string | null = null;
|
||||
let mediaLink: string | null = null;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
title = currentTrack.title;
|
||||
subtitle = currentTrack.artist?.name || "Unknown Artist";
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 500)
|
||||
: null;
|
||||
albumLink = currentTrack.album?.id ? `/album/${currentTrack.album.id}` : null;
|
||||
artistLink = currentTrack.artist?.id ? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}` : null;
|
||||
mediaLink = albumLink;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
subtitle = currentAudiobook.author;
|
||||
coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 500)
|
||||
: null;
|
||||
mediaLink = `/audiobooks/${currentAudiobook.id}`;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
title = currentPodcast.title;
|
||||
subtitle = currentPodcast.podcastTitle;
|
||||
coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 500)
|
||||
: null;
|
||||
const podcastId = currentPodcast.id.split(":")[0];
|
||||
mediaLink = `/podcasts/${podcastId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-b from-[#1a1a2e] via-[#121218] to-[#000000] z-[9999] flex flex-col overflow-hidden"
|
||||
onTouchStart={isMobileOrTablet ? handleTouchStart : undefined}
|
||||
onTouchMove={isMobileOrTablet ? handleTouchMove : undefined}
|
||||
onTouchEnd={isMobileOrTablet ? handleTouchEnd : undefined}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ paddingTop: 'calc(12px + env(safe-area-inset-top))' }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
returnToPreviousMode();
|
||||
}}
|
||||
className="text-gray-400 hover:text-white transition-colors p-2 -ml-2 rounded-full hover:bg-white/10"
|
||||
title="Close"
|
||||
>
|
||||
<ChevronDown className="w-7 h-7" />
|
||||
</button>
|
||||
|
||||
{/* Now Playing indicator */}
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest font-medium">
|
||||
Now Playing
|
||||
</span>
|
||||
|
||||
<div className="w-11" /> {/* Spacer for centering */}
|
||||
</div>
|
||||
|
||||
{/* Main Content - Portrait vs Landscape */}
|
||||
<div className="flex-1 flex flex-col landscape:flex-row items-center justify-center px-6 pb-6 landscape:px-8 landscape:gap-8 overflow-hidden">
|
||||
|
||||
{/* Artwork Section */}
|
||||
<div
|
||||
className="w-full max-w-[280px] landscape:max-w-[240px] landscape:w-[240px] aspect-square flex-shrink-0 mb-6 landscape:mb-0 relative"
|
||||
style={{
|
||||
transform: `translateX(${swipeOffset * 0.5}px)`,
|
||||
opacity: 1 - Math.abs(swipeOffset) / 200
|
||||
}}
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 rounded-2xl blur-2xl opacity-50",
|
||||
vibeMode
|
||||
? "bg-gradient-to-br from-brand/30 via-transparent to-purple-500/30"
|
||||
: "bg-gradient-to-br from-[#f5c518]/20 via-transparent to-[#a855f7]/20"
|
||||
)} />
|
||||
|
||||
{/* Album art OR Vibe Comparison when in vibe mode */}
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-2xl overflow-hidden shadow-2xl">
|
||||
{vibeMode && currentTrackFeatures ? (
|
||||
<VibeComparisonArt currentTrackFeatures={currentTrackFeatures} />
|
||||
) : coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="280px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-24 h-24 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swipe hint indicators */}
|
||||
{canSkip && isMobileOrTablet && Math.abs(swipeOffset) > 20 && (
|
||||
<div className={cn(
|
||||
"absolute top-1/2 -translate-y-1/2 text-white/60",
|
||||
swipeOffset > 0 ? "-left-8" : "-right-8"
|
||||
)}>
|
||||
{swipeOffset > 0 ? (
|
||||
<SkipBack className="w-6 h-6" />
|
||||
) : (
|
||||
<SkipForward className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info & Controls Section */}
|
||||
<div className="w-full max-w-[320px] landscape:max-w-[280px] landscape:flex-1 flex flex-col">
|
||||
{/* Track Info */}
|
||||
<div className="text-center landscape:text-left mb-6">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<h1 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
</Link>
|
||||
) : (
|
||||
<h1 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link href={artistLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<p className="text-base text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-base text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-1 bg-white/20 rounded-full mb-2",
|
||||
seekEnabled ? "cursor-pointer" : "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleSeek : undefined}
|
||||
title={!canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%...`
|
||||
: "Downloading..."
|
||||
: "Tap to seek"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-150",
|
||||
seekEnabled
|
||||
? "bg-gradient-to-r from-[#f5c518] to-[#a855f7]"
|
||||
: "bg-white/40"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 font-medium tabular-nums">
|
||||
<span>{formatTime(displayTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-6 mb-6">
|
||||
<button
|
||||
onClick={previous}
|
||||
className={cn(
|
||||
"text-white/80 hover:text-white transition-all hover:scale-110",
|
||||
!canSkip && "opacity-30 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
disabled={!canSkip}
|
||||
title={canSkip ? "Previous" : "Skip only for music"}
|
||||
>
|
||||
<SkipBack className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={isPlaying ? pause : resume}
|
||||
className="w-16 h-16 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-all shadow-xl"
|
||||
title={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-7 h-7" />
|
||||
) : (
|
||||
<Play className="w-7 h-7 ml-1" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className={cn(
|
||||
"text-white/80 hover:text-white transition-all hover:scale-110",
|
||||
!canSkip && "opacity-30 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
disabled={!canSkip}
|
||||
title={canSkip ? "Next" : "Skip only for music"}
|
||||
>
|
||||
<SkipForward className="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
disabled={!canSkip}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-700 cursor-not-allowed"
|
||||
: isShuffle
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-white"
|
||||
)}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
disabled={!canSkip}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-700 cursor-not-allowed"
|
||||
: repeatMode !== "off"
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-white"
|
||||
)}
|
||||
title={repeatMode === "one" ? "Repeat One" : repeatMode === "all" ? "Repeat All" : "Repeat Off"}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-5 h-5" />
|
||||
) : (
|
||||
<Repeat className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-700 cursor-not-allowed"
|
||||
: vibeMode
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-[#f5c518]"
|
||||
)}
|
||||
title={vibeMode ? "Turn off vibe mode" : "Match this vibe"}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Safe area padding at bottom */}
|
||||
<div style={{ height: 'env(safe-area-inset-bottom)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { usePlayerMode } from "@/hooks/usePlayerMode";
|
||||
|
||||
export function PlayerModeWrapper({ children }: { children: ReactNode }) {
|
||||
// This component exists solely to call the usePlayerMode hook
|
||||
// which must be in a client component
|
||||
usePlayerMode();
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { MiniPlayer } from "./MiniPlayer";
|
||||
import { FullPlayer } from "./FullPlayer";
|
||||
import { OverlayPlayer } from "./OverlayPlayer";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* UniversalPlayer - Manages player UI rendering based on mode and device
|
||||
* NOTE: The AudioElement is rendered by ConditionalAudioProvider, NOT here
|
||||
* This component only handles the UI (MiniPlayer, FullPlayer, OverlayPlayer)
|
||||
*
|
||||
* Mobile/Tablet behavior:
|
||||
* - Defaults to overlay mode when new media starts
|
||||
* - If user closes overlay, shows mini player at bottom
|
||||
* - No full-width player on mobile
|
||||
*/
|
||||
export function UniversalPlayer() {
|
||||
const { playerMode, setPlayerMode, currentTrack, currentAudiobook, currentPodcast, isPlaying } =
|
||||
useAudio();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
const lastMediaIdRef = useRef<string | null>(null);
|
||||
const hasAutoSwitchedRef = useRef(false);
|
||||
|
||||
// Auto-switch to overlay mode on mobile/tablet when user starts playing new media
|
||||
useEffect(() => {
|
||||
if (!isMobileOrTablet) return;
|
||||
|
||||
const currentMediaId =
|
||||
currentTrack?.id ||
|
||||
currentAudiobook?.id ||
|
||||
currentPodcast?.id ||
|
||||
null;
|
||||
|
||||
// Only switch to overlay if:
|
||||
// 1. Media changed to a new track
|
||||
// 2. User is actively playing (not just page load with paused track)
|
||||
// 3. We haven't already auto-switched for this track
|
||||
const mediaChanged = currentMediaId && currentMediaId !== lastMediaIdRef.current;
|
||||
|
||||
if (mediaChanged && isPlaying && !hasAutoSwitchedRef.current) {
|
||||
setPlayerMode("overlay");
|
||||
hasAutoSwitchedRef.current = true;
|
||||
}
|
||||
|
||||
// Reset flag when media changes
|
||||
if (currentMediaId !== lastMediaIdRef.current) {
|
||||
hasAutoSwitchedRef.current = false;
|
||||
}
|
||||
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
}, [currentTrack?.id, currentAudiobook?.id, currentPodcast?.id, isPlaying, isMobileOrTablet, setPlayerMode]);
|
||||
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Conditional UI rendering based on mode and device */}
|
||||
{/* Note: AudioElement is rendered by ConditionalAudioProvider */}
|
||||
{/* Always show player UI (like Spotify), even when no media is playing */}
|
||||
{playerMode === "overlay" && hasMedia ? (
|
||||
<OverlayPlayer />
|
||||
) : isMobileOrTablet ? (
|
||||
/* On mobile/tablet: only mini player (no full player) */
|
||||
<MiniPlayer />
|
||||
) : (
|
||||
/* Desktop: always show full-width bottom player */
|
||||
<FullPlayer />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface AudioFeatures {
|
||||
bpm?: number | null;
|
||||
energy?: number | null;
|
||||
valence?: number | null;
|
||||
danceability?: number | null;
|
||||
keyScale?: string | null;
|
||||
}
|
||||
|
||||
interface VibeGraphProps {
|
||||
className?: string;
|
||||
currentTrackFeatures?: AudioFeatures | null;
|
||||
}
|
||||
|
||||
// Feature labels and their normalization ranges
|
||||
const FEATURES = [
|
||||
{ key: "energy", label: "Energy", min: 0, max: 1 },
|
||||
{ key: "valence", label: "Mood", min: 0, max: 1 },
|
||||
{ key: "danceability", label: "Dance", min: 0, max: 1 },
|
||||
{ key: "bpm", label: "BPM", min: 60, max: 200 },
|
||||
] as const;
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
export function VibeGraph({ className, currentTrackFeatures }: VibeGraphProps) {
|
||||
const { vibeMode, vibeSourceFeatures } = useAudioState();
|
||||
|
||||
// Calculate normalized values for both source and current track
|
||||
const { sourceValues, currentValues } = useMemo(() => {
|
||||
const source: number[] = [];
|
||||
const current: number[] = [];
|
||||
|
||||
FEATURES.forEach((feature) => {
|
||||
const sourceVal = vibeSourceFeatures?.[feature.key as keyof AudioFeatures];
|
||||
const currentVal = currentTrackFeatures?.[feature.key as keyof AudioFeatures];
|
||||
|
||||
source.push(normalizeValue(sourceVal as number, feature.min, feature.max));
|
||||
current.push(normalizeValue(currentVal as number, feature.min, feature.max));
|
||||
});
|
||||
|
||||
return { sourceValues: source, currentValues: current };
|
||||
}, [vibeSourceFeatures, currentTrackFeatures]);
|
||||
|
||||
// Don't render if not in vibe mode
|
||||
if (!vibeMode) return null;
|
||||
|
||||
// SVG dimensions
|
||||
const size = 80;
|
||||
const center = size / 2;
|
||||
const maxRadius = 32;
|
||||
|
||||
// Calculate polygon points for radar chart
|
||||
const getPolygonPoints = (values: number[]) => {
|
||||
const angleStep = (2 * Math.PI) / values.length;
|
||||
return values
|
||||
.map((value, i) => {
|
||||
const angle = angleStep * i - Math.PI / 2; // Start from top
|
||||
const radius = value * maxRadius;
|
||||
const x = center + radius * Math.cos(angle);
|
||||
const y = center + radius * Math.sin(angle);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Calculate label positions
|
||||
const getLabelPosition = (index: number) => {
|
||||
const angleStep = (2 * Math.PI) / FEATURES.length;
|
||||
const angle = angleStep * index - Math.PI / 2;
|
||||
const radius = maxRadius + 8;
|
||||
return {
|
||||
x: center + radius * Math.cos(angle),
|
||||
y: center + radius * Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
// Calculate match percentage
|
||||
const matchScore = useMemo(() => {
|
||||
if (!vibeSourceFeatures || !currentTrackFeatures) return null;
|
||||
|
||||
let totalDiff = 0;
|
||||
let count = 0;
|
||||
|
||||
FEATURES.forEach((feature, i) => {
|
||||
if (sourceValues[i] > 0 || currentValues[i] > 0) {
|
||||
totalDiff += Math.abs(sourceValues[i] - currentValues[i]);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
if (count === 0) return null;
|
||||
return Math.round((1 - totalDiff / count) * 100);
|
||||
}, [sourceValues, currentValues, vibeSourceFeatures, currentTrackFeatures]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative">
|
||||
<svg width={size} height={size} className="opacity-90">
|
||||
{/* Background circles */}
|
||||
{[0.25, 0.5, 0.75, 1].map((scale) => (
|
||||
<circle
|
||||
key={scale}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={maxRadius * scale}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Axis lines */}
|
||||
{FEATURES.map((_, i) => {
|
||||
const angleStep = (2 * Math.PI) / FEATURES.length;
|
||||
const angle = angleStep * i - Math.PI / 2;
|
||||
const x = center + maxRadius * Math.cos(angle);
|
||||
const y = center + maxRadius * Math.sin(angle);
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="rgba(255,255,255,0.15)"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Source track polygon (yellow, dashed) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(sourceValues)}
|
||||
fill="rgba(236, 178, 0, 0.15)"
|
||||
stroke="#ecb200"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3,2"
|
||||
/>
|
||||
|
||||
{/* Current track polygon (white, solid) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(currentValues)}
|
||||
fill="rgba(255, 255, 255, 0.1)"
|
||||
stroke="rgba(255, 255, 255, 0.8)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Feature labels */}
|
||||
{FEATURES.map((feature, i) => {
|
||||
const pos = getLabelPosition(i);
|
||||
return (
|
||||
<text
|
||||
key={feature.key}
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-gray-500 text-[6px] font-medium"
|
||||
>
|
||||
{feature.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Match score */}
|
||||
{matchScore !== null && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold tabular-nums",
|
||||
matchScore >= 80 ? "text-green-400" :
|
||||
matchScore >= 60 ? "text-[#ecb200]" :
|
||||
"text-gray-400"
|
||||
)}
|
||||
>
|
||||
{matchScore}%
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-500">match</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
"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 (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-black/90 backdrop-blur-xl border border-white/10 text-white",
|
||||
isFloating &&
|
||||
"fixed bottom-24 right-4 z-50 rounded-2xl shadow-2xl w-72 animate-in slide-in-from-right-5 duration-300",
|
||||
!isFloating && "rounded-xl w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-3 border-b border-white/10 cursor-pointer",
|
||||
isFloating && "hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => isFloating && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AudioWaveform className="w-4 h-4 text-brand" />
|
||||
<span className="text-sm font-semibold">Vibe Analysis</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{overallMatch !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold tabular-nums",
|
||||
overallMatch >= 80
|
||||
? "text-green-400"
|
||||
: overallMatch >= 60
|
||||
? "text-brand"
|
||||
: "text-red-400"
|
||||
)}
|
||||
>
|
||||
{overallMatch}%
|
||||
</span>
|
||||
)}
|
||||
{isFloating && onClose && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="p-1 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* What is this? */}
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
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."}
|
||||
</p>
|
||||
|
||||
{/* Feature Bars */}
|
||||
<div className="space-y-3">
|
||||
{featureComparisons?.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div key={feature.key} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-xs font-medium text-gray-300">
|
||||
{feature.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold tabular-nums",
|
||||
getMatchColor(feature.diff)
|
||||
)}
|
||||
>
|
||||
{feature.match}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comparison Bar */}
|
||||
<div className="relative h-2 bg-white/5 rounded-full overflow-hidden">
|
||||
{/* Source marker (yellow dashed) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-brand z-10"
|
||||
style={{
|
||||
left: `${
|
||||
feature.sourceNorm * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Current value bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 left-0 rounded-full transition-all duration-500",
|
||||
getMatchBgColor(feature.diff)
|
||||
)}
|
||||
style={{
|
||||
width: `${
|
||||
feature.currentNorm * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Current marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 bg-white rounded-full transition-all duration-500"
|
||||
style={{
|
||||
left: `calc(${
|
||||
feature.currentNorm * 100
|
||||
}% - 2px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex justify-between text-[10px] text-gray-600">
|
||||
<span>{feature.lowLabel}</span>
|
||||
{feature.unit &&
|
||||
feature.currentValue && (
|
||||
<span className="text-gray-400">
|
||||
{Math.round(
|
||||
feature.currentValue as number
|
||||
)}{" "}
|
||||
{feature.unit}
|
||||
</span>
|
||||
)}
|
||||
<span>{feature.highLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ML Moods (Enhanced mode only) */}
|
||||
{(vibeSourceFeatures as ExtendedFeatures)?.analysisMode ===
|
||||
"enhanced" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 pb-1 border-b border-white/5">
|
||||
<Music className="w-3 h-3 text-gray-500" />
|
||||
<span className="text-[10px] font-medium text-gray-400 uppercase tracking-wider">
|
||||
ML Mood Analysis
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{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 (
|
||||
<div
|
||||
key={mood.key}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 p-1.5 rounded-lg",
|
||||
match !== null && match >= 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
|
||||
)}%`}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"w-3.5 h-3.5",
|
||||
mood.color
|
||||
)}
|
||||
/>
|
||||
<span className="text-[9px] text-gray-400">
|
||||
{mood.label}
|
||||
</span>
|
||||
{match !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
match >= 80
|
||||
? "text-green-400"
|
||||
: match >= 60
|
||||
? "text-gray-300"
|
||||
: "text-red-400"
|
||||
)}
|
||||
>
|
||||
{match}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-4 pt-2 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-brand" />
|
||||
<span className="text-[10px] text-gray-500">
|
||||
Source
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-2 bg-white/40 rounded-sm" />
|
||||
<span className="text-[10px] text-gray-500">
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Explanation */}
|
||||
{overallMatch !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-center py-2 px-3 rounded-lg text-xs",
|
||||
overallMatch >= 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"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full h-full bg-gradient-to-br from-[#1a1a2e] via-[#0f0f1a] to-[#000000] flex items-center justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Animated background glow */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-brand/20 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl animate-pulse delay-1000" />
|
||||
</div>
|
||||
|
||||
{/* Radar Chart */}
|
||||
<svg width={size} height={size} className="relative z-10">
|
||||
{/* Background circles */}
|
||||
{[0.25, 0.5, 0.75, 1].map((scale) => (
|
||||
<circle
|
||||
key={scale}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={maxRadius * scale}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<line
|
||||
key={i}
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Source polygon (yellow, dashed) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(sourceValues)}
|
||||
fill="rgba(236, 178, 0, 0.15)"
|
||||
stroke="#ecb200"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6,4"
|
||||
/>
|
||||
|
||||
{/* Current polygon (white/gradient, solid) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(currentValues)}
|
||||
fill="url(#currentGradient)"
|
||||
stroke="rgba(255, 255, 255, 0.9)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Gradient definition */}
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="currentGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="rgba(255, 255, 255, 0.2)"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="rgba(168, 85, 247, 0.2)"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* 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 (
|
||||
<g key={feature.key}>
|
||||
{/* Icon background */}
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={14}
|
||||
fill="rgba(0,0,0,0.5)"
|
||||
stroke={
|
||||
feature.match >= 70
|
||||
? "rgba(74, 222, 128, 0.5)"
|
||||
: "rgba(255,255,255,0.2)"
|
||||
}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* Feature label */}
|
||||
<text
|
||||
x={x}
|
||||
y={y + 28}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-400 text-[10px] font-medium"
|
||||
>
|
||||
{feature.label}
|
||||
</text>
|
||||
{/* Match percentage */}
|
||||
<text
|
||||
x={x}
|
||||
y={y + 40}
|
||||
textAnchor="middle"
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
feature.match >= 70
|
||||
? "fill-green-400"
|
||||
: feature.match >= 50
|
||||
? "fill-yellow-400"
|
||||
: "fill-red-400"
|
||||
)}
|
||||
>
|
||||
{feature.match}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Center match score */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={35}
|
||||
fill="rgba(0,0,0,0.7)"
|
||||
stroke={
|
||||
overallMatch && overallMatch >= 70
|
||||
? "#4ade80"
|
||||
: overallMatch && overallMatch >= 50
|
||||
? "#facc15"
|
||||
: "#f87171"
|
||||
}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={center - 5}
|
||||
textAnchor="middle"
|
||||
className={cn(
|
||||
"text-2xl font-bold",
|
||||
overallMatch && overallMatch >= 70
|
||||
? "fill-green-400"
|
||||
: overallMatch && overallMatch >= 50
|
||||
? "fill-yellow-400"
|
||||
: "fill-red-400"
|
||||
)}
|
||||
>
|
||||
{overallMatch}%
|
||||
</text>
|
||||
<text
|
||||
x={center}
|
||||
y={center + 12}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-400 text-[10px] font-medium uppercase tracking-wider"
|
||||
>
|
||||
Match
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-0.5 bg-brand border-dashed"
|
||||
style={{ borderStyle: "dashed" }}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Source Vibe
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-2 bg-white/30 rounded-sm" />
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Current Track
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Container component that manages the floating EnhancedVibeOverlay.
|
||||
* Shows automatically when vibe mode is active on desktop.
|
||||
*/
|
||||
export function VibeOverlayContainer() {
|
||||
const { vibeMode, queue, currentIndex } = useAudioState();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
// Auto-show when vibe mode activates, reset dismissed state
|
||||
useEffect(() => {
|
||||
if (vibeMode) {
|
||||
setIsVisible(true);
|
||||
setIsDismissed(false);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setIsDismissed(false);
|
||||
}
|
||||
}, [vibeMode]);
|
||||
|
||||
// Get current track's audio features from the queue
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
// Don't render if not in vibe mode or dismissed
|
||||
if (!vibeMode || isDismissed || !isVisible) return null;
|
||||
|
||||
return (
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsDismissed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user