Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
+568
View File
@@ -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
}
+796
View File
@@ -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 />
)}
</>
);
}
+198
View File
@@ -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>
);
}
+761
View File
@@ -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>
);
}