"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, lazy, Suspense } from "react"; import { toast } from "sonner"; import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip"; import { cn, isLocalUrl } from "@/utils/cn"; import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime"; import { SeekSlider } from "./SeekSlider"; // Lazy load VibeOverlayEnhanced - only loads when vibe mode is active const EnhancedVibeOverlay = lazy(() => import("./VibeOverlayEnhanced").then(mod => ({ default: mod.EnhancedVibeOverlay }))); /** * 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: , }); } 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 = (() => { let time = currentTime; // If we're actively playing or have seeked, use the live currentTime if (time <= 0) { // Otherwise, show saved progress for audiobooks/podcasts if ( playbackType === "audiobook" && currentAudiobook?.progress?.currentTime ) { time = currentAudiobook.progress.currentTime; } else if ( playbackType === "podcast" && currentPodcast?.progress?.currentTime ) { time = currentPodcast.progress.currentTime; } } // CRITICAL: Clamp to duration to prevent display of invalid times return clampTime(time, duration); })(); const progress = duration > 0 ? Math.min(100, Math.max(0, (displayTime / duration) * 100)) : 0; const handleSeek = (time: number) => { seek(time); }; // Determine if seeking is allowed const seekEnabled = hasMedia && canSeek; const handleVolumeChange = (e: React.ChangeEvent) => { 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 ( {/* Floating Vibe Overlay - shows when tab is clicked */} {vibeMode && isVibePanelExpanded && ( Loading vibe analysis...}> setIsVibePanelExpanded(false)} /> )} {/* Vibe Tab - shows when vibe mode is active */} {vibeMode && ( 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" )} aria-label={isVibePanelExpanded ? "Hide vibe analysis" : "Show vibe analysis"} aria-expanded={isVibePanelExpanded} > Vibe Analysis {isVibePanelExpanded ? ( ) : ( )} )} {/* Subtle top glow */} {/* Artwork & Info */} {mediaLink ? ( {coverUrl ? ( ) : ( )} ) : ( )} {mediaLink ? ( {title} ) : ( {title} )} {artistLink ? ( {subtitle} ) : mediaLink ? ( {subtitle} ) : ( {subtitle} )} {/* Controls */} {/* Buttons */} {/* Shuffle */} {/* Skip Backward 30s */} 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} aria-label="Rewind 30 seconds" title="Rewind 30 seconds" > 30 {hasMedia && !isBuffering && ( )} {isBuffering ? ( ) : isPlaying ? ( ) : ( )} {/* Skip Forward 30s */} 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} aria-label="Forward 30 seconds" title="Forward 30 seconds" > 30 {/* Repeat */} {repeatMode === "one" ? ( ) : ( )} {/* Vibe Mode Toggle */} {isVibeLoading ? ( ) : ( )} {/* Progress Bar */} = 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format )} > {formatTime(displayTime)} = 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format )} > {playbackType === "podcast" || playbackType === "audiobook" ? formatTimeRemaining( Math.max(0, duration - displayTime) ) : formatTime(duration)} {/* Volume & Expand */} {isMuted || volume === 0 ? ( ) : ( )} {/* Keyboard Shortcuts Info */} 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} aria-label="Expand player" title="Expand to full screen" > ); }
{subtitle}