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:
Your Name
2026-01-06 20:07:33 -06:00
parent 8fe151a0d1
commit cc8d0f6969
242 changed files with 20562 additions and 7725 deletions

View File

@@ -370,6 +370,29 @@ function DownloadJobItem({
}
};
const getSourceColor = () => {
if (!job.metadata?.currentSource) return "text-white/60";
switch (job.metadata.currentSource) {
case "lidarr":
return "text-purple-400";
case "soulseek":
return "text-teal-400";
default:
return "text-white/60";
}
};
const getStatusText = () => {
if (job.metadata?.statusText) {
return job.metadata.statusText;
}
// Fallback for backward compatibility
if (job.status === "processing" || job.status === "pending") {
return "Processing";
}
return null;
};
const handleDelete = async () => {
try {
setIsDeleting(true);
@@ -397,7 +420,7 @@ function DownloadJobItem({
<p className="text-sm font-medium text-white truncate">
{job.subject}
</p>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span
className={cn(
"text-xs font-medium capitalize",
@@ -406,6 +429,19 @@ function DownloadJobItem({
>
{job.status}
</span>
{getStatusText() && (
<>
<span className="text-xs text-white/40"></span>
<span
className={cn(
"text-xs font-medium",
getSourceColor()
)}
>
{getStatusText()}
</span>
</>
)}
<span className="text-xs text-white/40"></span>
<span className="text-xs text-white/40 capitalize">
{job.type}
@@ -457,13 +493,48 @@ function DownloadJobItemCompact({
}
};
const getSourceColor = () => {
if (!job.metadata?.currentSource) return "text-white/60";
switch (job.metadata.currentSource) {
case "lidarr":
return "text-purple-400";
case "soulseek":
return "text-teal-400";
default:
return "text-white/60";
}
};
const getStatusText = () => {
if (job.metadata?.statusText) {
return job.metadata.statusText;
}
// Fallback for backward compatibility
if (job.status === "processing" || job.status === "pending") {
return "Processing";
}
return null;
};
return (
<div className="px-3 py-2 flex items-center gap-2">
<div className="flex-shrink-0">{getStatusIcon()}</div>
<p className="flex-1 text-xs font-medium text-white truncate">
{job.subject}
</p>
<span className="text-[10px] text-white/40 capitalize">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white truncate">
{job.subject}
</p>
{getStatusText() && (
<p
className={cn(
"text-[10px] font-medium",
getSourceColor()
)}
>
{getStatusText()}
</p>
)}
</div>
<span className="text-[10px] text-white/40 capitalize shrink-0">
{job.status}
</span>
</div>

View File

@@ -0,0 +1,373 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { enrichmentApi, EnrichmentFailure } from "@/lib/enrichmentApi";
import {
X,
RefreshCw,
SkipForward,
Trash2,
AlertCircle,
Filter,
} from "lucide-react";
interface EnrichmentFailuresModalProps {
isOpen: boolean;
onClose: () => void;
}
export function EnrichmentFailuresModal({
isOpen,
onClose,
}: EnrichmentFailuresModalProps) {
const [selectedType, setSelectedType] = useState<
"all" | "artist" | "track" | "audio"
>("all");
const [selectedFailures, setSelectedFailures] = useState<Set<string>>(
new Set()
);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const queryClient = useQueryClient();
// Fetch failures
const {
data: failures,
isLoading,
refetch,
} = useQuery({
queryKey: ["enrichment-failures", selectedType, currentPage],
queryFn: async () => {
const params: any = {
limit: pageSize,
offset: (currentPage - 1) * pageSize,
resolved: false,
};
if (selectedType !== "all") {
params.entityType = selectedType;
}
return enrichmentApi.getFailures(params);
},
enabled: isOpen,
});
// Fetch counts
const { data: counts } = useQuery({
queryKey: ["enrichment-failure-counts"],
queryFn: () => enrichmentApi.getFailureCounts(),
enabled: isOpen,
});
// Retry mutation
const retryMutation = useMutation({
mutationFn: (failureIds: string[]) =>
enrichmentApi.retryFailures(failureIds),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["enrichment-failures"],
});
queryClient.invalidateQueries({
queryKey: ["enrichment-failure-counts"],
});
queryClient.invalidateQueries({
queryKey: ["enrichment-progress"],
});
setSelectedFailures(new Set());
},
});
// Skip mutation
const skipMutation = useMutation({
mutationFn: (failureIds: string[]) =>
enrichmentApi.skipFailures(failureIds),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["enrichment-failures"],
});
queryClient.invalidateQueries({
queryKey: ["enrichment-failure-counts"],
});
setSelectedFailures(new Set());
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (failureId: string) =>
enrichmentApi.deleteFailure(failureId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["enrichment-failures"],
});
queryClient.invalidateQueries({
queryKey: ["enrichment-failure-counts"],
});
},
});
const toggleFailureSelection = (id: string) => {
const newSelected = new Set(selectedFailures);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedFailures(newSelected);
};
const handleSelectAll = () => {
if (selectedFailures.size === failures?.failures.length) {
setSelectedFailures(new Set());
} else {
setSelectedFailures(
new Set(failures?.failures.map((f) => f.id) || [])
);
}
};
const handleRetrySelected = () => {
if (selectedFailures.size > 0) {
retryMutation.mutate(Array.from(selectedFailures));
}
};
const handleSkipSelected = () => {
if (selectedFailures.size > 0) {
skipMutation.mutate(Array.from(selectedFailures));
}
};
if (!isOpen) return null;
const totalFailures = counts?.total || 0;
const totalPages = Math.ceil((failures?.total || 0) / pageSize);
return (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div className="bg-[#1a1a1a] rounded-lg w-full max-w-4xl max-h-[90vh] flex flex-col border border-white/10">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-white/10">
<div>
<h2 className="text-xl font-bold text-white">
Enrichment Failures
</h2>
<p className="text-sm text-white/50 mt-1">
{totalFailures} total failure
{totalFailures !== 1 ? "s" : ""} to review
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-white/70" />
</button>
</div>
{/* Filter Tabs */}
<div className="flex gap-2 p-4 border-b border-white/10 overflow-x-auto">
{(
[
{ key: "all" as const, label: "All", count: counts?.total || 0 },
{
key: "artist" as const,
label: "Artists",
count: counts?.artist || 0,
},
{
key: "track" as const,
label: "Tracks",
count: counts?.track || 0,
},
{
key: "audio" as const,
label: "Audio Analysis",
count: counts?.audio || 0,
},
]
).map((tab) => (
<button
key={tab.key}
onClick={() => {
setSelectedType(tab.key);
setCurrentPage(1);
setSelectedFailures(new Set());
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedType === tab.key
? "bg-[#ecb200] text-black"
: "bg-white/5 text-white/70 hover:bg-white/10"
}`}
>
{tab.label} ({tab.count})
</button>
))}
</div>
{/* Action Bar */}
{selectedFailures.size > 0 && (
<div className="flex items-center gap-2 p-4 bg-white/5 border-b border-white/10">
<span className="text-sm text-white/70">
{selectedFailures.size} selected
</span>
<div className="flex gap-2 ml-auto">
<button
onClick={handleRetrySelected}
disabled={retryMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg
hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
Retry
</button>
<button
onClick={handleSkipSelected}
disabled={skipMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-white/10 text-white/70 rounded-lg
hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<SkipForward className="w-3.5 h-3.5" />
Skip
</button>
</div>
</div>
)}
{/* Failures List */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-white/50">
Loading failures...
</div>
</div>
) : failures?.failures.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-white/50">
<AlertCircle className="w-12 h-12 mb-3 opacity-50" />
<p className="text-lg font-medium">
No failures found
</p>
<p className="text-sm mt-1">
All items enriched successfully
</p>
</div>
) : (
<div className="space-y-2">
{/* Select All */}
<div className="flex items-center gap-3 px-3 py-2">
<input
type="checkbox"
checked={
selectedFailures.size ===
failures?.failures.length
}
onChange={handleSelectAll}
className="w-4 h-4 rounded border-white/20 bg-white/10"
/>
<span className="text-sm text-white/50">
Select all
</span>
</div>
{failures?.failures.map((failure) => (
<div
key={failure.id}
className="flex items-start gap-3 p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
>
<input
type="checkbox"
checked={selectedFailures.has(
failure.id
)}
onChange={() =>
toggleFailureSelection(failure.id)
}
className="w-4 h-4 mt-1 rounded border-white/20 bg-white/10"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white truncate">
{failure.entityName ||
failure.entityId}
</span>
<span className="text-xs px-2 py-0.5 bg-white/10 text-white/50 rounded uppercase">
{failure.entityType}
</span>
</div>
<p className="text-xs text-red-400 mt-1">
{failure.errorMessage ||
"Unknown error"}
</p>
<div className="flex items-center gap-3 mt-2 text-[10px] text-white/30">
<span>
Retry {failure.retryCount}/
{failure.maxRetries}
</span>
<span></span>
<span>
Last:{" "}
{new Date(
failure.lastFailedAt
).toLocaleString()}
</span>
{failure.errorCode && (
<>
<span></span>
<span>
{failure.errorCode}
</span>
</>
)}
</div>
</div>
<button
onClick={() =>
deleteMutation.mutate(failure.id)
}
disabled={deleteMutation.isPending}
className="p-2 hover:bg-red-500/20 rounded-lg transition-colors"
title="Delete failure record"
>
<Trash2 className="w-4 h-4 text-red-400" />
</button>
</div>
))}
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t border-white/10">
<button
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
}
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm bg-white/10 text-white/70 rounded-lg
hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-white/50">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() =>
setCurrentPage((p) =>
Math.min(totalPages, p + 1)
)
}
disabled={currentPage === totalPages}
className="px-3 py-1.5 text-sm bg-white/10 text-white/70 rounded-lg
hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -19,6 +19,15 @@ interface MetadataEditorProps {
rgMbid?: string;
coverUrl?: string;
heroUrl?: string;
// Original values for comparison (when user overrides exist)
_originalName?: string;
_originalBio?: string | null;
_originalGenres?: string[];
_originalHeroUrl?: string | null;
_originalTitle?: string;
_originalYear?: number | null;
_originalCoverUrl?: string | null;
_hasUserOverrides?: boolean;
};
onSave?: (updatedData: any) => void;
}
@@ -36,7 +45,9 @@ export function MetadataEditor({
}: MetadataEditorProps) {
const [isOpen, setIsOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [formData, setFormData] = useState(currentData);
const hasOverrides = currentData._hasUserOverrides ?? false;
const handleOpen = () => {
setFormData(currentData);
@@ -48,6 +59,35 @@ export function MetadataEditor({
setFormData(currentData);
};
const handleReset = async () => {
if (
!confirm(
"Reset all metadata to original values? This cannot be undone."
)
) {
return;
}
setIsResetting(true);
try {
if (type === "artist") {
await api.resetArtistMetadata(id);
} else if (type === "album") {
await api.resetAlbumMetadata(id);
} else {
await api.resetTrackMetadata(id);
}
toast.success("Metadata reset to original values");
onSave?.(null);
setIsOpen(false);
} catch (error: any) {
toast.error(error.message || "Failed to reset metadata");
} finally {
setIsResetting(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
@@ -144,6 +184,24 @@ export function MetadataEditor({
}
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
/>
{type === "artist" &&
currentData._originalName &&
currentData._originalName !==
(formData.name || "") && (
<p className="mt-1 text-xs text-gray-500">
Original:{" "}
{currentData._originalName}
</p>
)}
{type !== "artist" &&
currentData._originalTitle &&
currentData._originalTitle !==
(formData.title || "") && (
<p className="mt-1 text-xs text-gray-500">
Original:{" "}
{currentData._originalTitle}
</p>
)}
</div>
{/* Bio (Artist only) */}
@@ -160,6 +218,18 @@ export function MetadataEditor({
rows={6}
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none resize-none"
/>
{currentData._originalBio &&
currentData._originalBio !==
(formData.bio || "") && (
<p className="mt-1 text-xs text-gray-500">
Original:{" "}
{currentData._originalBio.substring(
0,
100
)}
...
</p>
)}
</div>
)}
@@ -180,6 +250,14 @@ export function MetadataEditor({
}
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
/>
{currentData._originalYear &&
currentData._originalYear !==
(formData.year || null) && (
<p className="mt-1 text-xs text-gray-500">
Original:{" "}
{currentData._originalYear}
</p>
)}
</div>
)}
@@ -206,6 +284,21 @@ export function MetadataEditor({
placeholder="Rock, Alternative, Indie"
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
/>
{currentData._originalGenres &&
currentData._originalGenres.length > 0 &&
JSON.stringify(
currentData._originalGenres.sort()
) !==
JSON.stringify(
(formData.genres || []).sort()
) && (
<p className="mt-1 text-xs text-gray-500">
Original:{" "}
{currentData._originalGenres.join(
", "
)}
</p>
)}
</div>
{/* MusicBrainz ID */}
@@ -268,6 +361,24 @@ export function MetadataEditor({
placeholder="https://..."
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none text-sm"
/>
{type === "artist" &&
currentData._originalHeroUrl &&
currentData._originalHeroUrl !==
(formData.heroUrl || "") && (
<p className="mt-1 text-xs text-gray-500 truncate">
Original:{" "}
{currentData._originalHeroUrl}
</p>
)}
{type === "album" &&
currentData._originalCoverUrl &&
currentData._originalCoverUrl !==
(formData.coverUrl || "") && (
<p className="mt-1 text-xs text-gray-500 truncate">
Original:{" "}
{currentData._originalCoverUrl}
</p>
)}
{/* Image Preview */}
{(formData.heroUrl || formData.coverUrl) && (
<div className="mt-2">
@@ -295,6 +406,17 @@ export function MetadataEditor({
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-white/10">
{hasOverrides && (
<button
onClick={handleReset}
disabled={isSaving || isResetting}
className="px-6 py-2 rounded-full bg-red-500/20 hover:bg-red-500/30 text-red-400 font-bold transition-all border border-red-500/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isResetting
? "Resetting..."
: "Reset to Original"}
</button>
)}
<button
onClick={handleClose}
className="px-6 py-2 rounded-full bg-white/10 hover:bg-white/20 text-white font-bold transition-all"

View File

@@ -13,6 +13,14 @@ export function PWAInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false);
const [isIOS, setIsIOS] = useState(false);
const isDismissedRecently = (): boolean => {
const dismissedAt = localStorage.getItem("pwa-prompt-dismissed");
if (!dismissedAt) return false;
const dismissedTime = parseInt(dismissedAt, 10);
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return Date.now() - dismissedTime < sevenDays;
};
useEffect(() => {
// Check if already installed as PWA
if (window.matchMedia("(display-mode: standalone)").matches) {
@@ -20,13 +28,8 @@ export function PWAInstallPrompt() {
}
// Check if dismissed recently (within 7 days)
const dismissedAt = localStorage.getItem("pwa-prompt-dismissed");
if (dismissedAt) {
const dismissedTime = parseInt(dismissedAt, 10);
const sevenDays = 7 * 24 * 60 * 60 * 1000;
if (Date.now() - dismissedTime < sevenDays) {
return;
}
if (isDismissedRecently()) {
return;
}
// Check for iOS
@@ -38,14 +41,22 @@ export function PWAInstallPrompt() {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
// Show prompt after a short delay
setTimeout(() => setShowPrompt(true), 3000);
setTimeout(() => {
if (!isDismissedRecently()) {
setShowPrompt(true);
}
}, 3000);
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
// For iOS, show instructions after delay if on mobile
if (isIOSDevice) {
setTimeout(() => setShowPrompt(true), 5000);
setTimeout(() => {
if (!isDismissedRecently()) {
setShowPrompt(true);
}
}, 5000);
}
return () => {

View File

@@ -147,7 +147,10 @@ export function PodcastPlayer({
false
);
} catch (error) {
console.error("Failed to save podcast progress on pause:", error);
console.error(
"Failed to save podcast progress on pause:",
error
);
}
}
};
@@ -229,9 +232,7 @@ export function PodcastPlayer({
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const streamUrl = `${
process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3006"
}/podcasts/${podcastId}/episodes/${episode.id}/stream`;
const streamUrl = api.getPodcastEpisodeStreamUrl(podcastId, episode.id);
return (
<>

View File

@@ -12,6 +12,12 @@ interface ActiveDownload {
type: string;
status: string;
createdAt: string;
metadata?: {
statusText?: string;
currentSource?: "lidarr" | "soulseek";
lidarrAttempts?: number;
soulseekAttempts?: number;
};
}
export function ActiveDownloadsTab() {
@@ -31,15 +37,15 @@ export function ActiveDownloadsTab() {
};
const handleCancel = async (id: string) => {
setCancelling(prev => new Set(prev).add(id));
setCancelling((prev) => new Set(prev).add(id));
try {
await api.deleteDownload(id);
// Optimistically remove from list
setDownloads(prev => prev.filter(d => d.id !== id));
setDownloads((prev) => prev.filter((d) => d.id !== id));
} catch (error) {
console.error("Failed to cancel download:", error);
} finally {
setCancelling(prev => {
setCancelling((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
@@ -48,11 +54,11 @@ export function ActiveDownloadsTab() {
};
const handleCancelAll = async () => {
const ids = downloads.map(d => d.id);
const ids = downloads.map((d) => d.id);
setCancelling(new Set(ids));
try {
// Cancel all downloads in parallel
await Promise.all(ids.map(id => api.deleteDownload(id)));
await Promise.all(ids.map((id) => api.deleteDownload(id)));
setDownloads([]);
} catch (error) {
console.error("Failed to cancel all downloads:", error);
@@ -75,7 +81,7 @@ export function ActiveDownloadsTab() {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return "Just started";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
@@ -95,7 +101,9 @@ export function ActiveDownloadsTab() {
<div className="flex flex-col items-center justify-center py-12 text-center">
<Download className="w-8 h-8 text-white/20 mb-3" />
<p className="text-sm text-white/40">No active downloads</p>
<p className="text-xs text-white/30 mt-1">Downloads will appear here</p>
<p className="text-xs text-white/30 mt-1">
Downloads will appear here
</p>
</div>
);
}
@@ -141,14 +149,39 @@ export function ActiveDownloadsTab() {
<p className="text-sm font-medium text-white truncate">
{download.subject}
</p>
<div className="flex items-center gap-2 mt-1">
<span className={cn(
"text-xs font-medium capitalize",
download.status === "processing" ? "text-blue-400" : "text-yellow-400"
)}>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span
className={cn(
"text-xs font-medium capitalize",
download.status === "processing"
? "text-blue-400"
: "text-yellow-400"
)}
>
{download.status}
</span>
<span className="text-xs text-white/30"></span>
{download.metadata?.statusText && (
<>
<span className="text-xs text-white/30">
</span>
<span
className={cn(
"text-xs font-medium",
download.metadata
.currentSource ===
"lidarr"
? "text-purple-400"
: "text-teal-400"
)}
>
{download.metadata.statusText}
</span>
</>
)}
<span className="text-xs text-white/30">
</span>
<span className="text-xs text-white/30 capitalize flex items-center gap-1">
{download.type === "album" ? (
<Disc className="w-3 h-3" />
@@ -157,7 +190,9 @@ export function ActiveDownloadsTab() {
)}
{download.type}
</span>
<span className="text-xs text-white/30"></span>
<span className="text-xs text-white/30">
</span>
<span className="text-xs text-white/30">
{formatTime(download.createdAt)}
</span>

View File

@@ -36,9 +36,7 @@ export function NotificationsTab() {
} = useQuery<Notification[]>({
queryKey: ["notifications"],
queryFn: async () => {
console.log("[NotificationsTab] Fetching notifications...");
const result = await api.getNotifications();
console.log("[NotificationsTab] Got notifications:", result);
return result;
},
refetchInterval: 30000, // Poll every 30 seconds
@@ -58,9 +56,6 @@ export function NotificationsTab() {
notification.type === "playlist_ready" ||
notification.type === "import_complete"
) {
console.log(
"[NotificationsTab] New playlist notification, dispatching event"
);
window.dispatchEvent(new CustomEvent("playlist-created"));
}
}

View File

@@ -14,6 +14,7 @@ import { ActivityPanel } from "./ActivityPanel";
import { GalaxyBackground } from "../ui/GalaxyBackground";
import { GradientSpinner } from "../ui/GradientSpinner";
import { PWAInstallPrompt } from "../PWAInstallPrompt";
import { PullToRefresh } from "../ui/PullToRefresh";
import { ReactNode } from "react";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
import { useIsTV } from "@/lib/tv-utils";
@@ -84,6 +85,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
if (isTV) {
return (
<PlayerModeWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Skip to main content
</a>
<MediaControlsHandler />
<TVLayout>{children}</TVLayout>
</PlayerModeWrapper>
@@ -94,6 +101,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
if (isMobileOrTablet) {
return (
<PlayerModeWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Skip to main content
</a>
<div className="h-screen bg-black overflow-hidden flex flex-col">
<MediaControlsHandler />
<TopBar />
@@ -110,18 +123,22 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
/>
{/* Main content area with rounded corners */}
<main
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
style={{
marginTop: "58px",
marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}}
>
<GalaxyBackground />
{/* Padding at bottom for mini player floating above */}
<div className="pb-24">{children}</div>
</main>
<PullToRefresh>
<main
id="main-content"
tabIndex={-1}
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative focus:outline-none"
style={{
marginTop: "58px",
marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}}
>
<GalaxyBackground />
{/* Padding at bottom for mini player floating above */}
<div className="pb-24">{children}</div>
</main>
</PullToRefresh>
{/* Mini Player - fixed, positioned above bottom nav */}
<UniversalPlayer />
@@ -137,6 +154,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
// Desktop Layout
return (
<PlayerModeWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Skip to main content
</a>
<div
className="h-screen bg-black overflow-hidden flex flex-col"
style={{ paddingTop: "64px" }}
@@ -145,7 +168,11 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
<TopBar />
<div className="flex-1 flex gap-2 p-2 pt-0 overflow-hidden">
<Sidebar />
<main className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative">
<main
id="main-content"
tabIndex={-1}
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative focus:outline-none"
>
<GalaxyBackground />
{children}
</main>

View File

@@ -43,9 +43,11 @@ export function BottomNavigation() {
if (!isMobileOrTablet) return null;
return (
<nav
<nav
className="fixed bottom-0 left-0 right-0 z-40 bg-black border-t border-white/10"
style={{
role="navigation"
aria-label="Main navigation"
style={{
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
}}
>
@@ -60,10 +62,12 @@ export function BottomNavigation() {
href={item.href}
className={cn(
"flex flex-col items-center justify-center flex-1 h-full py-2 transition-colors",
isActive
? "text-white"
isActive
? "text-white"
: "text-gray-500 active:text-gray-300"
)}
aria-label={item.name}
aria-current={isActive ? "page" : undefined}
>
<Icon
className={cn(

View File

@@ -72,6 +72,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<div
className="fixed inset-0 bg-black/60 z-50 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Sidebar Drawer */}
@@ -109,7 +110,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
</div>
{/* Menu Content */}
<nav className="flex-1 overflow-y-auto py-4">
<nav className="flex-1 overflow-y-auto py-4" role="navigation" aria-label="Mobile menu">
{/* Quick Links Section */}
<div className="px-3 mb-6">
<div className="text-[10px] font-semibold text-gray-600 uppercase tracking-widest px-3 mb-2">
@@ -118,6 +119,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/discover"
aria-current={pathname === "/discover" ? "page" : undefined}
aria-label="Discover"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/discover"
@@ -133,6 +136,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/radio"
aria-current={pathname === "/radio" ? "page" : undefined}
aria-label="Radio"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/radio"
@@ -148,6 +153,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/releases"
aria-current={pathname === "/releases" ? "page" : undefined}
aria-label="Releases"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/releases"
@@ -191,6 +198,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/settings"
aria-current={pathname === "/settings" ? "page" : undefined}
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/settings"

View File

@@ -90,17 +90,9 @@ export function Sidebar() {
// Listen for playlist events to refresh playlists
const handlePlaylistEvent = async () => {
console.log(
"[Sidebar] Playlist event received, refreshing playlists..."
);
if (!isAuthenticated) return;
try {
const data = await api.getPlaylists();
console.log(
"[Sidebar] Playlists refreshed:",
data.length,
"playlists"
);
setPlaylists(data);
} catch (error) {
console.error("Failed to reload playlists:", error);
@@ -215,6 +207,7 @@ export function Sidebar() {
? "bg-[#1DB954] text-black"
: "bg-white/10 text-white hover:bg-white/15 active:scale-95"
)}
aria-label={isSyncing ? "Syncing library" : "Sync library"}
title={isSyncing ? "Syncing..." : "Sync Library"}
>
<RefreshCw
@@ -233,6 +226,7 @@ export function Sidebar() {
? "bg-white text-black"
: "bg-white/10 text-gray-400 hover:text-white hover:bg-white/15 active:scale-95"
)}
aria-label="Settings"
title="Settings"
>
<Settings className="w-4 h-4" />
@@ -247,6 +241,8 @@ export function Sidebar() {
"pt-6 space-y-1",
isMobileOrTablet ? "px-6" : "px-3"
)}
role="navigation"
aria-label="Main navigation"
>
{navigation.map((item) => {
const isActive = pathname === item.href;
@@ -257,6 +253,7 @@ export function Sidebar() {
key={item.name}
href={item.href}
prefetch={false}
aria-current={isActive ? "page" : undefined}
className={cn(
"block rounded-lg transition-all duration-200 group relative overflow-hidden",
isMobileOrTablet ? "px-4 py-3.5" : "px-4 py-3",
@@ -310,6 +307,7 @@ export function Sidebar() {
href="/playlists"
prefetch={false}
className="w-7 h-7 flex items-center justify-center rounded-md bg-white/5 text-gray-400 hover:text-white hover:bg-gradient-to-br hover:from-purple-500 hover:to-pink-500 hover:scale-110 transition-all duration-300 shadow-lg shadow-transparent hover:shadow-purple-500/30 border border-white/5 hover:border-transparent"
aria-label="Create playlist"
title="Create Playlist"
>
<Plus className="w-4 h-4" />

View File

@@ -9,6 +9,7 @@ import { useAudio } from "@/lib/audio-context";
import { api } from "@/lib/api";
import { DPAD_KEYS } from "@/lib/tv-utils";
import { useTVNavigation } from "@/hooks/useTVNavigation";
import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime";
import { RefreshCw, SkipBack, SkipForward, Shuffle, Repeat } from "lucide-react";
const tvNavigation = [
@@ -99,12 +100,8 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
: null;
}
// Format time helper
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// CRITICAL: Clamp currentTime to prevent display of invalid times
const clampedCurrentTime = clampTime(currentTime, duration);
// Sync library
const handleSync = async () => {
@@ -260,7 +257,11 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
{/* Time counter */}
<div className="tv-np-time">
{formatTime(currentTime)} / {formatTime(duration)}
{formatTime(clampedCurrentTime)} / {
playbackType === "podcast" || playbackType === "audiobook"
? formatTimeRemaining(Math.max(0, duration - clampedCurrentTime))
: formatTime(duration)
}
</div>
{/* Shuffle */}
@@ -309,7 +310,7 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
)}
{/* Content */}
<main ref={contentRef} className="tv-content">
<main id="main-content" tabIndex={-1} ref={contentRef} className="tv-content">
{children}
</main>
</>

View File

@@ -44,8 +44,11 @@ export function TopBar() {
// Track download status from context (single source of truth)
const { pendingDownloads, downloadStatus } = useDownloadContext();
const hasActiveDownloads =
downloadStatus.hasActiveDownloads || pendingDownloads.length > 0;
// Only use API-driven state for the icon
// pendingDownloads is optimistic local state that can become stale
const hasActiveDownloads = downloadStatus.hasActiveDownloads;
const hasPendingUploads = pendingDownloads.length > 0 &&
pendingDownloads.some(p => Date.now() - p.timestamp < 30000); // Only count recent pending
const hasFailedDownloads = downloadStatus.failedDownloads.length > 0;
const handleSync = async () => {
@@ -167,6 +170,7 @@ export function TopBar() {
? "bg-white text-black"
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
)}
aria-label="Home"
title="Home"
>
<Home className="w-5 h-5" />
@@ -186,6 +190,7 @@ export function TopBar() {
setSearchQuery(e.target.value)
}
placeholder="Search..."
aria-label="Search"
autoCapitalize="none"
autoCorrect="off"
tabIndex={0}
@@ -241,6 +246,7 @@ export function TopBar() {
? "bg-white text-black"
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white hover:scale-105"
)}
aria-label="Home"
title="Home"
>
<Home className="w-6 h-6" />
@@ -262,6 +268,7 @@ export function TopBar() {
setSearchQuery(e.target.value)
}
placeholder="What do you want to play?"
aria-label="Search"
autoCapitalize="none"
autoCorrect="off"
tabIndex={0}
@@ -276,6 +283,17 @@ export function TopBar() {
<button
onClick={handleSync}
disabled={isPolling}
aria-label={
isPolling
? "Library scan in progress"
: hasActiveDownloads
? `${downloadStatus.activeDownloads.length} download(s) in progress`
: hasPendingUploads
? `${pendingDownloads.length} download(s) starting`
: hasFailedDownloads
? `${downloadStatus.failedDownloads.length} download(s) failed`
: "Sync library"
}
className={cn(
"flex items-center gap-2 px-3 h-10 rounded-full transition-all text-sm font-medium",
isPolling
@@ -290,10 +308,9 @@ export function TopBar() {
isPolling
? "Library scan in progress..."
: hasActiveDownloads
? `${
downloadStatus.activeDownloads
.length + pendingDownloads.length
} download(s) in progress`
? `${downloadStatus.activeDownloads.length} download(s) in progress`
: hasPendingUploads
? `${pendingDownloads.length} download(s) starting...`
: hasFailedDownloads
? `${downloadStatus.failedDownloads.length} download(s) failed`
: "Sync Library"
@@ -316,6 +333,7 @@ export function TopBar() {
? "bg-white text-black"
: "text-white/60 hover:text-white"
)}
aria-label="Settings"
title="Settings"
>
<Settings className="w-5 h-5" />
@@ -323,6 +341,7 @@ export function TopBar() {
<button
onClick={handleLogout}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all text-red-400 hover:text-red-300"
aria-label="Logout"
title="Logout"
>
<Power className="w-5 h-5" />

View File

@@ -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>
);
}

View File

@@ -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:",

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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
)}
>

View File

@@ -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>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import React, { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* Global error boundary for catching application-level errors.
* Provides a fallback UI when critical errors occur in production.
*/
export class GlobalErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("[GlobalErrorBoundary] Application error:", error);
console.error(
"[GlobalErrorBoundary] Component stack:",
errorInfo.componentStack
);
}
private handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950 text-white p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold">
Something went wrong
</h1>
<p className="text-gray-400">
An unexpected error occurred. Please try
reloading the page.
</p>
</div>
{this.state.error && (
<div className="bg-gray-900 p-4 rounded-lg text-left">
<p className="text-sm font-mono text-red-400 break-words">
{this.state.error.message}
</p>
</div>
)}
<button
onClick={this.handleReload}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { useMediaQuery } from "@/hooks/useMediaQuery";
/**
* GalaxyBackground Component
@@ -21,6 +22,11 @@ interface GalaxyBackgroundProps {
}
export function GalaxyBackground({ primaryColor, secondaryColor }: GalaxyBackgroundProps = {}) {
// Performance optimization: disable animations on mobile and for reduced-motion preference
const isMobile = useMediaQuery("(max-width: 768px)");
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const shouldDisableAnimations = isMobile || prefersReducedMotion;
// Convert hex color to RGB values for opacity control
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
@@ -89,6 +95,9 @@ export function GalaxyBackground({ primaryColor, secondaryColor }: GalaxyBackgro
</>
)}
{/* Floating Star Particles - only render on desktop with motion enabled */}
{!shouldDisableAnimations && (
<>
{/* Floating Star Particles - more concentrated at bottom */}
{/* Bottom layer - most prominent */}
{particles.bottom.map((p, i) => (
@@ -171,6 +180,8 @@ export function GalaxyBackground({ primaryColor, secondaryColor }: GalaxyBackgro
}}
/>
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useRef, useCallback, ReactNode, TouchEvent } from "react";
import { GradientSpinner } from "./GradientSpinner";
import { RefreshCw } from "lucide-react";
interface PullToRefreshProps {
children: ReactNode;
threshold?: number;
}
export function PullToRefresh({
children,
threshold = 80,
}: PullToRefreshProps) {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0);
const isPulling = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleTouchStart = useCallback((e: TouchEvent) => {
// Only allow pull-to-refresh when scrolled to the top
const container = containerRef.current;
if (!container) return;
// Check if the main element inside is scrolled to top
const mainElement = container.querySelector("main");
if (mainElement && mainElement.scrollTop === 0) {
startY.current = e.touches[0].clientY;
isPulling.current = true;
}
}, []);
const handleTouchMove = useCallback(
(e: TouchEvent) => {
if (!isPulling.current || isRefreshing) return;
const currentY = e.touches[0].clientY;
const distance = currentY - startY.current;
// Only track downward pulls
if (distance > 0) {
// Apply resistance factor for smoother feel
const resistance = 0.5;
const adjustedDistance = distance * resistance;
setPullDistance(adjustedDistance);
// Prevent default scroll behavior when pulling
if (adjustedDistance > 10) {
e.preventDefault();
}
}
},
[isRefreshing]
);
const handleTouchEnd = useCallback(() => {
if (!isPulling.current) return;
isPulling.current = false;
// Check if we've pulled past the threshold
if (pullDistance >= threshold) {
setIsRefreshing(true);
setPullDistance(threshold); // Lock at threshold during refresh
// Trigger full page reload after a brief delay for visual feedback
setTimeout(() => {
window.location.reload();
}, 300);
} else {
// Reset if not past threshold
setPullDistance(0);
}
}, [pullDistance, threshold]);
// Calculate visual properties based on pull progress
const pullProgress = Math.min(pullDistance / threshold, 1);
const showIndicator = pullDistance > 0;
const shouldRelease = pullDistance >= threshold;
return (
<div
ref={containerRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="relative h-full"
style={{ touchAction: "pan-y" }}
>
{/* Pull-to-refresh indicator */}
{showIndicator && (
<div
className="absolute top-0 left-0 right-0 z-50 flex flex-col items-center justify-center pointer-events-none"
style={{
transform: `translateY(${Math.min(pullDistance, threshold + 20)}px)`,
opacity: pullProgress,
transition: isPulling.current
? "none"
: "transform 0.3s ease-out, opacity 0.3s ease-out",
willChange: "transform",
}}
>
<div className="bg-black/80 backdrop-blur-sm rounded-full p-3 shadow-lg border border-white/10">
{isRefreshing ? (
<GradientSpinner size="sm" />
) : (
<RefreshCw
className={`w-5 h-5 text-white transition-transform ${
shouldRelease ? "rotate-180" : ""
}`}
style={{
transform: `rotate(${pullDistance * 2}deg)`,
}}
/>
)}
</div>
<p className="text-white/80 text-xs mt-2 font-medium">
{isRefreshing
? "Refreshing..."
: shouldRelease
? "Release to refresh"
: "Pull to refresh"}
</p>
</div>
)}
{children}
</div>
);
}