"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: , }); } 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) => { // 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) => { 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 && (
setIsVibePanelExpanded(false)} />
)} {/* Vibe Tab - shows when vibe mode is active */} {vibeMode && ( )}
{/* Subtle top glow */}
{/* Artwork & Info */}
{mediaLink ? (
{coverUrl ? ( {title} ) : ( )}
) : (
)}
{mediaLink ? (

{title}

) : (

{title}

)} {artistLink ? (

{subtitle}

) : mediaLink ? (

{subtitle}

) : (

{subtitle}

)}
{/* Controls */}
{/* Buttons */}
{/* Shuffle */} {/* Skip Backward 30s */} {/* Skip Forward 30s */} {/* Repeat */} {/* Vibe Mode Toggle */}
{/* Progress Bar */}
= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format )}> {formatTime(displayTime)}
{seekEnabled && (
)}
= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format )}> {formatTime(duration)}
{/* Volume & Expand */}
{/* Keyboard Shortcuts Info */}
); }