Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
This commit is contained in:
@@ -25,12 +25,15 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, lazy, Suspense } 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";
|
||||
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
|
||||
@@ -90,19 +93,23 @@ export function FullPlayer() {
|
||||
// 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);
|
||||
|
||||
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;
|
||||
@@ -126,12 +133,15 @@ export function FullPlayer() {
|
||||
};
|
||||
|
||||
// Start vibe mode with the queue IDs (include current track)
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
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]" />,
|
||||
@@ -169,32 +179,34 @@ export function FullPlayer() {
|
||||
// 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 (currentTime > 0) return currentTime;
|
||||
|
||||
// Otherwise, show saved progress for audiobooks/podcasts
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.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;
|
||||
}
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
|
||||
return 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 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;
|
||||
const handleSeek = (time: number) => {
|
||||
seek(time);
|
||||
};
|
||||
|
||||
@@ -220,8 +232,12 @@ export function FullPlayer() {
|
||||
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;
|
||||
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;
|
||||
@@ -249,11 +265,13 @@ export function FullPlayer() {
|
||||
{/* 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)}
|
||||
/>
|
||||
<Suspense fallback={<div className="bg-[#181818] border border-white/10 rounded-lg p-4 text-white/50">Loading vibe analysis...</div>}>
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -266,8 +284,12 @@ export function FullPlayer() {
|
||||
"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"
|
||||
isVibePanelExpanded
|
||||
? "text-brand"
|
||||
: "text-white/70 hover:text-brand"
|
||||
)}
|
||||
aria-label={isVibePanelExpanded ? "Hide vibe analysis" : "Show vibe analysis"}
|
||||
aria-expanded={isVibePanelExpanded}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
@@ -282,287 +304,368 @@ export function FullPlayer() {
|
||||
<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">
|
||||
<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="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}%` }}
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="relative w-14 h-14 flex-shrink-0 group"
|
||||
>
|
||||
{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" />
|
||||
<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>
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
<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>
|
||||
)}
|
||||
</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 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>
|
||||
|
||||
{/* Keyboard Shortcuts Info */}
|
||||
<KeyboardShortcutsTooltip />
|
||||
{/* Controls */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-5" role="group" aria-label="Playback controls">
|
||||
{/* 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"}
|
||||
aria-label="Shuffle"
|
||||
aria-pressed={isShuffle}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<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>
|
||||
{/* 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}
|
||||
aria-label="Rewind 30 seconds"
|
||||
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"}
|
||||
aria-label="Previous track"
|
||||
title="Previous 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}
|
||||
aria-label={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
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"}
|
||||
aria-label="Next track"
|
||||
title="Next 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}
|
||||
aria-label="Forward 30 seconds"
|
||||
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"}
|
||||
aria-label={
|
||||
repeatMode === "off"
|
||||
? "Repeat off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat all"
|
||||
: "Repeat one"
|
||||
}
|
||||
aria-pressed={repeatMode !== "off"}
|
||||
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
|
||||
}
|
||||
aria-label={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
aria-pressed={vibeMode}
|
||||
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>
|
||||
<SeekSlider
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
currentTime={displayTime}
|
||||
onSeek={handleSeek}
|
||||
canSeek={canSeek}
|
||||
hasMedia={hasMedia}
|
||||
downloadProgress={downloadProgress}
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
/>
|
||||
<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
|
||||
)}
|
||||
>
|
||||
{playbackType === "podcast" ||
|
||||
playbackType === "audiobook"
|
||||
? formatTimeRemaining(
|
||||
Math.max(0, duration - displayTime)
|
||||
)
|
||||
: 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"
|
||||
aria-label={volume === 0 ? "Unmute" : "Mute"}
|
||||
>
|
||||
{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}
|
||||
aria-label="Volume"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(volume * 100)}
|
||||
aria-valuetext={`${Math.round(volume * 100)} percent`}
|
||||
style={{
|
||||
background: `linear-gradient(to right, #fff ${volume * 100}%, rgba(255,255,255,0.15) ${volume * 100}%)`
|
||||
}}
|
||||
className="w-full h-1 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}
|
||||
aria-label="Expand player"
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { dispatchQueryEvent } from "@/lib/query-events";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -71,7 +72,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Controls context
|
||||
const { pause, next } = useAudioControls();
|
||||
const { pause, next, nextPodcastEpisode } = useAudioControls();
|
||||
|
||||
// Refs
|
||||
const lastTrackIdRef = useRef<string | null>(null);
|
||||
@@ -130,8 +131,12 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
savePodcastProgress(true);
|
||||
}
|
||||
|
||||
// Handle track advancement based on repeat mode
|
||||
if (playbackType === "track") {
|
||||
// Handle track advancement based on playback type
|
||||
if (playbackType === "podcast") {
|
||||
nextPodcastEpisode(); // Auto-advance to next episode
|
||||
} else if (playbackType === "audiobook") {
|
||||
pause();
|
||||
} else if (playbackType === "track") {
|
||||
if (repeatMode === "one") {
|
||||
howlerEngine.seek(0);
|
||||
howlerEngine.play();
|
||||
@@ -208,7 +213,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
howlerEngine.off("play", handlePlay);
|
||||
howlerEngine.off("pause", handlePause);
|
||||
};
|
||||
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTimeFromEngine, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
|
||||
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, nextPodcastEpisode, pause, setCurrentTimeFromEngine, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
|
||||
|
||||
// Save audiobook progress
|
||||
const saveAudiobookProgress = useCallback(
|
||||
@@ -245,6 +250,8 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
lastPlayedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
dispatchQueryEvent("audiobook-progress-updated");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save audiobook progress:",
|
||||
@@ -277,6 +284,8 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
duration,
|
||||
isFinished
|
||||
);
|
||||
|
||||
dispatchQueryEvent("podcast-progress-updated");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save podcast progress:",
|
||||
|
||||
@@ -26,9 +26,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { clampTime } from "@/utils/formatTime";
|
||||
import { useState, useRef, useEffect, lazy, Suspense } from "react";
|
||||
import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { SeekSlider } from "./SeekSlider";
|
||||
|
||||
// Lazy load VibeOverlayEnhanced - only loads when vibe mode is active
|
||||
const EnhancedVibeOverlay = lazy(() => import("./VibeOverlayEnhanced").then(mod => ({ default: mod.EnhancedVibeOverlay })));
|
||||
|
||||
export function MiniPlayer() {
|
||||
const {
|
||||
@@ -80,12 +84,19 @@ export function MiniPlayer() {
|
||||
currentTrack?.id || currentAudiobook?.id || currentPodcast?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMediaId && currentMediaId !== lastMediaIdRef.current) {
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
setIsDismissed(false);
|
||||
setIsMinimized(false);
|
||||
// Reset dismissed state when new media loads OR when same media starts playing again
|
||||
if (currentMediaId) {
|
||||
if (currentMediaId !== lastMediaIdRef.current) {
|
||||
// Different media - reset everything
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
setIsDismissed(false);
|
||||
setIsMinimized(false);
|
||||
} else if (isDismissed && isPlaying) {
|
||||
// Same media but user started playing again - show the player
|
||||
setIsDismissed(false);
|
||||
}
|
||||
}
|
||||
}, [currentMediaId]);
|
||||
}, [currentMediaId, isDismissed, isPlaying]);
|
||||
|
||||
// Handle Vibe Match toggle - finds tracks that sound like the current track
|
||||
const handleVibeToggle = async () => {
|
||||
@@ -210,19 +221,18 @@ export function MiniPlayer() {
|
||||
0
|
||||
);
|
||||
})();
|
||||
|
||||
// CRITICAL: Clamp currentTime to prevent invalid progress display
|
||||
const clampedCurrentTime = clampTime(currentTime, duration);
|
||||
|
||||
const progress =
|
||||
duration > 0
|
||||
? Math.min(100, Math.max(0, (currentTime / duration) * 100))
|
||||
? Math.min(100, Math.max(0, (clampedCurrentTime / 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);
|
||||
// Handle progress bar seek
|
||||
const handleSeek = (time: number) => {
|
||||
seek(time);
|
||||
};
|
||||
|
||||
const seekEnabled = hasMedia && canSeek;
|
||||
@@ -277,34 +287,59 @@ export function MiniPlayer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimized tab - small pill on RIGHT to bring player back
|
||||
// Minimized tab - matches full player height, slides from right
|
||||
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"
|
||||
className="fixed right-0 z-50 shadow-2xl transition-transform hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 16px)",
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
}}
|
||||
aria-label="Show player"
|
||||
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
|
||||
className="rounded-l-xl p-[2px]"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #a855f7 0%, #f5c518 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="rounded-l-[10px] overflow-hidden">
|
||||
<div className="relative bg-gradient-to-r from-[#2d1847] to-[#1a1a2e]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#a855f7]/40 to-[#f5c518]/30" />
|
||||
|
||||
{/* Progress bar at top */}
|
||||
<div className="relative h-[2px] bg-white/20 w-full">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#a855f7] to-[#f5c518] transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex items-center gap-2 pl-3 pr-2 py-3">
|
||||
<ChevronLeft className="w-4 h-4 text-white flex-shrink-0" />
|
||||
{coverUrl ? (
|
||||
<div className="relative w-12 h-12 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-black/30 flex items-center justify-center">
|
||||
<MusicIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -314,128 +349,155 @@ export function MiniPlayer() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed left-2 right-2 z-50 rounded-xl overflow-hidden shadow-xl"
|
||||
className="fixed left-2 right-2 z-50 shadow-2xl"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
transform: `translateX(${swipeOffset}px)`,
|
||||
opacity: swipeOpacity,
|
||||
transition: swipeOffset === 0 ? 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
|
||||
transition:
|
||||
swipeOffset === 0
|
||||
? "transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
: "none",
|
||||
}}
|
||||
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 - more spacious padding */}
|
||||
{/* Gradient border container - uses padding technique for gradient border */}
|
||||
<div
|
||||
className="relative flex items-center gap-3 px-3 py-3 cursor-pointer"
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className="rounded-[14px] p-[2px]"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #f5c518 0%, #a855f7 50%, #f5c518 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Album Art - slightly larger */}
|
||||
<div className="relative w-12 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-black/30 shadow-md">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-5 h-5 text-gray-400" />
|
||||
{/* Inner container with overflow hidden for proper clipping */}
|
||||
<div className="rounded-[12px] overflow-hidden">
|
||||
{/* Single solid background with gradient overlay - prevents corner bleed */}
|
||||
<div className="relative bg-gradient-to-r from-[#2a1a3f] via-[#3d2060] to-[#2a1a3f]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#f5c518]/25 via-[#a855f7]/35 to-[#f5c518]/25" />
|
||||
|
||||
{/* Progress bar at top - inside the clipped container */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate leading-tight">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-gray-300/70 text-xs truncate leading-tight mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{/* Player content - more spacious padding */}
|
||||
<div
|
||||
className="relative flex items-center gap-3 px-3 py-3 cursor-pointer"
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
>
|
||||
{/* Album Art - slightly larger */}
|
||||
<div className="relative w-12 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-black/30 shadow-md">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls - Vibe & Play/Pause */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Vibe Button */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"w-10 h-10 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-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate leading-tight">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-gray-300/70 text-xs truncate leading-tight mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isBuffering) {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-10 h-10 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-5 h-5 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Controls - Vibe button (for music only) & Play/Pause */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="group"
|
||||
aria-label="Playback controls"
|
||||
>
|
||||
{/* Vibe button - only for music tracks */}
|
||||
{canSkip && (
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={isVibeLoading}
|
||||
className={cn(
|
||||
"w-10 h-10 flex items-center justify-center rounded-full transition-colors",
|
||||
vibeMode
|
||||
? "text-[#f5c518]"
|
||||
: "text-white/80 hover:text-[#f5c518]"
|
||||
)}
|
||||
aria-label={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
aria-pressed={vibeMode}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isBuffering) {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition shadow-md",
|
||||
isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-white text-black hover:scale-105"
|
||||
)}
|
||||
aria-label={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,11 +518,13 @@ export function MiniPlayer() {
|
||||
)}
|
||||
>
|
||||
<div className="bg-[#121212]">
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="inline"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
<Suspense fallback={<div className="p-4 text-center text-white/50">Loading vibe analysis...</div>}>
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="inline"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,6 +542,8 @@ export function MiniPlayer() {
|
||||
? "text-brand"
|
||||
: "text-white/70 hover:text-brand"
|
||||
)}
|
||||
aria-label={isVibePanelExpanded ? "Hide vibe analysis" : "Show vibe analysis"}
|
||||
aria-expanded={isVibePanelExpanded}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
@@ -494,40 +560,17 @@ export function MiniPlayer() {
|
||||
<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>
|
||||
<SeekSlider
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
currentTime={clampedCurrentTime}
|
||||
onSeek={handleSeek}
|
||||
canSeek={canSeek}
|
||||
hasMedia={hasMedia}
|
||||
downloadProgress={downloadProgress}
|
||||
variant="minimal"
|
||||
className="absolute top-0 left-0 right-0"
|
||||
/>
|
||||
|
||||
{/* Player Content */}
|
||||
<div className="px-3 py-2.5 pt-3">
|
||||
@@ -591,6 +634,7 @@ export function MiniPlayer() {
|
||||
<button
|
||||
onClick={() => setPlayerMode("full")}
|
||||
className="text-gray-400 hover:text-white transition p-1"
|
||||
aria-label="Show bottom player"
|
||||
title="Show bottom player"
|
||||
>
|
||||
<MonitorUp className="w-3.5 h-3.5" />
|
||||
@@ -604,6 +648,7 @@ export function MiniPlayer() {
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
aria-label="Expand player"
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
@@ -625,6 +670,8 @@ export function MiniPlayer() {
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Shuffle"
|
||||
aria-pressed={isShuffle}
|
||||
title={canSkip ? "Shuffle" : "Shuffle (music only)"}
|
||||
>
|
||||
<Shuffle className="w-3.5 h-3.5" />
|
||||
@@ -640,6 +687,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Skip backward 30 seconds"
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
@@ -658,6 +706,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Previous track"
|
||||
title={
|
||||
canSkip ? "Previous" : "Previous (music only)"
|
||||
}
|
||||
@@ -683,6 +732,7 @@ export function MiniPlayer() {
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
@@ -710,6 +760,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Next track"
|
||||
title={canSkip ? "Next" : "Next (music only)"}
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
@@ -725,6 +776,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
@@ -745,6 +797,8 @@ export function MiniPlayer() {
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label={repeatMode === 'one' ? "Repeat one" : repeatMode === 'all' ? "Repeat all" : "Repeat off"}
|
||||
aria-pressed={repeatMode !== 'off'}
|
||||
title={
|
||||
canSkip
|
||||
? repeatMode === "off"
|
||||
@@ -774,6 +828,8 @@ export function MiniPlayer() {
|
||||
? "text-brand hover:text-brand-hover"
|
||||
: "text-gray-400 hover:text-brand"
|
||||
)}
|
||||
aria-label="Toggle vibe visualization"
|
||||
aria-pressed={vibeMode}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
|
||||
@@ -17,13 +17,16 @@ import {
|
||||
Repeat1,
|
||||
AudioWaveform,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
import { formatTime, clampTime, formatTimeRemaining } 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";
|
||||
import { SeekSlider } from "./SeekSlider";
|
||||
|
||||
export function OverlayPlayer() {
|
||||
const {
|
||||
@@ -46,6 +49,8 @@ export function OverlayPlayer() {
|
||||
previous,
|
||||
returnToPreviousMode,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setUpcoming,
|
||||
@@ -53,14 +58,14 @@ export function OverlayPlayer() {
|
||||
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);
|
||||
@@ -85,27 +90,35 @@ export function OverlayPlayer() {
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) return null;
|
||||
|
||||
const displayTime = (() => {
|
||||
if (currentTime > 0) return currentTime;
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.currentTime;
|
||||
let time = currentTime;
|
||||
|
||||
if (time <= 0) {
|
||||
if (
|
||||
playbackType === "audiobook" &&
|
||||
currentAudiobook?.progress?.currentTime
|
||||
) {
|
||||
time = currentAudiobook.progress.currentTime;
|
||||
} else if (
|
||||
playbackType === "podcast" &&
|
||||
currentPodcast?.progress?.currentTime
|
||||
) {
|
||||
time = currentPodcast.progress.currentTime;
|
||||
}
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
return 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 progress =
|
||||
duration > 0
|
||||
? Math.min(100, Math.max(0, (displayTime / duration) * 100))
|
||||
: 0;
|
||||
const seekEnabled = canSeek;
|
||||
const canSkip = playbackType === "track";
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
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;
|
||||
const handleSeek = (time: number) => {
|
||||
seek(time);
|
||||
};
|
||||
|
||||
@@ -122,7 +135,7 @@ export function OverlayPlayer() {
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchStartX.current === null) return;
|
||||
|
||||
|
||||
if (canSkip) {
|
||||
if (swipeOffset > 60) {
|
||||
previous();
|
||||
@@ -130,7 +143,7 @@ export function OverlayPlayer() {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setSwipeOffset(0);
|
||||
touchStartX.current = null;
|
||||
};
|
||||
@@ -138,17 +151,21 @@ export function OverlayPlayer() {
|
||||
// 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);
|
||||
|
||||
const response = await api.getRadioTracks(
|
||||
"vibe",
|
||||
currentTrack.id,
|
||||
50
|
||||
);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
@@ -170,10 +187,13 @@ export function OverlayPlayer() {
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
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]" />,
|
||||
@@ -203,8 +223,12 @@ export function OverlayPlayer() {
|
||||
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;
|
||||
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;
|
||||
@@ -224,16 +248,16 @@ export function OverlayPlayer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<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
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ paddingTop: 'calc(12px + env(safe-area-inset-top))' }}
|
||||
style={{ paddingTop: "calc(12px + env(safe-area-inset-top))" }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -246,38 +270,39 @@ export function OverlayPlayer() {
|
||||
>
|
||||
<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={{
|
||||
<div
|
||||
className="w-full max-w-[320px] 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
|
||||
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"
|
||||
)} />
|
||||
|
||||
<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} />
|
||||
<VibeComparisonArt
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
/>
|
||||
) : coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
@@ -295,20 +320,24 @@ export function OverlayPlayer() {
|
||||
</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>
|
||||
)}
|
||||
{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 */}
|
||||
@@ -316,7 +345,11 @@ export function OverlayPlayer() {
|
||||
{/* Track Info */}
|
||||
<div className="text-center landscape:text-left mb-6">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<Link
|
||||
href={mediaLink}
|
||||
onClick={returnToPreviousMode}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<h1 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
@@ -327,7 +360,11 @@ export function OverlayPlayer() {
|
||||
</h1>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link href={artistLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<Link
|
||||
href={artistLink}
|
||||
onClick={returnToPreviousMode}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<p className="text-base text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
@@ -341,41 +378,50 @@ export function OverlayPlayer() {
|
||||
|
||||
{/* 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>
|
||||
<SeekSlider
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
currentTime={displayTime}
|
||||
onSeek={handleSeek}
|
||||
canSeek={canSeek}
|
||||
hasMedia={hasMedia}
|
||||
downloadProgress={downloadProgress}
|
||||
variant="overlay"
|
||||
showHandle={false}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 font-medium tabular-nums">
|
||||
<span>{formatTime(displayTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
<span>
|
||||
{playbackType === "podcast" || playbackType === "audiobook"
|
||||
? formatTimeRemaining(Math.max(0, duration - displayTime))
|
||||
: formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-6 mb-6">
|
||||
{/* Skip -30s (for audiobooks/podcasts) */}
|
||||
{!canSkip && (
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
className="text-white/80 hover:text-white transition-all hover:scale-110 relative"
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-7 h-7" />
|
||||
<span className="absolute text-[9px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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"
|
||||
!canSkip &&
|
||||
"opacity-30 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
disabled={!canSkip}
|
||||
title={canSkip ? "Previous" : "Skip only for music"}
|
||||
@@ -399,13 +445,28 @@ export function OverlayPlayer() {
|
||||
onClick={next}
|
||||
className={cn(
|
||||
"text-white/80 hover:text-white transition-all hover:scale-110",
|
||||
!canSkip && "opacity-30 cursor-not-allowed hover:scale-100"
|
||||
!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>
|
||||
|
||||
{/* Skip +30s (for audiobooks/podcasts) */}
|
||||
{!canSkip && (
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
className="text-white/80 hover:text-white transition-all hover:scale-110 relative"
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-7 h-7" />
|
||||
<span className="absolute text-[9px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
@@ -437,7 +498,13 @@ export function OverlayPlayer() {
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-white"
|
||||
)}
|
||||
title={repeatMode === "one" ? "Repeat One" : repeatMode === "all" ? "Repeat All" : "Repeat Off"}
|
||||
title={
|
||||
repeatMode === "one"
|
||||
? "Repeat One"
|
||||
: repeatMode === "all"
|
||||
? "Repeat All"
|
||||
: "Repeat Off"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-5 h-5" />
|
||||
@@ -457,7 +524,11 @@ export function OverlayPlayer() {
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-[#f5c518]"
|
||||
)}
|
||||
title={vibeMode ? "Turn off vibe mode" : "Match this vibe"}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
@@ -468,9 +539,9 @@ export function OverlayPlayer() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Safe area padding at bottom */}
|
||||
<div style={{ height: 'env(safe-area-inset-bottom)' }} />
|
||||
<div style={{ height: "env(safe-area-inset-bottom)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
interface SeekSliderProps {
|
||||
/** Current progress percentage (0-100) */
|
||||
progress: number;
|
||||
/** Duration in seconds */
|
||||
duration: number;
|
||||
/** Current time in seconds */
|
||||
currentTime: number;
|
||||
/** Callback when seeking to a new position */
|
||||
onSeek: (time: number) => void;
|
||||
/** Whether seeking is enabled */
|
||||
canSeek: boolean;
|
||||
/** Whether the slider has media loaded */
|
||||
hasMedia: boolean;
|
||||
/** Download progress (0-100) if downloading */
|
||||
downloadProgress?: number | null;
|
||||
/** Custom class name for the container */
|
||||
className?: string;
|
||||
/** Whether to show the drag handle on hover/drag */
|
||||
showHandle?: boolean;
|
||||
/** Variant styling */
|
||||
variant?: "default" | "minimal" | "overlay";
|
||||
}
|
||||
|
||||
export function SeekSlider({
|
||||
progress,
|
||||
duration,
|
||||
currentTime,
|
||||
onSeek,
|
||||
canSeek,
|
||||
hasMedia,
|
||||
downloadProgress,
|
||||
className,
|
||||
showHandle = true,
|
||||
variant = "default",
|
||||
}: SeekSliderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const touchIdentifierRef = useRef<number | null>(null);
|
||||
|
||||
const calculateProgress = useCallback((clientX: number): number => {
|
||||
if (!sliderRef.current) return 0;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
return percentage;
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
|
||||
// Store the touch identifier to track this specific touch
|
||||
const touch = e.touches[0];
|
||||
touchIdentifierRef.current = touch.identifier;
|
||||
|
||||
setIsDragging(true);
|
||||
const newProgress = calculateProgress(touch.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
|
||||
// Prevent default to avoid scrolling and stop propagation to prevent parent swipe handlers
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
// Find the touch that we're tracking
|
||||
const touch = Array.from(e.touches).find(
|
||||
(t) => t.identifier === touchIdentifierRef.current
|
||||
);
|
||||
|
||||
if (!touch) return;
|
||||
|
||||
const newProgress = calculateProgress(touch.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
|
||||
// Prevent default to avoid scrolling and stop propagation to prevent parent swipe handlers
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[isDragging, canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
// Check if the touch we're tracking ended
|
||||
const touchEnded = !Array.from(e.touches).some(
|
||||
(t) => t.identifier === touchIdentifierRef.current
|
||||
);
|
||||
|
||||
if (!touchEnded) return;
|
||||
|
||||
if (previewProgress !== null) {
|
||||
const newTime = (previewProgress / 100) * duration;
|
||||
onSeek(newTime);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
setPreviewProgress(null);
|
||||
touchIdentifierRef.current = null;
|
||||
|
||||
// Stop propagation to prevent parent swipe handlers
|
||||
e.stopPropagation();
|
||||
},
|
||||
[isDragging, canSeek, previewProgress, duration, onSeek]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
|
||||
setIsDragging(true);
|
||||
const newProgress = calculateProgress(e.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
},
|
||||
[canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
const newProgress = calculateProgress(e.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
},
|
||||
[isDragging, canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
if (previewProgress !== null) {
|
||||
const newTime = (previewProgress / 100) * duration;
|
||||
onSeek(newTime);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
setPreviewProgress(null);
|
||||
}, [isDragging, canSeek, previewProgress, duration, onSeek]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Don't handle click if we just finished dragging
|
||||
if (isDragging) return;
|
||||
if (!canSeek) return;
|
||||
|
||||
const newProgress = calculateProgress(e.clientX);
|
||||
const newTime = (newProgress / 100) * duration;
|
||||
onSeek(newTime);
|
||||
},
|
||||
[isDragging, canSeek, calculateProgress, duration, onSeek]
|
||||
);
|
||||
|
||||
// Add global mouse event listeners when dragging
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const displayProgress =
|
||||
previewProgress !== null ? previewProgress : progress;
|
||||
const isActive = canSeek && hasMedia;
|
||||
|
||||
// Determine tooltip text
|
||||
const getTooltipText = () => {
|
||||
if (!hasMedia) return undefined;
|
||||
if (!canSeek) {
|
||||
return downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached";
|
||||
}
|
||||
return isDragging ? "Release to seek" : "Click or drag to seek";
|
||||
};
|
||||
|
||||
// Variant-specific styles
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case "minimal":
|
||||
return {
|
||||
container: "h-1",
|
||||
track: "bg-white/[0.15]",
|
||||
progress: isActive
|
||||
? "bg-white"
|
||||
: hasMedia
|
||||
? "bg-white/50"
|
||||
: "bg-gray-600",
|
||||
};
|
||||
case "overlay":
|
||||
return {
|
||||
container: "h-1",
|
||||
track: "bg-white/20",
|
||||
progress: isActive
|
||||
? "bg-gradient-to-r from-[#f5c518] to-[#a855f7]"
|
||||
: "bg-white/40",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
container: "h-1",
|
||||
track: "bg-white/[0.15]",
|
||||
progress: isActive
|
||||
? "bg-white group-hover:bg-white"
|
||||
: hasMedia
|
||||
? "bg-white/50"
|
||||
: "bg-gray-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getVariantStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={cn(
|
||||
"relative rounded-full transition-all",
|
||||
styles.container,
|
||||
styles.track,
|
||||
isActive ? "cursor-pointer group" : "cursor-not-allowed",
|
||||
isDragging && "h-2", // Expand when dragging
|
||||
className
|
||||
)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
title={getTooltipText()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
styles.progress
|
||||
)}
|
||||
style={{ width: `${displayProgress}%` }}
|
||||
>
|
||||
{showHandle && isActive && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full transition-opacity shadow-lg shadow-white/50",
|
||||
isDragging
|
||||
? "opacity-100 scale-125"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual feedback when dragging */}
|
||||
{isDragging && (
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-black/80 text-white text-xs px-2 py-1 rounded pointer-events-none whitespace-nowrap">
|
||||
{Math.floor((displayProgress / 100) * duration)}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -541,7 +541,7 @@ export function VibeComparisonArt({
|
||||
if (!vibeMode || !comparisons) return null;
|
||||
|
||||
// Radar chart dimensions
|
||||
const size = 280;
|
||||
const size = 320;
|
||||
const center = size / 2;
|
||||
const maxRadius = 110;
|
||||
|
||||
@@ -564,7 +564,8 @@ export function VibeComparisonArt({
|
||||
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",
|
||||
"relative w-full h-full flex items-center justify-center",
|
||||
"md:bg-gradient-to-br md:from-[#1a1a2e] md:via-[#0f0f1a] md:to-[#000000] md:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
|
||||
// Lazy load VibeOverlayEnhanced - only loads when vibe mode is active
|
||||
const EnhancedVibeOverlay = lazy(() => import("./VibeOverlayEnhanced").then(mod => ({ default: mod.EnhancedVibeOverlay })));
|
||||
|
||||
/**
|
||||
* Container component that manages the floating EnhancedVibeOverlay.
|
||||
@@ -31,10 +33,12 @@ export function VibeOverlayContainer() {
|
||||
if (!vibeMode || isDismissed || !isVisible) return null;
|
||||
|
||||
return (
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsDismissed(true)}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsDismissed(true)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user