Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Download,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { DownloadJob } from "@/hooks/useDownloadStatus";
|
||||
import { GradientSpinner } from "./ui/GradientSpinner";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useDownloadContext } from "@/lib/download-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
|
||||
export function DownloadNotifications() {
|
||||
const { downloadStatus } = useDownloadContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
// Handle drag start - only from header
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// Don't drag when clicking buttons or links
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button") || target.closest("a")) return;
|
||||
|
||||
// Prevent default to avoid text selection during drag
|
||||
e.preventDefault();
|
||||
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle drag move
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const newX = e.clientX - dragStart.x;
|
||||
const newY = e.clientY - dragStart.y;
|
||||
|
||||
// Get window and element dimensions
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const elementWidth = containerRef.current?.offsetWidth || 384;
|
||||
const elementHeight = containerRef.current?.offsetHeight || 400;
|
||||
|
||||
// Starting position in viewport (bottom-24 = 96px from bottom, right-4 = 16px from right)
|
||||
const startRight = 16; // right-4 in Tailwind
|
||||
const startBottom = 96; // bottom-24 in Tailwind
|
||||
|
||||
// Calculate bounds that allow dragging across entire viewport
|
||||
// Keep at least 80px of the header visible
|
||||
const minX = -(windowWidth - startRight - 80); // Can drag far left
|
||||
const maxX = windowWidth - startRight - 80; // Can drag far right
|
||||
const minY = -(windowHeight - startBottom - 80); // Can drag to top
|
||||
const maxY = windowHeight - startBottom - 80; // Can drag to bottom
|
||||
|
||||
const constrainedX = Math.max(minX, Math.min(newX, maxX));
|
||||
const constrainedY = Math.max(minY, Math.min(newY, maxY));
|
||||
|
||||
setPosition({ x: constrainedX, y: constrainedY });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStart, position]);
|
||||
|
||||
// Auto-open when there are active downloads or failures
|
||||
const shouldShow =
|
||||
downloadStatus.hasActiveDownloads ||
|
||||
downloadStatus.failedDownloads.length > 0 ||
|
||||
downloadStatus.recentDownloads.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
setIsOpen(true);
|
||||
setDismissed(false);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [shouldShow]);
|
||||
|
||||
const shouldRender = (shouldShow && !dismissed) || isOpen;
|
||||
if (!shouldRender) return null;
|
||||
|
||||
// Function to manually close modal (even if there are downloads)
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
// Function to clear completed/failed downloads
|
||||
const handleClearCompleted = async () => {
|
||||
try {
|
||||
const jobIds = [
|
||||
...downloadStatus.recentDownloads.map((j) => j.id),
|
||||
...downloadStatus.failedDownloads.map((j) => j.id),
|
||||
];
|
||||
|
||||
if (jobIds.length === 0) {
|
||||
setDismissed(true);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearing(true);
|
||||
await Promise.all(
|
||||
jobIds.map((id) =>
|
||||
api
|
||||
.deleteDownload(id)
|
||||
.catch((error) =>
|
||||
console.error(`Failed to delete job ${id}`, error)
|
||||
)
|
||||
)
|
||||
);
|
||||
setIsClearing(false);
|
||||
setDismissed(true);
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
setIsClearing(false);
|
||||
console.error("Failed to clear downloads:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const allJobs = [
|
||||
...downloadStatus.activeDownloads,
|
||||
...downloadStatus.recentDownloads,
|
||||
]
|
||||
.filter((job) => !deletedIds.has(job.id)) // Filter out optimistically deleted jobs
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
// Mobile: Compact floating pill at top
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed top-20 left-1/2 -translate-x-1/2 z-50 w-auto max-w-[90vw]"
|
||||
>
|
||||
<div className="bg-[#1a1a1a]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl overflow-hidden">
|
||||
{/* Compact Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-white/60" />
|
||||
<span className="text-xs font-semibold text-white">
|
||||
Downloads
|
||||
</span>
|
||||
{downloadStatus.hasActiveDownloads && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/20 text-green-400 rounded-full">
|
||||
{downloadStatus.activeDownloads.length}{" "}
|
||||
active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-white/40 hover:text-white transition-colors p-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compact Download List - max 3 visible */}
|
||||
<div className="max-h-40 overflow-y-auto border-t border-white/5">
|
||||
{allJobs.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-white/40 text-xs">
|
||||
No downloads
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
{allJobs.slice(0, 5).map((job) => (
|
||||
<DownloadJobItemCompact
|
||||
key={job.id}
|
||||
job={job}
|
||||
onDelete={(id) =>
|
||||
setDeletedIds((prev) =>
|
||||
new Set(prev).add(id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{allJobs.length > 5 && (
|
||||
<div className="px-3 py-2 text-center text-white/40 text-xs">
|
||||
+{allJobs.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Clear button */}
|
||||
{(downloadStatus.recentDownloads.length > 0 ||
|
||||
downloadStatus.failedDownloads.length > 0) && (
|
||||
<div className="px-3 py-2 border-t border-white/10 bg-black/40">
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
disabled={isClearing}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-all",
|
||||
isClearing
|
||||
? "text-white/30 cursor-not-allowed"
|
||||
: "text-white/60 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Clear completed
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: Full draggable panel
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed bottom-24 right-4 z-50 w-96 max-w-[calc(100vw-2rem)]"
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
transition: isDragging ? "none" : "transform 0.2s ease-out",
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-3 border-b border-white/10 bg-black/40",
|
||||
"cursor-move select-none",
|
||||
isDragging && "cursor-grabbing"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="w-4 h-4 text-white/40" />
|
||||
<Download className="w-4 h-4 text-white/60" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Downloads
|
||||
</h3>
|
||||
{downloadStatus.hasActiveDownloads && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/20 text-green-400 rounded-full">
|
||||
{downloadStatus.activeDownloads.length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent drag
|
||||
handleClose();
|
||||
}}
|
||||
className="text-white/40 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Download List */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{allJobs.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-white/40 text-sm">
|
||||
No recent downloads
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
{allJobs.map((job) => (
|
||||
<DownloadJobItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
onDelete={(id) =>
|
||||
setDeletedIds((prev) =>
|
||||
new Set(prev).add(id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Clear button */}
|
||||
{(downloadStatus.recentDownloads.length > 0 ||
|
||||
downloadStatus.failedDownloads.length > 0) && (
|
||||
<div className="px-4 py-2 border-t border-white/10 bg-black/40">
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
disabled={isClearing}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded transition-all",
|
||||
isClearing
|
||||
? "text-white/30 cursor-not-allowed"
|
||||
: "text-white/60 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{isClearing ? "Clearing..." : "Clear completed"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadJobItem({
|
||||
job,
|
||||
onDelete,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (job.status) {
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||
case "failed":
|
||||
return <XCircle className="w-4 h-4 text-red-400" />;
|
||||
case "processing":
|
||||
case "pending":
|
||||
return <GradientSpinner size="sm" />;
|
||||
default:
|
||||
return <Download className="w-4 h-4 text-white/40" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (job.status) {
|
||||
case "completed":
|
||||
return "text-green-400";
|
||||
case "failed":
|
||||
return "text-red-400";
|
||||
case "processing":
|
||||
return "text-blue-400";
|
||||
case "pending":
|
||||
return "text-yellow-400";
|
||||
default:
|
||||
return "text-white/40";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
// Optimistically remove from UI
|
||||
onDelete?.(job.id);
|
||||
// Then delete from backend
|
||||
await api.deleteDownload(job.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete download:", error);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show delete button for completed, failed, or stuck processing jobs
|
||||
const canDelete =
|
||||
job.status === "completed" ||
|
||||
job.status === "failed" ||
|
||||
job.status === "processing";
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 hover:bg-white/5 transition-colors group">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">{getStatusIcon()}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{job.subject}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium capitalize",
|
||||
getStatusColor()
|
||||
)}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
<span className="text-xs text-white/40">•</span>
|
||||
<span className="text-xs text-white/40 capitalize">
|
||||
{job.type}
|
||||
</span>
|
||||
</div>
|
||||
{job.error && (
|
||||
<p className="text-xs text-red-400/80 mt-1 line-clamp-2">
|
||||
{job.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-white/10 rounded",
|
||||
isDeleting && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
title="Delete"
|
||||
>
|
||||
<X className="w-4 h-4 text-white/60 hover:text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact version for mobile
|
||||
function DownloadJobItemCompact({
|
||||
job,
|
||||
onDelete,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const getStatusIcon = () => {
|
||||
switch (job.status) {
|
||||
case "completed":
|
||||
return <CheckCircle className="w-3 h-3 text-green-400" />;
|
||||
case "failed":
|
||||
return <XCircle className="w-3 h-3 text-red-400" />;
|
||||
case "processing":
|
||||
case "pending":
|
||||
return <GradientSpinner size="sm" />;
|
||||
default:
|
||||
return <Download className="w-3 h-3 text-white/40" />;
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Edit, X, Save } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { GradientSpinner } from "./ui/GradientSpinner";
|
||||
|
||||
interface MetadataEditorProps {
|
||||
type: "artist" | "album" | "track";
|
||||
id: string;
|
||||
currentData: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
bio?: string;
|
||||
genres?: string[];
|
||||
year?: number;
|
||||
mbid?: string;
|
||||
rgMbid?: string;
|
||||
coverUrl?: string;
|
||||
heroUrl?: string;
|
||||
};
|
||||
onSave?: (updatedData: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata Editor Component
|
||||
* Plex/Kavita-style metadata editor with pencil icon
|
||||
* Opens a modal for editing artist/album/track metadata
|
||||
*/
|
||||
export function MetadataEditor({
|
||||
type,
|
||||
id,
|
||||
currentData,
|
||||
onSave,
|
||||
}: MetadataEditorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [formData, setFormData] = useState(currentData);
|
||||
|
||||
const handleOpen = () => {
|
||||
setFormData(currentData);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setFormData(currentData);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Call API to update metadata
|
||||
let response;
|
||||
if (type === "artist") {
|
||||
response = await api.updateArtistMetadata(id, formData);
|
||||
} else if (type === "album") {
|
||||
response = await api.updateAlbumMetadata(id, formData);
|
||||
} else {
|
||||
response = await api.updateTrackMetadata(id, formData);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`${
|
||||
type === "artist"
|
||||
? "Artist"
|
||||
: type === "album"
|
||||
? "Album"
|
||||
: "Track"
|
||||
} metadata updated`
|
||||
);
|
||||
onSave?.(response);
|
||||
setIsOpen(false);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to update metadata:", error);
|
||||
toast.error(error.message || "Failed to update metadata");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pencil Icon Button */}
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="p-2 rounded-full bg-black/40 hover:bg-black/60 transition-all opacity-0 group-hover:opacity-100"
|
||||
title={`Edit ${type} metadata`}
|
||||
>
|
||||
<Edit className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[#121212] rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Edit{" "}
|
||||
{type === "artist"
|
||||
? "Artist"
|
||||
: type === "album"
|
||||
? "Album"
|
||||
: "Track"}{" "}
|
||||
Metadata
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-all"
|
||||
>
|
||||
<X className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* Name/Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-white mb-2">
|
||||
{type === "artist"
|
||||
? "Artist Name"
|
||||
: type === "album"
|
||||
? "Album Title"
|
||||
: "Track Title"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
formData.name || formData.title || ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
type === "artist"
|
||||
? "name"
|
||||
: "title",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio (Artist only) */}
|
||||
{type === "artist" && (
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-white mb-2">
|
||||
Biography
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.bio || ""}
|
||||
onChange={(e) =>
|
||||
handleChange("bio", e.target.value)
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Year (Album only) */}
|
||||
{type === "album" && (
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-white mb-2">
|
||||
Release Year
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.year || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"year",
|
||||
parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Genres */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-white mb-2">
|
||||
Genres
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
(comma-separated)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.genres?.join(", ") || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"genres",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MusicBrainz ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-white mb-2">
|
||||
MusicBrainz ID
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
(leave empty to auto-fetch)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
type === "artist"
|
||||
? formData.mbid || ""
|
||||
: type === "album"
|
||||
? formData.rgMbid || ""
|
||||
: formData.mbid || ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
type === "artist"
|
||||
? "mbid"
|
||||
: type === "album"
|
||||
? "rgMbid"
|
||||
: "mbid",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-white mb-2">
|
||||
{type === "artist"
|
||||
? "Artist Image URL"
|
||||
: "Cover Art URL"}
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
(leave empty to auto-fetch)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={
|
||||
type === "artist"
|
||||
? formData.heroUrl || ""
|
||||
: formData.coverUrl || ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
type === "artist"
|
||||
? "heroUrl"
|
||||
: "coverUrl",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
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"
|
||||
/>
|
||||
{/* Image Preview */}
|
||||
{(formData.heroUrl || formData.coverUrl) && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={
|
||||
formData.heroUrl ||
|
||||
formData.coverUrl
|
||||
}
|
||||
alt="Preview"
|
||||
className="w-32 h-32 object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Override Warning */}
|
||||
<div className="bg-yellow-600/10 border border-yellow-600/20 rounded p-4">
|
||||
<p className="text-sm text-yellow-400">
|
||||
<strong>Note:</strong> Manually edited
|
||||
metadata will not be overwritten by
|
||||
automatic enrichment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-white/10">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-6 py-2 rounded-full bg-white/10 hover:bg-white/20 text-white font-bold transition-all"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2 rounded-full bg-[#ecb200] hover:bg-[#d4a000] text-black font-bold transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<GradientSpinner size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Music } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { memo } from "react";
|
||||
|
||||
interface MixCardProps {
|
||||
mix: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
};
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const MixCard = memo(
|
||||
function MixCard({ mix, index }: MixCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/mix/${mix.id}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="p-3 rounded-md group cursor-pointer hover:bg-white/5 transition-colors">
|
||||
{/* Circular mosaic cover art */}
|
||||
<div className="aspect-square bg-[#282828] rounded-full mb-3 overflow-hidden relative shadow-lg">
|
||||
{mix.coverUrls.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-0 w-full h-full">
|
||||
{mix.coverUrls.slice(0, 4).map((url, idx) => {
|
||||
const proxiedUrl = api.getCoverArtUrl(url, 300);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative bg-[#282828]"
|
||||
>
|
||||
<Image
|
||||
src={proxiedUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="180px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fill remaining cells if less than 4 covers */}
|
||||
{Array.from({
|
||||
length: Math.max(0, 4 - mix.coverUrls.length),
|
||||
}).map((_, idx) => (
|
||||
<div
|
||||
key={`empty-${idx}`}
|
||||
className="relative bg-[#282828] flex items-center justify-center"
|
||||
>
|
||||
<Music className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music className="w-10 h-10 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{mix.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 line-clamp-2 mt-0.5">
|
||||
{mix.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.mix.id === nextProps.mix.id;
|
||||
}
|
||||
);
|
||||
|
||||
export { MixCard };
|
||||
@@ -0,0 +1,635 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { api, MoodPreset, MoodMixParams } from "@/lib/api";
|
||||
import { useAudioControls } from "@/lib/audio-controls-context";
|
||||
import { Track } from "@/lib/audio-state-context";
|
||||
import { Play, Loader2, AudioWaveform, Sliders, X, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface MoodMixerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MoodMixer({ isOpen, onClose }: MoodMixerProps) {
|
||||
const { playTracks } = useAudioControls();
|
||||
const [presets, setPresets] = useState<MoodPreset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState<string | null>(null);
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Custom sliders state - basic audio features
|
||||
const [customParams, setCustomParams] = useState<{
|
||||
valence: [number, number];
|
||||
energy: [number, number];
|
||||
danceability: [number, number];
|
||||
bpm: [number, number];
|
||||
}>({
|
||||
valence: [0, 100],
|
||||
energy: [0, 100],
|
||||
danceability: [0, 100],
|
||||
bpm: [60, 180],
|
||||
});
|
||||
|
||||
// ML mood sliders state (Advanced mode)
|
||||
const [mlMoods, setMlMoods] = useState<{
|
||||
moodHappy: [number, number];
|
||||
moodSad: [number, number];
|
||||
moodRelaxed: [number, number];
|
||||
moodAggressive: [number, number];
|
||||
moodParty: [number, number];
|
||||
moodAcoustic: [number, number];
|
||||
moodElectronic: [number, number];
|
||||
}>({
|
||||
moodHappy: [0, 100],
|
||||
moodSad: [0, 100],
|
||||
moodRelaxed: [0, 100],
|
||||
moodAggressive: [0, 100],
|
||||
moodParty: [0, 100],
|
||||
moodAcoustic: [0, 100],
|
||||
moodElectronic: [0, 100],
|
||||
});
|
||||
|
||||
// Handle visibility animation
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
loadPresets();
|
||||
} else {
|
||||
// Delay hiding to allow exit animation
|
||||
const timeout = setTimeout(() => setIsVisible(false), 200);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
const data = await api.getMoodPresets();
|
||||
setPresets(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load mood presets:", error);
|
||||
toast.error("Failed to load mood presets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMix = async (preset: MoodPreset) => {
|
||||
setGenerating(preset.id);
|
||||
try {
|
||||
const mix = await api.generateMoodMix({
|
||||
...preset.params,
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
if (mix.tracks && mix.tracks.length > 0) {
|
||||
const tracks: Track[] = mix.tracks.map((t: any) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: {
|
||||
name: t.album?.artist?.name || "Unknown Artist",
|
||||
id: t.album?.artist?.id,
|
||||
},
|
||||
album: {
|
||||
title: t.album?.title || "Unknown Album",
|
||||
coverArt: t.album?.coverUrl,
|
||||
id: t.albumId,
|
||||
},
|
||||
duration: t.duration,
|
||||
}));
|
||||
|
||||
playTracks(tracks, 0);
|
||||
toast.success(`Your ${preset.name} Mix`, {
|
||||
description: `Playing ${tracks.length} tracks`,
|
||||
});
|
||||
|
||||
// Save these params as user's mood mix preferences (include preset name for mix title)
|
||||
try {
|
||||
await api.post('/mixes/mood/save-preferences', {
|
||||
...preset.params,
|
||||
limit: 15,
|
||||
presetName: preset.name
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to save mood preferences:", err);
|
||||
}
|
||||
|
||||
// Notify other components to refresh mixes
|
||||
window.dispatchEvent(new CustomEvent("mix-generated"));
|
||||
window.dispatchEvent(new CustomEvent("mixes-updated"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error("Not enough matching tracks", {
|
||||
description:
|
||||
"Try a different mood or wait for more analysis",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to generate mood mix:", error);
|
||||
toast.error(error?.error || "Failed to generate mix");
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const generateCustomMix = async () => {
|
||||
setGenerating("custom");
|
||||
try {
|
||||
const params: MoodMixParams = {
|
||||
valence: {
|
||||
min: customParams.valence[0] / 100,
|
||||
max: customParams.valence[1] / 100,
|
||||
},
|
||||
energy: {
|
||||
min: customParams.energy[0] / 100,
|
||||
max: customParams.energy[1] / 100,
|
||||
},
|
||||
danceability: {
|
||||
min: customParams.danceability[0] / 100,
|
||||
max: customParams.danceability[1] / 100,
|
||||
},
|
||||
bpm: { min: customParams.bpm[0], max: customParams.bpm[1] },
|
||||
limit: 15,
|
||||
};
|
||||
|
||||
// Add ML mood params if advanced mode is enabled
|
||||
if (showAdvanced) {
|
||||
params.moodHappy = {
|
||||
min: mlMoods.moodHappy[0] / 100,
|
||||
max: mlMoods.moodHappy[1] / 100,
|
||||
};
|
||||
params.moodSad = {
|
||||
min: mlMoods.moodSad[0] / 100,
|
||||
max: mlMoods.moodSad[1] / 100,
|
||||
};
|
||||
params.moodRelaxed = {
|
||||
min: mlMoods.moodRelaxed[0] / 100,
|
||||
max: mlMoods.moodRelaxed[1] / 100,
|
||||
};
|
||||
params.moodAggressive = {
|
||||
min: mlMoods.moodAggressive[0] / 100,
|
||||
max: mlMoods.moodAggressive[1] / 100,
|
||||
};
|
||||
params.moodParty = {
|
||||
min: mlMoods.moodParty[0] / 100,
|
||||
max: mlMoods.moodParty[1] / 100,
|
||||
};
|
||||
params.moodAcoustic = {
|
||||
min: mlMoods.moodAcoustic[0] / 100,
|
||||
max: mlMoods.moodAcoustic[1] / 100,
|
||||
};
|
||||
params.moodElectronic = {
|
||||
min: mlMoods.moodElectronic[0] / 100,
|
||||
max: mlMoods.moodElectronic[1] / 100,
|
||||
};
|
||||
}
|
||||
|
||||
const mix = await api.generateMoodMix(params);
|
||||
|
||||
if (mix.tracks && mix.tracks.length > 0) {
|
||||
const tracks: Track[] = mix.tracks.map((t: any) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: {
|
||||
name: t.album?.artist?.name || "Unknown Artist",
|
||||
id: t.album?.artist?.id,
|
||||
},
|
||||
album: {
|
||||
title: t.album?.title || "Unknown Album",
|
||||
coverArt: t.album?.coverUrl,
|
||||
id: t.albumId,
|
||||
},
|
||||
duration: t.duration,
|
||||
}));
|
||||
|
||||
playTracks(tracks, 0);
|
||||
toast.success("Your Custom Mix", {
|
||||
description: `Playing ${tracks.length} tracks`,
|
||||
});
|
||||
|
||||
// Save these params as user's mood mix preferences
|
||||
try {
|
||||
await api.post('/mixes/mood/save-preferences', {
|
||||
...params,
|
||||
presetName: "Custom"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to save mood preferences:", err);
|
||||
}
|
||||
|
||||
// Notify other components to refresh mixes
|
||||
window.dispatchEvent(new CustomEvent("mix-generated"));
|
||||
window.dispatchEvent(new CustomEvent("mixes-updated"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error("Not enough matching tracks", {
|
||||
description: "Try widening your parameters",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to generate custom mix:", error);
|
||||
toast.error(error?.error || "Failed to generate mix");
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVisible && !isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 transition-opacity duration-200 ${
|
||||
isOpen ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`bg-gradient-to-b from-[#1a1a1a] to-[#0a0a0a] rounded-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden border border-white/10 shadow-2xl transition-all duration-200 ${
|
||||
isOpen ? "scale-100 opacity-100" : "scale-95 opacity-0"
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#ecb200] to-amber-600 flex items-center justify-center">
|
||||
<AudioWaveform className="w-5 h-5 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Mood Mixer
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Generate a mix based on your vibe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(85vh-100px)]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#ecb200]" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Toggle between presets and custom */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setShowCustom(false)}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg font-medium text-sm transition-all ${
|
||||
!showCustom
|
||||
? "bg-[#ecb200] text-black"
|
||||
: "bg-white/5 text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<AudioWaveform className="w-4 h-4 inline-block mr-2" />
|
||||
Quick Moods
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCustom(true)}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg font-medium text-sm transition-all ${
|
||||
showCustom
|
||||
? "bg-[#ecb200] text-black"
|
||||
: "bg-white/5 text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<Sliders className="w-4 h-4 inline-block mr-2" />
|
||||
Custom Mix
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showCustom ? (
|
||||
/* Preset Grid */
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => generateMix(preset)}
|
||||
disabled={generating !== null}
|
||||
className={`
|
||||
relative group p-4 rounded-xl overflow-hidden
|
||||
bg-gradient-to-br ${preset.color}
|
||||
border border-white/10 hover:border-white/20
|
||||
transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-left
|
||||
`}
|
||||
>
|
||||
<div className="relative z-10 flex flex-col justify-end h-full">
|
||||
<h3 className="font-semibold text-white text-sm">
|
||||
{preset.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Play overlay */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
{generating === preset.id ? (
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-[#ecb200] flex items-center justify-center shadow-lg">
|
||||
<Play
|
||||
className="w-6 h-6 text-black ml-0.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Custom Sliders */
|
||||
<div className="space-y-6">
|
||||
<SliderControl
|
||||
label="Happiness"
|
||||
value={customParams.valence}
|
||||
onChange={(v) =>
|
||||
setCustomParams((p) => ({
|
||||
...p,
|
||||
valence: v,
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Sad"
|
||||
highLabel="Happy"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Energy"
|
||||
value={customParams.energy}
|
||||
onChange={(v) =>
|
||||
setCustomParams((p) => ({
|
||||
...p,
|
||||
energy: v,
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Calm"
|
||||
highLabel="Energetic"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Danceability"
|
||||
value={customParams.danceability}
|
||||
onChange={(v) =>
|
||||
setCustomParams((p) => ({
|
||||
...p,
|
||||
danceability: v,
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Static"
|
||||
highLabel="Groovy"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Tempo (BPM)"
|
||||
value={customParams.bpm}
|
||||
onChange={(v) =>
|
||||
setCustomParams((p) => ({
|
||||
...p,
|
||||
bpm: v,
|
||||
}))
|
||||
}
|
||||
min={60}
|
||||
max={180}
|
||||
lowLabel="Slow"
|
||||
highLabel="Fast"
|
||||
showValues
|
||||
/>
|
||||
|
||||
{/* Advanced Mode Toggle */}
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full py-2 px-3 rounded-lg bg-white/5 hover:bg-white/10 transition-colors flex items-center justify-between text-sm text-white/70"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4" />
|
||||
ML Mood Controls
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* ML Mood Sliders (Advanced Mode) */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 p-4 bg-white/5 rounded-lg border border-white/10">
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Fine-tune using ML-detected mood predictions
|
||||
</p>
|
||||
<SliderControl
|
||||
label="Happy"
|
||||
value={mlMoods.moodHappy}
|
||||
onChange={(v) =>
|
||||
setMlMoods((p) => ({ ...p, moodHappy: v }))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Low"
|
||||
highLabel="High"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Sad"
|
||||
value={mlMoods.moodSad}
|
||||
onChange={(v) =>
|
||||
setMlMoods((p) => ({ ...p, moodSad: v }))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Low"
|
||||
highLabel="High"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Relaxed"
|
||||
value={mlMoods.moodRelaxed}
|
||||
onChange={(v) =>
|
||||
setMlMoods((p) => ({ ...p, moodRelaxed: v }))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Low"
|
||||
highLabel="High"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Aggressive"
|
||||
value={mlMoods.moodAggressive}
|
||||
onChange={(v) =>
|
||||
setMlMoods((p) => ({ ...p, moodAggressive: v }))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Low"
|
||||
highLabel="High"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Party"
|
||||
value={mlMoods.moodParty}
|
||||
onChange={(v) =>
|
||||
setMlMoods((p) => ({ ...p, moodParty: v }))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Low"
|
||||
highLabel="High"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Acoustic"
|
||||
value={mlMoods.moodAcoustic}
|
||||
onChange={(v) =>
|
||||
setMlMoods((p) => ({ ...p, moodAcoustic: v }))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Low"
|
||||
highLabel="High"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Electronic"
|
||||
value={mlMoods.moodElectronic}
|
||||
onChange={(v) =>
|
||||
setMlMoods((p) => ({ ...p, moodElectronic: v }))
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
lowLabel="Low"
|
||||
highLabel="High"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={generateCustomMix}
|
||||
disabled={generating !== null}
|
||||
className="w-full py-3 px-4 rounded-lg bg-[#ecb200] text-black font-semibold hover:bg-[#d4a000] transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating === "custom" ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Play
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
Generate Mix
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SliderControlProps {
|
||||
label: string;
|
||||
value: [number, number];
|
||||
onChange: (value: [number, number]) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
lowLabel: string;
|
||||
highLabel: string;
|
||||
showValues?: boolean;
|
||||
}
|
||||
|
||||
function SliderControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
lowLabel,
|
||||
highLabel,
|
||||
showValues,
|
||||
}: SliderControlProps) {
|
||||
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newMin = Math.min(Number(e.target.value), value[1] - 1);
|
||||
onChange([newMin, value[1]]);
|
||||
};
|
||||
|
||||
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newMax = Math.max(Number(e.target.value), value[0] + 1);
|
||||
onChange([value[0], newMax]);
|
||||
};
|
||||
|
||||
const percentage = ((value[0] - min) / (max - min)) * 100;
|
||||
const width = ((value[1] - value[0]) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{label}</span>
|
||||
{showValues && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{value[0]} - {value[1]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative h-2">
|
||||
{/* Track background - pointer-events-none so inputs receive clicks */}
|
||||
<div className="absolute inset-0 bg-white/10 rounded-full pointer-events-none" />
|
||||
|
||||
{/* Active range - pointer-events-none so inputs receive clicks */}
|
||||
<div
|
||||
className="absolute h-full bg-gradient-to-r from-[#ecb200] to-amber-500 rounded-full pointer-events-none"
|
||||
style={{ left: `${percentage}%`, width: `${width}%` }}
|
||||
/>
|
||||
|
||||
{/* Min slider */}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value[0]}
|
||||
onChange={handleMinChange}
|
||||
className="absolute inset-0 w-full opacity-0 cursor-pointer z-10"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
/>
|
||||
|
||||
{/* Max slider */}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value[1]}
|
||||
onChange={handleMaxChange}
|
||||
className="absolute inset-0 w-full opacity-0 cursor-pointer z-20"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
/>
|
||||
|
||||
{/* Thumb indicators */}
|
||||
<div
|
||||
className="absolute w-4 h-4 bg-white rounded-full shadow-lg transform -translate-y-1/4 pointer-events-none border-2 border-[#ecb200]"
|
||||
style={{ left: `calc(${percentage}% - 8px)` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-4 h-4 bg-white rounded-full shadow-lg transform -translate-y-1/4 pointer-events-none border-2 border-[#ecb200]"
|
||||
style={{ left: `calc(${percentage + width}% - 8px)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{lowLabel}</span>
|
||||
<span>{highLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Download, Smartphone } from "lucide-react";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already installed as PWA
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for iOS
|
||||
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
|
||||
setIsIOS(isIOSDevice);
|
||||
|
||||
// Listen for beforeinstallprompt (Chrome, Edge, etc.)
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
// Show prompt after a short delay
|
||||
setTimeout(() => setShowPrompt(true), 3000);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
|
||||
// For iOS, show instructions after delay if on mobile
|
||||
if (isIOSDevice) {
|
||||
setTimeout(() => setShowPrompt(true), 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
setShowPrompt(false);
|
||||
}
|
||||
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
localStorage.setItem("pwa-prompt-dismissed", Date.now().toString());
|
||||
};
|
||||
|
||||
if (!showPrompt) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 left-4 right-4 md:left-auto md:right-4 md:w-80 z-50 animate-slide-up">
|
||||
<div className="bg-[#1a1a1a] border border-[#333] rounded-xl p-4 shadow-2xl">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-1 text-white/50 hover:text-white/80 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-[#ecb200]/20 rounded-lg">
|
||||
<Smartphone className="w-6 h-6 text-[#ecb200]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-semibold text-sm mb-1">
|
||||
Install Lidify
|
||||
</h3>
|
||||
{isIOS ? (
|
||||
<p className="text-white/60 text-xs leading-relaxed">
|
||||
Tap the <span className="text-white">Share</span> button, then{" "}
|
||||
<span className="text-white">"Add to Home Screen"</span> for the best experience.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-white/60 text-xs leading-relaxed">
|
||||
Add Lidify to your home screen for quick access and background audio.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isIOS && deferredPrompt && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="w-full mt-3 py-2 px-4 bg-[#ecb200] text-black font-semibold text-sm rounded-lg hover:bg-[#ffc933] transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Install App
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/lib/toast-context";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipForward,
|
||||
SkipBack,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
|
||||
interface Episode {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
duration: number;
|
||||
publishedAt: string;
|
||||
episodeNumber?: number;
|
||||
season?: number;
|
||||
progress?: {
|
||||
currentTime: number;
|
||||
progress: number;
|
||||
isFinished: boolean;
|
||||
lastPlayedAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface PodcastPlayerProps {
|
||||
podcastId: string;
|
||||
episode: Episode;
|
||||
onClose: () => void;
|
||||
onEpisodeChange?: (episode: Episode) => void;
|
||||
}
|
||||
|
||||
export function PodcastPlayer({
|
||||
podcastId,
|
||||
episode,
|
||||
onClose,
|
||||
onEpisodeChange,
|
||||
}: PodcastPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(
|
||||
episode.progress?.currentTime || 0
|
||||
);
|
||||
const [duration, setDuration] = useState(episode.duration || 0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isRemovingProgress, setIsRemovingProgress] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Resume from last position
|
||||
useEffect(() => {
|
||||
if (audioRef.current && episode.progress?.currentTime) {
|
||||
audioRef.current.currentTime = episode.progress.currentTime;
|
||||
setCurrentTime(episode.progress.currentTime);
|
||||
}
|
||||
}, [episode]);
|
||||
|
||||
// Save progress periodically while playing
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !audioRef.current) {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
progressIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save progress every 10 seconds
|
||||
progressIntervalRef.current = setInterval(async () => {
|
||||
if (audioRef.current && currentTime > 0) {
|
||||
const duration =
|
||||
audioRef.current.duration || episode.duration || 0;
|
||||
const isFinished = duration - currentTime < 30;
|
||||
|
||||
try {
|
||||
await api.updatePodcastEpisodeProgress(
|
||||
podcastId,
|
||||
episode.id,
|
||||
currentTime,
|
||||
duration,
|
||||
isFinished
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync podcast progress:", error);
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, currentTime, podcastId, episode.id, episode.duration]);
|
||||
|
||||
// Track time updates
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current) {
|
||||
setCurrentTime(audioRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Track duration
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
// Play/pause handler
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
// Save on pause
|
||||
const handlePause = async () => {
|
||||
setIsPlaying(false);
|
||||
if (audioRef.current) {
|
||||
try {
|
||||
await api.updatePodcastEpisodeProgress(
|
||||
podcastId,
|
||||
episode.id,
|
||||
audioRef.current.currentTime,
|
||||
audioRef.current.duration || duration,
|
||||
false
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save podcast progress on pause:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save when finished
|
||||
const handleEnded = async () => {
|
||||
setIsPlaying(false);
|
||||
if (audioRef.current) {
|
||||
try {
|
||||
await api.updatePodcastEpisodeProgress(
|
||||
podcastId,
|
||||
episode.id,
|
||||
audioRef.current.duration,
|
||||
audioRef.current.duration,
|
||||
true
|
||||
);
|
||||
toast.success("Episode finished!");
|
||||
onEpisodeChange?.(episode);
|
||||
} catch (error) {
|
||||
console.error("Failed to save podcast progress on end:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = (seconds: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime += seconds;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (value: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = value;
|
||||
setCurrentTime(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (value: number) => {
|
||||
setVolume(value);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = value;
|
||||
}
|
||||
setIsMuted(value === 0);
|
||||
};
|
||||
|
||||
const handleMuteToggle = () => {
|
||||
if (isMuted) {
|
||||
handleVolumeChange(volume || 0.5);
|
||||
} else {
|
||||
handleVolumeChange(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeedChange = (speed: number) => {
|
||||
setPlaybackSpeed(speed);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = speed;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProgress = async () => {
|
||||
setShowConfirmModal(false);
|
||||
setIsRemovingProgress(true);
|
||||
|
||||
try {
|
||||
await api.deletePodcastEpisodeProgress(podcastId, episode.id);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
}
|
||||
toast.success("Progress removed");
|
||||
onEpisodeChange?.(episode);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove progress:", error);
|
||||
toast.error("Failed to remove progress");
|
||||
} finally {
|
||||
setIsRemovingProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
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`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-t border-[#262626] shadow-2xl z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-6 py-4">
|
||||
{/* Audio Element */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={streamUrl}
|
||||
preload="metadata"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={handlePause}
|
||||
onEnded={handleEnded}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Top Row: Episode Info and Close Button */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-white truncate text-sm">
|
||||
{episode.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatTime(currentTime)} /{" "}
|
||||
{formatTime(duration)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{episode.progress && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowConfirmModal(true)
|
||||
}
|
||||
disabled={isRemovingProgress}
|
||||
className="text-gray-400 hover:text-red-400 transition-colors p-2"
|
||||
title="Reset progress"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors p-2"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
const rect =
|
||||
e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const percentage = clickX / rect.width;
|
||||
handleSeek(percentage * duration);
|
||||
}}
|
||||
className="relative h-1.5 bg-gray-800 rounded-full cursor-pointer group"
|
||||
>
|
||||
{/* Played portion */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-gray-700 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
{/* Current position */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-purple-500 rounded-full transition-all group-hover:bg-purple-400"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
{/* Playhead */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ left: `calc(${progress}% - 6px)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left: Playback controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 15s Rewind */}
|
||||
<button
|
||||
onClick={() => handleSkip(-15)}
|
||||
className="text-gray-400 hover:text-white transition-colors p-2"
|
||||
title="Rewind 15 seconds"
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
<span className="text-xs">15</span>
|
||||
</button>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="bg-purple-500 hover:bg-purple-600 text-white rounded-full p-2.5 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 fill-current" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 15s Forward */}
|
||||
<button
|
||||
onClick={() => handleSkip(15)}
|
||||
className="text-gray-400 hover:text-white transition-colors p-2"
|
||||
title="Forward 15 seconds"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
<span className="text-xs">15</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Playback speed */}
|
||||
<div className="flex items-center gap-1">
|
||||
{[0.5, 0.75, 1, 1.25, 1.5, 1.75, 2].map(
|
||||
(speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() =>
|
||||
handleSpeedChange(speed)
|
||||
}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded transition-colors",
|
||||
playbackSpeed === speed
|
||||
? "bg-purple-500 text-white"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Volume control */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleMuteToggle}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="w-5 h-5" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
handleVolumeChange(
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-20 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Progress Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
title="Remove Progress"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRemoveProgress}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Remove Progress
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-gray-300">
|
||||
Remove your progress for this episode? This will reset your
|
||||
position to the beginning.
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/lib/toast-context";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface RemoveProgressButtonProps {
|
||||
audiobookId: string;
|
||||
onProgressRemoved?: () => void;
|
||||
}
|
||||
|
||||
export function RemoveProgressButton({
|
||||
audiobookId,
|
||||
onProgressRemoved,
|
||||
}: RemoveProgressButtonProps) {
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleRemoveProgress = async () => {
|
||||
setShowConfirmModal(false);
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await api.deleteAudiobookProgress(audiobookId);
|
||||
toast.success("Progress removed");
|
||||
onProgressRemoved?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to remove progress:", error);
|
||||
toast.error("Failed to remove progress");
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
disabled={isRemoving}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:text-red-400 transition-colors disabled:opacity-50 rounded-lg hover:bg-white/5"
|
||||
title="Start over from the beginning"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>{isRemoving ? "Removing..." : "Start Over"}</span>
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
isOpen={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
title="Remove Progress"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRemoveProgress}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Remove Progress
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-gray-300">
|
||||
Remove your progress for this audiobook? This will reset your position to the beginning.
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
|
||||
// Register service worker
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("[SW] Service Worker registered:", registration.scope);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[SW] Service Worker registration failed:", error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Download, Loader2, Music, Disc, X, Trash2 } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { GradientSpinner } from "../ui/GradientSpinner";
|
||||
|
||||
interface ActiveDownload {
|
||||
id: string;
|
||||
subject: string;
|
||||
type: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function ActiveDownloadsTab() {
|
||||
const [downloads, setDownloads] = useState<ActiveDownload[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchDownloads = async () => {
|
||||
try {
|
||||
const data = await api.getActiveDownloads();
|
||||
setDownloads(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active downloads:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
setCancelling(prev => new Set(prev).add(id));
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
// Optimistically remove from list
|
||||
setDownloads(prev => prev.filter(d => d.id !== id));
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel download:", error);
|
||||
} finally {
|
||||
setCancelling(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAll = async () => {
|
||||
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)));
|
||||
setDownloads([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel all downloads:", error);
|
||||
// Refresh to get actual state
|
||||
fetchDownloads();
|
||||
} finally {
|
||||
setCancelling(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDownloads();
|
||||
|
||||
// Poll for updates every 5 seconds
|
||||
const interval = setInterval(fetchDownloads, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
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`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-white/20 border-t-white/60 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (downloads.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
|
||||
<span className="text-xs text-white/40">
|
||||
{downloads.length} downloading
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCancelAll}
|
||||
className="text-xs text-white/40 hover:text-red-400 transition-colors"
|
||||
title="Cancel all downloads"
|
||||
>
|
||||
Cancel all
|
||||
</button>
|
||||
<span className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{downloads.map((download) => (
|
||||
<div
|
||||
key={download.id}
|
||||
className="px-3 py-3 border-b border-white/5 hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{cancelling.has(download.id) ? (
|
||||
<Loader2 className="w-4 h-4 text-white/40 animate-spin" />
|
||||
) : (
|
||||
<GradientSpinner size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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"
|
||||
)}>
|
||||
{download.status}
|
||||
</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" />
|
||||
) : (
|
||||
<Music className="w-3 h-3" />
|
||||
)}
|
||||
{download.type}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">•</span>
|
||||
<span className="text-xs text-white/30">
|
||||
{formatTime(download.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCancel(download.id)}
|
||||
disabled={cancelling.has(download.id)}
|
||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-white/10 rounded transition-all shrink-0"
|
||||
title="Cancel download"
|
||||
>
|
||||
<X className="w-4 h-4 text-white/40 hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle, XCircle, Trash2, RotateCcw, History, Disc, Music } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
interface DownloadHistory {
|
||||
id: string;
|
||||
subject: string;
|
||||
type: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export function HistoryTab() {
|
||||
const [history, setHistory] = useState<DownloadHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retrying, setRetrying] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const data = await api.getDownloadHistory();
|
||||
setHistory(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch download history:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
|
||||
// Refresh on window focus
|
||||
const handleFocus = () => fetchHistory();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
}, []);
|
||||
|
||||
const handleClear = async (id: string) => {
|
||||
try {
|
||||
await api.clearDownloadFromHistory(id);
|
||||
setHistory((prev) => prev.filter((h) => h.id !== id));
|
||||
// Notify other components that download status has changed
|
||||
window.dispatchEvent(new CustomEvent("download-status-changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear download:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await api.clearAllDownloadHistory();
|
||||
setHistory([]);
|
||||
// Notify other components that download status has changed
|
||||
window.dispatchEvent(new CustomEvent("download-status-changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear all history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (id: string) => {
|
||||
try {
|
||||
setRetrying((prev) => new Set(prev).add(id));
|
||||
const result = await api.retryFailedDownload(id);
|
||||
if (result.success) {
|
||||
// Remove from history (it's now in active)
|
||||
setHistory((prev) => prev.filter((h) => h.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to retry download:", error);
|
||||
} finally {
|
||||
setRetrying((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return "Just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const completed = history.filter((h) => h.status === "completed");
|
||||
const failed = history.filter((h) => h.status === "failed" || h.status === "exhausted");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-white/20 border-t-white/60 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<History className="w-8 h-8 text-white/20 mb-3" />
|
||||
<p className="text-sm text-white/40">No download history</p>
|
||||
<p className="text-xs text-white/30 mt-1">Completed downloads will appear here</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with clear all */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
|
||||
<div className="flex items-center gap-3 text-xs text-white/40">
|
||||
{completed.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
{completed.length}
|
||||
</span>
|
||||
)}
|
||||
{failed.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3 text-red-400" />
|
||||
{failed.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="text-xs text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* History list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Failed section first */}
|
||||
{failed.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 bg-red-500/10 border-b border-red-500/20">
|
||||
<span className="text-xs font-medium text-red-400">Failed ({failed.length})</span>
|
||||
</div>
|
||||
{failed.map((item) => (
|
||||
<HistoryItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClear={handleClear}
|
||||
onRetry={handleRetry}
|
||||
isRetrying={retrying.has(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed section */}
|
||||
{completed.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 bg-green-500/10 border-b border-green-500/20">
|
||||
<span className="text-xs font-medium text-green-400">Completed ({completed.length})</span>
|
||||
</div>
|
||||
{completed.map((item) => (
|
||||
<HistoryItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryItem({
|
||||
item,
|
||||
onClear,
|
||||
onRetry,
|
||||
isRetrying,
|
||||
}: {
|
||||
item: DownloadHistory;
|
||||
onClear: (id: string) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
isRetrying?: boolean;
|
||||
}) {
|
||||
const isCompleted = item.status === "completed";
|
||||
const isFailed = item.status === "failed" || item.status === "exhausted";
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return "Just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-3 border-b border-white/5 hover:bg-white/5 transition-colors group">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{item.subject}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-white/30 capitalize flex items-center gap-1">
|
||||
{item.type === "album" ? (
|
||||
<Disc className="w-3 h-3" />
|
||||
) : (
|
||||
<Music className="w-3 h-3" />
|
||||
)}
|
||||
{item.type}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">•</span>
|
||||
<span className="text-xs text-white/30">
|
||||
{formatTime(item.completedAt || item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{item.error && (
|
||||
<p className="text-xs text-red-400/70 mt-1 line-clamp-2">
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isFailed && onRetry && (
|
||||
<button
|
||||
onClick={() => onRetry(item.id)}
|
||||
disabled={isRetrying}
|
||||
className={cn(
|
||||
"p-1 hover:bg-white/10 rounded transition-colors",
|
||||
isRetrying && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
title="Retry download"
|
||||
>
|
||||
<RotateCcw className={cn(
|
||||
"w-3.5 h-3.5 text-white/40 hover:text-[#ecb200]",
|
||||
isRetrying && "animate-spin"
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onClear(item.id)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
title="Remove from history"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-white/40 hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
Trash2,
|
||||
ListMusic,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/utils/cn";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string | null;
|
||||
metadata: any;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function NotificationsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const previousNotificationIds = useRef<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
data: notifications = [],
|
||||
isLoading: loading,
|
||||
error,
|
||||
} = 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
|
||||
});
|
||||
|
||||
// Dispatch events when new playlist-related notifications arrive
|
||||
useEffect(() => {
|
||||
if (!notifications || notifications.length === 0) return;
|
||||
|
||||
const currentIds = new Set(notifications.map((n) => n.id));
|
||||
|
||||
// Check for new playlist-related notifications
|
||||
for (const notification of notifications) {
|
||||
if (!previousNotificationIds.current.has(notification.id)) {
|
||||
// This is a new notification
|
||||
if (
|
||||
notification.type === "playlist_ready" ||
|
||||
notification.type === "import_complete"
|
||||
) {
|
||||
console.log(
|
||||
"[NotificationsTab] New playlist notification, dispatching event"
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("playlist-created"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousNotificationIds.current = currentIds;
|
||||
}, [notifications]);
|
||||
|
||||
// Log error if any
|
||||
if (error) {
|
||||
console.error(
|
||||
"[NotificationsTab] Error fetching notifications:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as read - optimistic update
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: (id: string) => api.markNotificationAsRead(id),
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["notifications"] });
|
||||
|
||||
const previousNotifications = queryClient.getQueryData<
|
||||
Notification[]
|
||||
>(["notifications"]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<Notification[]>(
|
||||
["notifications"],
|
||||
(old) =>
|
||||
old?.map((n) => (n.id === id ? { ...n, read: true } : n)) ||
|
||||
[]
|
||||
);
|
||||
|
||||
return { previousNotifications };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousNotifications) {
|
||||
queryClient.setQueryData(
|
||||
["notifications"],
|
||||
context.previousNotifications
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clear single notification - optimistic update
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: (id: string) => api.clearNotification(id),
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["notifications"] });
|
||||
|
||||
const previousNotifications = queryClient.getQueryData<
|
||||
Notification[]
|
||||
>(["notifications"]);
|
||||
|
||||
// Optimistically remove
|
||||
queryClient.setQueryData<Notification[]>(
|
||||
["notifications"],
|
||||
(old) => old?.filter((n) => n.id !== id) || []
|
||||
);
|
||||
|
||||
return { previousNotifications };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousNotifications) {
|
||||
queryClient.setQueryData(
|
||||
["notifications"],
|
||||
context.previousNotifications
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clear all notifications - optimistic update
|
||||
const clearAllMutation = useMutation({
|
||||
mutationFn: () => api.clearAllNotifications(),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ["notifications"] });
|
||||
|
||||
const previousNotifications = queryClient.getQueryData<
|
||||
Notification[]
|
||||
>(["notifications"]);
|
||||
|
||||
// Optimistically clear all
|
||||
queryClient.setQueryData<Notification[]>(["notifications"], []);
|
||||
|
||||
return { previousNotifications };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previousNotifications) {
|
||||
queryClient.setQueryData(
|
||||
["notifications"],
|
||||
context.previousNotifications
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleMarkAsRead = (id: string) => markAsReadMutation.mutate(id);
|
||||
const handleClear = (id: string) => clearMutation.mutate(id);
|
||||
const handleClearAll = () => clearAllMutation.mutate();
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "download_complete":
|
||||
return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||
case "download_failed":
|
||||
return <AlertCircle className="w-4 h-4 text-red-400" />;
|
||||
case "playlist_ready":
|
||||
case "import_complete":
|
||||
return <ListMusic className="w-4 h-4 text-[#ecb200]" />;
|
||||
case "system":
|
||||
default:
|
||||
return <Bell className="w-4 h-4 text-white/60" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLink = (notification: Notification): string | null => {
|
||||
if (notification.metadata?.playlistId) {
|
||||
return `/playlist/${notification.metadata.playlistId}`;
|
||||
}
|
||||
if (notification.metadata?.albumId) {
|
||||
return `/album/${notification.metadata.albumId}`;
|
||||
}
|
||||
if (notification.metadata?.artistId) {
|
||||
return `/artist/${notification.metadata.artistId}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return "Just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-white/20 border-t-white/60 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Bell className="w-8 h-8 text-white/20 mb-3" />
|
||||
<p className="text-sm text-white/40">No notifications</p>
|
||||
<p className="text-xs text-white/30 mt-1">
|
||||
You're all caught up!
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with clear all */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
|
||||
<span className="text-xs text-white/40">
|
||||
{notifications.length} notification
|
||||
{notifications.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="text-xs text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{notifications.map((notification) => {
|
||||
const link = getLink(notification);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"px-3 py-3 border-b border-white/5 hover:bg-white/5 transition-colors group",
|
||||
!notification.read && "bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
notification.read
|
||||
? "text-white/70"
|
||||
: "text-white"
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
{!notification.read && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#ecb200] flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{notification.message && (
|
||||
<p className="text-xs text-white/50 mt-0.5 line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] text-white/30">
|
||||
{formatTime(notification.createdAt)}
|
||||
</span>
|
||||
{link && (
|
||||
<Link
|
||||
href={link}
|
||||
className="text-[10px] text-[#ecb200] hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
View{" "}
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!notification.read && (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleMarkAsRead(
|
||||
notification.id
|
||||
)
|
||||
}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 text-white/40 hover:text-white" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
handleClear(notification.id)
|
||||
}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
title="Dismiss"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-white/40 hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNotifications } from "@/hooks/useNotifications";
|
||||
import { useActiveDownloads } from "@/hooks/useNotifications";
|
||||
import { NotificationsTab } from "@/components/activity/NotificationsTab";
|
||||
import { ActiveDownloadsTab } from "@/components/activity/ActiveDownloadsTab";
|
||||
import { HistoryTab } from "@/components/activity/HistoryTab";
|
||||
import {
|
||||
Bell,
|
||||
Download,
|
||||
History,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
|
||||
type ActivityTab = "notifications" | "active" | "history";
|
||||
|
||||
const TABS: { id: ActivityTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "active", label: "Active", icon: Download },
|
||||
{ id: "history", label: "History", icon: History },
|
||||
];
|
||||
|
||||
interface ActivityPanelProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
activeTab?: ActivityTab;
|
||||
onTabChange?: (tab: ActivityTab) => void;
|
||||
}
|
||||
|
||||
export function ActivityPanel({
|
||||
isOpen,
|
||||
onToggle,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: ActivityPanelProps) {
|
||||
const [internalActiveTab, setInternalActiveTab] =
|
||||
useState<ActivityTab>("notifications");
|
||||
const resolvedActiveTab = activeTab ?? internalActiveTab;
|
||||
const setResolvedActiveTab = onTabChange ?? setInternalActiveTab;
|
||||
const { unreadCount } = useNotifications();
|
||||
const { downloads: activeDownloads } = useActiveDownloads();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
// Badge counts
|
||||
const notificationBadge = unreadCount > 0 ? unreadCount : null;
|
||||
const activeBadge =
|
||||
activeDownloads.length > 0 ? activeDownloads.length : null;
|
||||
const hasActivity = unreadCount > 0 || activeDownloads.length > 0;
|
||||
|
||||
// Mobile/Tablet: Full-screen overlay
|
||||
if (isMobileOrTablet) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-[100]"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
|
||||
{/* Panel - slides in from right */}
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full max-w-md bg-[#0a0a0a] z-[101] flex flex-col"
|
||||
style={{ paddingTop: "env(safe-area-inset-top)" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/10">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Activity
|
||||
</h2>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5 text-white/60" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/10">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const badge =
|
||||
tab.id === "notifications"
|
||||
? notificationBadge
|
||||
: tab.id === "active"
|
||||
? activeBadge
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setResolvedActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors relative",
|
||||
resolvedActiveTab === tab.id
|
||||
? "text-white border-b-2 border-[#f5c518]"
|
||||
: "text-white/50 hover:text-white/70"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
{badge && (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[18px] h-[18px] px-1 rounded-full text-xs font-bold flex items-center justify-center ml-1",
|
||||
tab.id === "active"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-[#f5c518] text-black"
|
||||
)}
|
||||
>
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{resolvedActiveTab === "notifications" && (
|
||||
<NotificationsTab />
|
||||
)}
|
||||
{resolvedActiveTab === "active" && (
|
||||
<ActiveDownloadsTab />
|
||||
)}
|
||||
{resolvedActiveTab === "history" && <HistoryTab />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: Side panel
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 h-full bg-[#0d0d0d] rounded-tl-lg rounded-bl-lg border-l border-white/5 flex flex-col z-10 transition-all duration-300 ease-out overflow-hidden relative",
|
||||
isOpen ? "w-[400px]" : "w-12"
|
||||
)}
|
||||
>
|
||||
{/* Collapsed state overlay */}
|
||||
{!isOpen && (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="absolute inset-0 flex items-center justify-center cursor-pointer hover:bg-[#141414] transition-colors"
|
||||
title="Open activity panel"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white/40" />
|
||||
|
||||
{/* Activity badge */}
|
||||
{hasActivity && (
|
||||
<span className="absolute top-4 right-3 w-2.5 h-2.5 rounded-full bg-[#ecb200]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded content - only visible when open */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full transition-opacity duration-200",
|
||||
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
|
||||
<h2 className="text-base font-semibold text-white whitespace-nowrap">
|
||||
Activity
|
||||
</h2>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1.5 hover:bg-white/10 rounded transition-colors"
|
||||
title="Close panel"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white/60" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/10">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const badge =
|
||||
tab.id === "notifications"
|
||||
? notificationBadge
|
||||
: tab.id === "active"
|
||||
? activeBadge
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setResolvedActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors relative whitespace-nowrap",
|
||||
resolvedActiveTab === tab.id
|
||||
? "text-white border-b-2 border-[#ecb200]"
|
||||
: "text-white/50 hover:text-white/70"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
{badge && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-0.5 right-1/4 min-w-[18px] h-[18px] px-1 rounded-full text-xs font-bold flex items-center justify-center",
|
||||
tab.id === "active"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-[#ecb200] text-black"
|
||||
)}
|
||||
>
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{resolvedActiveTab === "notifications" && (
|
||||
<NotificationsTab />
|
||||
)}
|
||||
{resolvedActiveTab === "active" && <ActiveDownloadsTab />}
|
||||
{resolvedActiveTab === "history" && <HistoryTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle button for TopBar
|
||||
export function ActivityPanelToggle() {
|
||||
const { unreadCount } = useNotifications();
|
||||
const { downloads: activeDownloads } = useActiveDownloads();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
if (isMobile || isTablet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasActivity = unreadCount > 0 || activeDownloads.length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new CustomEvent("toggle-activity-panel"))
|
||||
}
|
||||
className={cn(
|
||||
"relative p-2 rounded-full transition-all",
|
||||
"text-white/60 hover:text-white"
|
||||
)}
|
||||
title="Toggle activity panel"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{hasActivity && (
|
||||
<span className="absolute top-1.5 right-2 w-1 h-1 rounded-full bg-[#ecb200]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { TVLayout } from "./TVLayout";
|
||||
import { BottomNavigation } from "./BottomNavigation";
|
||||
import { UniversalPlayer } from "../player/UniversalPlayer";
|
||||
import { MediaControlsHandler } from "../player/MediaControlsHandler";
|
||||
import { PlayerModeWrapper } from "../player/PlayerModeWrapper";
|
||||
import { ActivityPanel } from "./ActivityPanel";
|
||||
import { GalaxyBackground } from "../ui/GalaxyBackground";
|
||||
import { GradientSpinner } from "../ui/GradientSpinner";
|
||||
import { PWAInstallPrompt } from "../PWAInstallPrompt";
|
||||
import { ReactNode } from "react";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { useIsTV } from "@/lib/tv-utils";
|
||||
import { useActivityPanel } from "@/hooks/useActivityPanel";
|
||||
|
||||
const publicPaths = ["/login", "/register", "/onboarding", "/sync"];
|
||||
|
||||
export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isTV = useIsTV();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
const activityPanel = useActivityPanel();
|
||||
|
||||
// Listen for activity panel events (toggle/open/close/tab)
|
||||
useEffect(() => {
|
||||
const handleToggle = () => activityPanel.toggle();
|
||||
const handleOpen = () => activityPanel.open();
|
||||
const handleClose = () => activityPanel.close();
|
||||
const handleSetTab = (
|
||||
e: CustomEvent<{ tab: "notifications" | "active" | "history" }>
|
||||
) => {
|
||||
activityPanel.setActiveTab(e.detail.tab);
|
||||
};
|
||||
window.addEventListener("toggle-activity-panel", handleToggle);
|
||||
window.addEventListener("open-activity-panel", handleOpen);
|
||||
window.addEventListener("close-activity-panel", handleClose);
|
||||
window.addEventListener(
|
||||
"set-activity-panel-tab",
|
||||
handleSetTab as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("toggle-activity-panel", handleToggle);
|
||||
window.removeEventListener("open-activity-panel", handleOpen);
|
||||
window.removeEventListener("close-activity-panel", handleClose);
|
||||
window.removeEventListener(
|
||||
"set-activity-panel-tab",
|
||||
handleSetTab as EventListener
|
||||
);
|
||||
};
|
||||
}, [activityPanel]);
|
||||
|
||||
const isPublicPage = publicPaths.includes(pathname);
|
||||
|
||||
// Show loading state only on protected pages
|
||||
if (!isPublicPage && isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<GradientSpinner size="lg" />
|
||||
<p className="text-white/60 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On public pages (login/register), don't show sidebar/player/topbar
|
||||
if (isPublicPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// On protected pages, show appropriate layout based on device
|
||||
if (isAuthenticated) {
|
||||
// Android TV Layout - Optimized for 10-foot UI
|
||||
if (isTV) {
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<MediaControlsHandler />
|
||||
<TVLayout>{children}</TVLayout>
|
||||
</PlayerModeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile/Tablet Layout
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<div className="h-screen bg-black overflow-hidden flex flex-col">
|
||||
<MediaControlsHandler />
|
||||
<TopBar />
|
||||
|
||||
{/* Sidebar - renders MobileSidebar for hamburger menu */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Activity Panel - for mobile notifications (rendered as overlay) */}
|
||||
<ActivityPanel
|
||||
isOpen={activityPanel.isOpen}
|
||||
onToggle={activityPanel.toggle}
|
||||
activeTab={activityPanel.activeTab}
|
||||
onTabChange={activityPanel.setActiveTab}
|
||||
/>
|
||||
|
||||
{/* 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: "52px",
|
||||
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>
|
||||
|
||||
{/* Mini Player - fixed, positioned above bottom nav */}
|
||||
<UniversalPlayer />
|
||||
|
||||
{/* Bottom Navigation - fixed at bottom */}
|
||||
<BottomNavigation />
|
||||
<PWAInstallPrompt />
|
||||
</div>
|
||||
</PlayerModeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop Layout
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<div
|
||||
className="h-screen bg-black overflow-hidden flex flex-col"
|
||||
style={{ paddingTop: "64px" }}
|
||||
>
|
||||
<MediaControlsHandler />
|
||||
<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">
|
||||
<GalaxyBackground />
|
||||
{children}
|
||||
</main>
|
||||
<ActivityPanel
|
||||
isOpen={activityPanel.isOpen}
|
||||
onToggle={activityPanel.toggle}
|
||||
activeTab={activityPanel.activeTab}
|
||||
onTabChange={activityPanel.setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
<UniversalPlayer />
|
||||
<PWAInstallPrompt />
|
||||
</div>
|
||||
</PlayerModeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If not authenticated on a protected page, auth context will redirect
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<GradientSpinner size="lg" />
|
||||
<p className="text-white/60 text-sm">Redirecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Library, BookOpen, Mic, ListMusic } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Library",
|
||||
href: "/library",
|
||||
icon: Library,
|
||||
matchPattern: "/library"
|
||||
},
|
||||
{
|
||||
name: "Audiobooks",
|
||||
href: "/audiobooks",
|
||||
icon: BookOpen,
|
||||
matchPattern: "/audiobooks"
|
||||
},
|
||||
{
|
||||
name: "Podcasts",
|
||||
href: "/podcasts",
|
||||
icon: Mic,
|
||||
matchPattern: "/podcasts"
|
||||
},
|
||||
{
|
||||
name: "Playlists",
|
||||
href: "/playlists",
|
||||
icon: ListMusic,
|
||||
matchPattern: "/playlist" // Matches both /playlists and /playlist/[id]
|
||||
},
|
||||
];
|
||||
|
||||
export function BottomNavigation() {
|
||||
const pathname = usePathname();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
// Only render on mobile/tablet
|
||||
if (!isMobileOrTablet) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-40 bg-black border-t border-white/10"
|
||||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-around h-14">
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.matchPattern);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center flex-1 h-full py-2 transition-colors",
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-gray-500 active:text-gray-300"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"w-5 h-5 mb-1",
|
||||
isActive && "text-white"
|
||||
)}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] tracking-wide",
|
||||
isActive ? "font-semibold" : "font-medium"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Settings,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
Compass,
|
||||
X,
|
||||
Radio,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useToast } from "@/lib/toast-context";
|
||||
import Image from "next/image";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
// Close on route change
|
||||
useEffect(() => {
|
||||
onClose();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
// Handle library sync
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) return;
|
||||
|
||||
try {
|
||||
setIsSyncing(true);
|
||||
await api.scanLibrary();
|
||||
window.dispatchEvent(new CustomEvent("notifications-changed"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to sync library:", error);
|
||||
toast.error("Failed to start scan. Please try again.");
|
||||
} finally {
|
||||
setTimeout(() => setIsSyncing(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
toast.success("Logged out successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
toast.error("Failed to logout");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sidebar Drawer */}
|
||||
<div
|
||||
className="fixed inset-y-0 left-0 w-[280px] bg-[#0a0a0a] z-50 flex flex-col overflow-hidden transform transition-transform border-r border-white/[0.06]"
|
||||
style={{
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/LIDIFY.webp"
|
||||
alt="Lidify"
|
||||
width={32}
|
||||
height={32}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-lg font-bold text-white tracking-tight">
|
||||
Lidify
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 flex items-center justify-center text-gray-500 hover:text-white transition-colors rounded-full hover:bg-white/10"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Menu Content */}
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
{/* 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">
|
||||
Quick Links
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/discover"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/discover"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Compass className="w-5 h-5" />
|
||||
<span className="text-[15px] font-medium">
|
||||
Discover
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/radio"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/radio"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Radio className="w-5 h-5" />
|
||||
<span className="text-[15px] font-medium">
|
||||
Radio
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/releases"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/releases"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="text-[15px] font-medium">
|
||||
Releases
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
<div className="px-3">
|
||||
<div className="text-[10px] font-semibold text-gray-600 uppercase tracking-widest px-3 mb-2">
|
||||
Actions
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left",
|
||||
isSyncing
|
||||
? "text-green-400"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"w-5 h-5",
|
||||
isSyncing && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
<span className="text-[15px] font-medium">
|
||||
{isSyncing ? "Syncing..." : "Sync Library"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/settings"
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="text-[15px] font-medium">
|
||||
Settings
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer - Logout */}
|
||||
<div className="border-t border-white/[0.06] p-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-3 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="text-[15px] font-medium">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Plus, Settings, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { useToast } from "@/lib/toast-context";
|
||||
import Image from "next/image";
|
||||
import { MobileSidebar } from "./MobileSidebar";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Library", href: "/library" },
|
||||
{ name: "Radio", href: "/radio" },
|
||||
{ name: "Discovery", href: "/discover" },
|
||||
{ name: "Audiobooks", href: "/audiobooks" },
|
||||
{ name: "Podcasts", href: "/podcasts" },
|
||||
{ name: "Browse", href: "/browse/playlists", badge: "Beta" },
|
||||
] as const;
|
||||
|
||||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
trackCount: number;
|
||||
isHidden?: boolean;
|
||||
isOwner?: boolean;
|
||||
user?: { username: string };
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { currentTrack, currentAudiobook, currentPodcast, playbackType } =
|
||||
useAudio();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [isLoadingPlaylists, setIsLoadingPlaylists] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const hasLoadedPlaylists = useRef(false);
|
||||
|
||||
// Handle library sync - no toast, notification bar handles feedback
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) return;
|
||||
|
||||
try {
|
||||
setIsSyncing(true);
|
||||
await api.scanLibrary();
|
||||
// No toast - notification will appear in the activity panel
|
||||
window.dispatchEvent(new CustomEvent("notifications-changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger library scan:", error);
|
||||
toast.error("Failed to start scan. Please try again.");
|
||||
} finally {
|
||||
// Keep syncing for a bit to show the animation
|
||||
setTimeout(() => setIsSyncing(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Load playlists only once
|
||||
useEffect(() => {
|
||||
let loadingTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
if (!isAuthenticated || hasLoadedPlaylists.current) return;
|
||||
|
||||
// Delay showing loading state to avoid flicker
|
||||
loadingTimeout = setTimeout(() => setIsLoadingPlaylists(true), 200);
|
||||
hasLoadedPlaylists.current = true;
|
||||
try {
|
||||
const data = await api.getPlaylists();
|
||||
setPlaylists(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load playlists:", error);
|
||||
hasLoadedPlaylists.current = false; // Allow retry on error
|
||||
} finally {
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setIsLoadingPlaylists(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPlaylists();
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("playlist-created", handlePlaylistEvent);
|
||||
window.addEventListener("playlist-updated", handlePlaylistEvent);
|
||||
window.addEventListener("playlist-deleted", handlePlaylistEvent);
|
||||
|
||||
return () => {
|
||||
if (loadingTimeout) {
|
||||
clearTimeout(loadingTimeout);
|
||||
}
|
||||
window.removeEventListener("playlist-created", handlePlaylistEvent);
|
||||
window.removeEventListener("playlist-updated", handlePlaylistEvent);
|
||||
window.removeEventListener("playlist-deleted", handlePlaylistEvent);
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Close mobile menu when route changes
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Close mobile menu on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
if (isMobileMenuOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
// Listen for toggle event from TopBar
|
||||
useEffect(() => {
|
||||
const handleToggle = () => setIsMobileMenuOpen(true);
|
||||
window.addEventListener("toggle-mobile-menu", handleToggle);
|
||||
return () =>
|
||||
window.removeEventListener("toggle-mobile-menu", handleToggle);
|
||||
}, []);
|
||||
|
||||
// Don't show sidebar on login/register pages
|
||||
// (Check after all hooks to comply with Rules of Hooks)
|
||||
if (pathname === "/login" || pathname === "/register") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render sidebar content inline to prevent component recreation
|
||||
const sidebarContent = (
|
||||
<>
|
||||
{/* Mobile Only - Logo and App Info */}
|
||||
{isMobileOrTablet && (
|
||||
<div className="px-6 pt-8 pb-6 border-b border-white/[0.08]">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<Image
|
||||
src="/assets/images/LIDIFY.webp"
|
||||
alt="Lidify"
|
||||
width={48}
|
||||
height={48}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-black text-white tracking-tight">
|
||||
Lidify
|
||||
</h2>
|
||||
{!currentTrack &&
|
||||
!currentAudiobook &&
|
||||
!currentPodcast ? (
|
||||
<p className="text-sm text-gray-400 font-medium">
|
||||
Stream Your Way
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
<span className="text-gray-500">
|
||||
Listening to:{" "}
|
||||
</span>
|
||||
<span className="text-white font-medium">
|
||||
{playbackType === "track" &&
|
||||
currentTrack
|
||||
? `${currentTrack.artist?.name} - ${currentTrack.album?.title}`
|
||||
: playbackType === "audiobook" &&
|
||||
currentAudiobook
|
||||
? currentAudiobook.title
|
||||
: playbackType === "podcast" &&
|
||||
currentPodcast
|
||||
? currentPodcast.podcastTitle
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions - Settings and Sync */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
className={cn(
|
||||
"w-10 h-10 flex items-center justify-center rounded-full transition-all duration-300",
|
||||
isSyncing
|
||||
? "bg-[#1DB954] text-black"
|
||||
: "bg-white/10 text-white hover:bg-white/15 active:scale-95"
|
||||
)}
|
||||
title={isSyncing ? "Syncing..." : "Sync Library"}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"w-4 h-4 transition-transform",
|
||||
isSyncing && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
"w-10 h-10 flex items-center justify-center rounded-full transition-all",
|
||||
pathname === "/settings"
|
||||
? "bg-white text-black"
|
||||
: "bg-white/10 text-gray-400 hover:text-white hover:bg-white/15 active:scale-95"
|
||||
)}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className={cn(
|
||||
"pt-6 space-y-1",
|
||||
isMobileOrTablet ? "px-6" : "px-3"
|
||||
)}
|
||||
>
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const badge = "badge" in item ? item.badge : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
"block rounded-lg transition-all duration-200 group relative overflow-hidden",
|
||||
isMobileOrTablet ? "px-4 py-3.5" : "px-4 py-3",
|
||||
isActive
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5 active:bg-white/[0.07]"
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold transition-all duration-200",
|
||||
isMobileOrTablet
|
||||
? "text-base"
|
||||
: "text-sm",
|
||||
isActive && "text-white"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide rounded bg-[#ecb200]/20 text-[#ecb200] border border-[#ecb200]/30">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Playlists Section */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col mt-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 flex items-center justify-between group",
|
||||
isMobileOrTablet ? "px-6" : "px-4"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/playlists"
|
||||
prefetch={false}
|
||||
className="relative group/link"
|
||||
>
|
||||
<span className="text-[10px] font-black text-gray-500 group-hover/link:text-transparent group-hover/link:bg-clip-text group-hover/link:bg-gradient-to-r group-hover/link:from-purple-400 group-hover/link:to-pink-400 transition-all duration-300 uppercase tracking-[0.15em]">
|
||||
Your playlists
|
||||
</span>
|
||||
<div className="absolute -bottom-0.5 left-0 right-0 h-px bg-gradient-to-r from-purple-500/0 via-purple-500/50 to-purple-500/0 opacity-0 group-hover/link:opacity-100 transition-opacity duration-300" />
|
||||
</Link>
|
||||
<Link
|
||||
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"
|
||||
title="Create Playlist"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-[#1c1c1c] scrollbar-track-transparent",
|
||||
isMobileOrTablet ? "px-6" : "px-3"
|
||||
)}
|
||||
>
|
||||
{isLoadingPlaylists ? (
|
||||
// Loading skeleton with shimmer
|
||||
<>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="px-3 py-2.5 rounded-lg relative overflow-hidden bg-white/[0.02] border-l-2 border-transparent"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-purple-500/10 to-transparent"
|
||||
style={{
|
||||
animation: "shimmer 2s infinite",
|
||||
}}
|
||||
/>
|
||||
<div className="h-4 bg-white/5 rounded w-3/4 mb-2 relative"></div>
|
||||
<div className="h-3 bg-white/5 rounded w-1/2 relative"></div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : playlists.filter((p) => !p.isHidden).length > 0 ? (
|
||||
playlists
|
||||
.filter((p) => !p.isHidden) // Filter out hidden playlists
|
||||
.map((playlist) => {
|
||||
const isActive =
|
||||
pathname === `/playlist/${playlist.id}`;
|
||||
const isShared = playlist.isOwner === false;
|
||||
return (
|
||||
<Link
|
||||
key={playlist.id}
|
||||
href={`/playlist/${playlist.id}`}
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
"block px-3 py-2.5 rounded-lg transition-all duration-300 group relative overflow-hidden",
|
||||
isActive
|
||||
? "bg-gradient-to-r from-purple-500/10 to-transparent text-white border-l-2 border-purple-500 shadow-md shadow-purple-500/5"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/[0.05] border-l-2 border-transparent hover:border-l-2 hover:border-purple-500/30"
|
||||
)}
|
||||
>
|
||||
{/* Hover shimmer effect */}
|
||||
{!isActive && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-purple-500/5 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium truncate relative z-10 transition-all duration-200 flex-1",
|
||||
isActive
|
||||
? "font-semibold"
|
||||
: "group-hover:translate-x-0.5"
|
||||
)}
|
||||
>
|
||||
{playlist.name}
|
||||
</div>
|
||||
{isShared && (
|
||||
<span
|
||||
className="shrink-0 w-1.5 h-1.5 rounded-full bg-purple-500"
|
||||
title={`Shared by ${
|
||||
playlist.user
|
||||
?.username ||
|
||||
"someone"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs truncate relative z-10 mt-0.5 transition-colors duration-200",
|
||||
isActive
|
||||
? "text-gray-400"
|
||||
: "text-gray-500 group-hover:text-gray-400"
|
||||
)}
|
||||
>
|
||||
{isShared
|
||||
? `by ${
|
||||
playlist.user?.username ||
|
||||
"Shared"
|
||||
}`
|
||||
: "Playlist"}{" "}
|
||||
• {playlist.trackCount} track
|
||||
{playlist.trackCount !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
No playlists yet
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
Create your first playlist to get started
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Sidebar */}
|
||||
{isMobileOrTablet && (
|
||||
<MobileSidebar
|
||||
isOpen={isMobileMenuOpen}
|
||||
onClose={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
{!isMobileOrTablet && (
|
||||
<aside className="w-72 bg-[#0f0f0f] rounded-lg flex flex-col overflow-hidden relative z-10 border border-white/[0.03]">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { DPAD_KEYS } from "@/lib/tv-utils";
|
||||
import { useTVNavigation } from "@/hooks/useTVNavigation";
|
||||
import { RefreshCw, SkipBack, SkipForward, Shuffle, Repeat } from "lucide-react";
|
||||
|
||||
const tvNavigation = [
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Search", href: "/search" },
|
||||
{ name: "Library", href: "/library" },
|
||||
{ name: "Audiobooks", href: "/audiobooks" },
|
||||
{ name: "Podcasts", href: "/podcasts" },
|
||||
{ name: "Discovery", href: "/discover" },
|
||||
{ name: "Playlists", href: "/playlists" },
|
||||
];
|
||||
|
||||
export function TVLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
// Start with nav focused and first tab selected for immediate D-pad usability
|
||||
const [focusedTabIndex, setFocusedTabIndex] = useState(0);
|
||||
const [isNavFocused, setIsNavFocused] = useState(true);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// TV content navigation hook
|
||||
const {
|
||||
containerRef: contentRef,
|
||||
focusFirstCard,
|
||||
handleKeyDown: handleContentKeyDown,
|
||||
isContentFocused,
|
||||
} = useTVNavigation({
|
||||
onBack: () => {
|
||||
setIsNavFocused(true);
|
||||
const currentIndex = tvNavigation.findIndex(n => n.href === pathname);
|
||||
setFocusedTabIndex(currentIndex >= 0 ? currentIndex : 0);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
isPlaying,
|
||||
pause,
|
||||
resume,
|
||||
currentTime,
|
||||
duration,
|
||||
next,
|
||||
previous,
|
||||
isShuffle,
|
||||
toggleShuffle,
|
||||
repeatMode,
|
||||
toggleRepeat,
|
||||
seek,
|
||||
} = useAudio();
|
||||
|
||||
// Add tv-mode class to body on mount
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('tv-mode');
|
||||
document.body.classList.add('tv-mode');
|
||||
return () => {
|
||||
document.documentElement.classList.remove('tv-mode');
|
||||
document.body.classList.remove('tv-mode');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
let title = "";
|
||||
let artist = "";
|
||||
let coverUrl: string | null = null;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
title = currentTrack.title;
|
||||
artist = currentTrack.artist?.name || "";
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 96)
|
||||
: null;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
artist = currentAudiobook.author || "";
|
||||
coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 96)
|
||||
: null;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
title = currentPodcast.title;
|
||||
artist = currentPodcast.podcastTitle || "";
|
||||
coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 96)
|
||||
: 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')}`;
|
||||
};
|
||||
|
||||
// Sync library
|
||||
const handleSync = async () => {
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
await api.scanLibrary();
|
||||
} catch (error) {
|
||||
console.error("Sync failed:", error);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Media keys work globally regardless of focus state
|
||||
if (hasMedia) {
|
||||
switch (e.key) {
|
||||
case DPAD_KEYS.PLAY_PAUSE:
|
||||
case "MediaPlayPause":
|
||||
case " ": // Space bar as play/pause
|
||||
// Only use space when not in an input field
|
||||
if (e.key === " ") {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
isPlaying ? pause() : resume();
|
||||
return;
|
||||
case "MediaTrackNext":
|
||||
e.preventDefault();
|
||||
next();
|
||||
return;
|
||||
case "MediaTrackPrevious":
|
||||
e.preventDefault();
|
||||
previous();
|
||||
return;
|
||||
case DPAD_KEYS.FAST_FORWARD:
|
||||
case "MediaFastForward":
|
||||
e.preventDefault();
|
||||
seek(Math.min(currentTime + 10, duration));
|
||||
return;
|
||||
case DPAD_KEYS.REWIND:
|
||||
case "MediaRewind":
|
||||
e.preventDefault();
|
||||
seek(Math.max(currentTime - 10, 0));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNavFocused) {
|
||||
if (e.key === DPAD_KEYS.LEFT) {
|
||||
e.preventDefault();
|
||||
setFocusedTabIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (e.key === DPAD_KEYS.RIGHT) {
|
||||
e.preventDefault();
|
||||
setFocusedTabIndex(prev => Math.min(tvNavigation.length - 1, prev + 1));
|
||||
} else if (e.key === DPAD_KEYS.DOWN) {
|
||||
e.preventDefault();
|
||||
setIsNavFocused(false);
|
||||
// Use the navigation hook to focus first card
|
||||
focusFirstCard();
|
||||
} else if (e.key === DPAD_KEYS.CENTER) {
|
||||
e.preventDefault();
|
||||
router.push(tvNavigation[focusedTabIndex].href);
|
||||
}
|
||||
} else {
|
||||
// Delegate to content navigation hook
|
||||
handleContentKeyDown(e);
|
||||
}
|
||||
}, [isNavFocused, focusedTabIndex, router, hasMedia, isPlaying, pause, resume, next, previous, seek, currentTime, duration, focusFirstCard, handleContentKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Focus correct nav tab when isNavFocused changes or focusedTabIndex changes
|
||||
useEffect(() => {
|
||||
if (isNavFocused && navRef.current) {
|
||||
const tabs = navRef.current.querySelectorAll<HTMLAnchorElement>('[data-tv-tab]');
|
||||
tabs[focusedTabIndex]?.focus();
|
||||
}
|
||||
}, [focusedTabIndex, isNavFocused]);
|
||||
|
||||
// On initial mount and pathname change, set the correct focused tab
|
||||
useEffect(() => {
|
||||
const currentIndex = tvNavigation.findIndex(n =>
|
||||
n.href === pathname || (n.href !== "/" && pathname.startsWith(n.href))
|
||||
);
|
||||
if (currentIndex >= 0) {
|
||||
setFocusedTabIndex(currentIndex);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Nav */}
|
||||
<header className="tv-nav">
|
||||
<Link href="/" className="tv-logo">
|
||||
<Image src="/assets/images/LIDIFY.webp" alt="Lidify" width={24} height={24} />
|
||||
<span>Lidify</span>
|
||||
</Link>
|
||||
|
||||
<nav ref={navRef} className="tv-nav-links">
|
||||
{tvNavigation.map((item, index) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== "/" && pathname.startsWith(item.href));
|
||||
const isFocused = isNavFocused && focusedTabIndex === index;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
data-tv-tab
|
||||
className={cn("tv-nav-link", isActive && "active", isFocused && "focused")}
|
||||
onFocus={() => {
|
||||
setIsNavFocused(true);
|
||||
setFocusedTabIndex(index);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Sync button */}
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
className="tv-sync-btn"
|
||||
title="Sync Library"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", isSyncing && "animate-spin")} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Now Playing Bar - below nav */}
|
||||
{hasMedia && (
|
||||
<div className="tv-now-playing-bar">
|
||||
{coverUrl && (
|
||||
<Image src={coverUrl} alt={title} width={48} height={48} className="tv-np-cover" />
|
||||
)}
|
||||
<div className="tv-np-info">
|
||||
<div className="tv-np-title">{title}</div>
|
||||
<div className="tv-np-artist">{artist}</div>
|
||||
</div>
|
||||
|
||||
{/* Time counter */}
|
||||
<div className="tv-np-time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
|
||||
{/* Shuffle */}
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={cn("tv-np-ctrl", isShuffle && "active")}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Previous */}
|
||||
<button onClick={previous} className="tv-np-ctrl" title="Previous">
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button onClick={() => isPlaying ? pause() : resume()} className="tv-np-btn">
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5,3 19,12 5,21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Next */}
|
||||
<button onClick={next} className="tv-np-ctrl" title="Next">
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
className={cn("tv-np-ctrl", repeatMode !== "off" && "active")}
|
||||
title={repeatMode === "one" ? "Repeat One" : repeatMode === "all" ? "Repeat All" : "Repeat Off"}
|
||||
>
|
||||
<Repeat className="w-4 h-4" />
|
||||
{repeatMode === "one" && <span className="tv-np-repeat-one">1</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<main ref={contentRef} className="tv-content">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Home, Search, Settings, RefreshCw, Power, Menu, Bell } from "lucide-react";
|
||||
import { ActivityPanelToggle } from "./ActivityPanel";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/lib/toast-context";
|
||||
import { useJobStatus } from "@/hooks/useJobStatus";
|
||||
import { useDownloadContext } from "@/lib/download-context";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Image from "next/image";
|
||||
|
||||
export function TopBar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { logout } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [scanJobId, setScanJobId] = useState<string | null>(null);
|
||||
const [lastScanTime, setLastScanTime] = useState<number>(0);
|
||||
const { toast } = useToast();
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { jobStatus, isPolling } = useJobStatus(scanJobId, "scan", {
|
||||
onComplete: () => {
|
||||
// Refresh Activity Panel and enrichment progress after scan
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["enrichment-progress"] });
|
||||
setScanJobId(null);
|
||||
},
|
||||
onError: () => {
|
||||
// Scan errors will show in the activity panel via notifications
|
||||
setScanJobId(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Track download status from context (single source of truth)
|
||||
const { pendingDownloads, downloadStatus } = useDownloadContext();
|
||||
const hasActiveDownloads =
|
||||
downloadStatus.hasActiveDownloads || pendingDownloads.length > 0;
|
||||
const hasFailedDownloads = downloadStatus.failedDownloads.length > 0;
|
||||
|
||||
const handleSync = async () => {
|
||||
if (isPolling) return;
|
||||
|
||||
// Prevent spam clicking - cooldown of 5 seconds (silently ignore)
|
||||
const now = Date.now();
|
||||
const timeSinceLastScan = now - lastScanTime;
|
||||
if (timeSinceLastScan < 5000) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLastScanTime(now);
|
||||
const response = await api.scanLibrary();
|
||||
setScanJobId(response.jobId);
|
||||
// Refresh notifications to show the scan started notification
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger library scan:", error);
|
||||
// Scan errors will show in the activity panel via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
toast.success("Logged out successfully");
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
toast.error("Failed to logout");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-search with debounce (500ms after user stops typing)
|
||||
useEffect(() => {
|
||||
// Don't auto-search if we're already on the search page with the same query
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const currentQuery = params.get("q");
|
||||
if (pathname === "/search" && currentQuery === searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Don't search if query is empty
|
||||
if (!searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set new timeout to trigger search after 500ms of no typing
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
}, 500);
|
||||
|
||||
// Cleanup timeout on unmount or when searchQuery changes
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, router, pathname]);
|
||||
|
||||
// Sync search query with URL on page change
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const q = params.get("q");
|
||||
|
||||
if (pathname === "/search" && q) {
|
||||
// Only update if different to avoid loops
|
||||
if (q !== searchQuery) {
|
||||
setSearchQuery(q);
|
||||
}
|
||||
} else if (pathname !== "/search" && searchQuery) {
|
||||
// Clear search when leaving search page
|
||||
setSearchQuery("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]); // Only re-run when pathname changes
|
||||
|
||||
return (
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 bg-black flex items-center px-3 z-50"
|
||||
style={{ height: isMobileOrTablet ? "52px" : "64px" }}
|
||||
>
|
||||
{/* Mobile/Tablet Layout: Hamburger + Home + Search + Bell */}
|
||||
{isMobileOrTablet ? (
|
||||
<>
|
||||
{/* Hamburger menu button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
// Dispatch custom event to toggle mobile menu
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("toggle-mobile-menu")
|
||||
);
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center bg-[#0f0f0f] border border-[#262626] rounded-md text-white hover:bg-[#141414] transition-colors mr-2 flex-shrink-0"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Home */}
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-all flex-shrink-0 mr-2",
|
||||
pathname === "/"
|
||||
? "bg-white text-black"
|
||||
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
|
||||
)}
|
||||
title="Home"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex-1 min-w-0">
|
||||
<div
|
||||
className="relative"
|
||||
data-tv-section="search-input"
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) =>
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
placeholder="Search..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
tabIndex={0}
|
||||
className="w-full h-10 pl-10 pr-3 bg-[#1a1a1a] hover:bg-[#242424] border-2 border-transparent focus:border-white/20 rounded-full text-sm text-white placeholder-gray-400 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Notification Bell */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("toggle-activity-panel")
|
||||
);
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-white transition-colors ml-2 flex-shrink-0 relative"
|
||||
aria-label="Notifications"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{/* TODO: Add notification badge in Phase 3 */}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Layout */}
|
||||
{/* Logo - Far Left */}
|
||||
<div className="w-72 flex items-center px-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/LIDIFY.webp"
|
||||
alt="Lidify"
|
||||
width={32}
|
||||
height={32}
|
||||
className="group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<span className="text-xl font-semibold text-white">
|
||||
Lidify
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center - Home & Search */}
|
||||
<div className="flex-1 flex items-center justify-center gap-3 max-w-3xl mx-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-full flex items-center justify-center transition-all flex-shrink-0",
|
||||
pathname === "/"
|
||||
? "bg-white text-black"
|
||||
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white hover:scale-105"
|
||||
)}
|
||||
title="Home"
|
||||
>
|
||||
<Home className="w-6 h-6" />
|
||||
</Link>
|
||||
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className="flex-1 max-w-md"
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
data-tv-section="search-input"
|
||||
>
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) =>
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
placeholder="What do you want to play?"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
tabIndex={0}
|
||||
className="w-full h-12 pl-12 pr-4 bg-[#1a1a1a] hover:bg-[#242424] border-2 border-transparent focus:border-white/20 rounded-full text-sm text-white placeholder-gray-400 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right - Sync & Settings */}
|
||||
<div className="w-72 flex items-center justify-end gap-2 px-2">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isPolling}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 h-10 rounded-full transition-all text-sm font-medium",
|
||||
isPolling
|
||||
? "bg-white/10 text-gray-500 cursor-not-allowed"
|
||||
: hasActiveDownloads
|
||||
? " text-green-400 "
|
||||
: hasFailedDownloads
|
||||
? "bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
: "bg-#0a0a0a text-white hover:bg-white/20"
|
||||
)}
|
||||
title={
|
||||
isPolling
|
||||
? "Library scan in progress..."
|
||||
: hasActiveDownloads
|
||||
? `${
|
||||
downloadStatus.activeDownloads
|
||||
.length + pendingDownloads.length
|
||||
} download(s) in progress`
|
||||
: hasFailedDownloads
|
||||
? `${downloadStatus.failedDownloads.length} download(s) failed`
|
||||
: "Sync Library"
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
(isPolling || hasActiveDownloads) &&
|
||||
"animate-spin"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<ActivityPanelToggle />
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-all",
|
||||
pathname === "/settings"
|
||||
? "bg-white text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
)}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-all text-red-400 hover:text-red-300"
|
||||
title="Logout"
|
||||
>
|
||||
<Power className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { useAudioPlayback } from "@/lib/audio-playback-context";
|
||||
import { useAudioControls } from "@/lib/audio-controls-context";
|
||||
import { api } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize2,
|
||||
Music as MusicIcon,
|
||||
Shuffle,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Loader2,
|
||||
AudioWaveform,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { cn, isLocalUrl } from "@/utils/cn";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
|
||||
/**
|
||||
* FullPlayer - UI-only component for desktop bottom player
|
||||
* Does NOT manage audio element - that's handled by AudioElement component
|
||||
*/
|
||||
export function FullPlayer() {
|
||||
// Use split contexts to avoid re-rendering on every currentTime update
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
volume,
|
||||
isMuted,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
playerMode,
|
||||
vibeMode,
|
||||
vibeSourceFeatures,
|
||||
queue,
|
||||
currentIndex,
|
||||
} = useAudioState();
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
currentTime,
|
||||
duration: playbackDuration,
|
||||
canSeek,
|
||||
downloadProgress,
|
||||
} = useAudioPlayback();
|
||||
|
||||
const {
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
setPlayerMode,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setUpcoming,
|
||||
startVibeMode,
|
||||
stopVibeMode,
|
||||
} = useAudioControls();
|
||||
|
||||
const [isVibeLoading, setIsVibeLoading] = useState(false);
|
||||
const [isVibePanelExpanded, setIsVibePanelExpanded] = useState(false);
|
||||
|
||||
// Get current track's audio features for vibe comparison
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
// Handle Vibe Mode toggle - finds tracks that sound like the current track
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
// If vibe mode is on, turn it off
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, start vibe mode
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks("vibe", currentTrack.id, 50);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
// Get the source track's features from the API response
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
bpm: sf?.bpm,
|
||||
energy: sf?.energy,
|
||||
valence: sf?.valence,
|
||||
arousal: sf?.arousal,
|
||||
danceability: sf?.danceability,
|
||||
keyScale: sf?.keyScale,
|
||||
instrumentalness: sf?.instrumentalness,
|
||||
analysisMode: sf?.analysisMode,
|
||||
// ML Mood predictions
|
||||
moodHappy: sf?.moodHappy,
|
||||
moodSad: sf?.moodSad,
|
||||
moodRelaxed: sf?.moodRelaxed,
|
||||
moodAggressive: sf?.moodAggressive,
|
||||
moodParty: sf?.moodParty,
|
||||
moodAcoustic: sf?.moodAcoustic,
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
// Start vibe mode with the queue IDs (include current track)
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
|
||||
// Add vibe tracks as upcoming (after current song finishes)
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued up next`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-[#ecb200]" />,
|
||||
});
|
||||
} else {
|
||||
toast.error("Couldn't find matching tracks in your library");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start vibe match:", error);
|
||||
toast.error("Failed to match vibe");
|
||||
} finally {
|
||||
setIsVibeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const duration = (() => {
|
||||
// Prefer canonical durations for long-form media to avoid stale/misreported playbackDuration.
|
||||
if (playbackType === "podcast" && currentPodcast?.duration) {
|
||||
return currentPodcast.duration;
|
||||
}
|
||||
if (playbackType === "audiobook" && currentAudiobook?.duration) {
|
||||
return currentAudiobook.duration;
|
||||
}
|
||||
return (
|
||||
playbackDuration ||
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0
|
||||
);
|
||||
})();
|
||||
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
// For audiobooks/podcasts, show saved progress even before playback starts
|
||||
// This provides immediate visual feedback of where the user left off
|
||||
const displayTime = (() => {
|
||||
// If we're actively playing or have seeked, use the live currentTime
|
||||
if (currentTime > 0) return currentTime;
|
||||
|
||||
// Otherwise, show saved progress for audiobooks/podcasts
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.currentTime;
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
|
||||
return currentTime;
|
||||
})();
|
||||
|
||||
const progress = duration > 0 ? Math.min(100, Math.max(0, (displayTime / duration) * 100)) : 0;
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Don't allow seeking if canSeek is false (uncached podcast)
|
||||
if (!canSeek) {
|
||||
console.log("[FullPlayer] Seeking disabled - podcast not cached");
|
||||
return;
|
||||
}
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const time = percentage * duration;
|
||||
seek(time);
|
||||
};
|
||||
|
||||
// Determine if seeking is allowed
|
||||
const seekEnabled = hasMedia && canSeek;
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseInt(e.target.value) / 100;
|
||||
setVolume(newVolume);
|
||||
};
|
||||
|
||||
// Get current media info
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
let coverUrl: string | null = null;
|
||||
let albumLink: string | null = null;
|
||||
let artistLink: string | null = null;
|
||||
let mediaLink: string | null = null;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
title = currentTrack.title;
|
||||
subtitle = currentTrack.artist?.name || "Unknown Artist";
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 100)
|
||||
: null;
|
||||
albumLink = currentTrack.album?.id ? `/album/${currentTrack.album.id}` : null;
|
||||
artistLink = currentTrack.artist?.id ? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}` : null;
|
||||
mediaLink = albumLink;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
subtitle = currentAudiobook.author;
|
||||
coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 100)
|
||||
: null;
|
||||
mediaLink = `/audiobooks/${currentAudiobook.id}`;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
title = currentPodcast.title;
|
||||
subtitle = currentPodcast.podcastTitle;
|
||||
coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 100)
|
||||
: null;
|
||||
const podcastId = currentPodcast.id.split(":")[0];
|
||||
mediaLink = `/podcasts/${podcastId}`;
|
||||
} else {
|
||||
// Idle state - no media playing
|
||||
title = "Not Playing";
|
||||
subtitle = "Select something to play";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0">
|
||||
{/* Floating Vibe Overlay - shows when tab is clicked */}
|
||||
{vibeMode && isVibePanelExpanded && (
|
||||
<div className="absolute bottom-full right-4 mb-2 z-50">
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vibe Tab - shows when vibe mode is active */}
|
||||
{vibeMode && (
|
||||
<button
|
||||
onClick={() => setIsVibePanelExpanded(!isVibePanelExpanded)}
|
||||
className={cn(
|
||||
"absolute -top-8 right-4 z-10",
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-t-lg",
|
||||
"bg-[#181818] border border-b-0 border-white/10",
|
||||
"text-xs font-medium transition-colors",
|
||||
isVibePanelExpanded ? "text-brand" : "text-white/70 hover:text-brand"
|
||||
)}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
{isVibePanelExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-24 bg-black border-t border-white/[0.08]">
|
||||
{/* Subtle top glow */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
<div className="flex items-center h-full px-6 gap-6">
|
||||
{/* Artwork & Info */}
|
||||
<div className="flex items-center gap-4 w-80">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} className="relative w-14 h-14 flex-shrink-0 group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent rounded-full blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="relative w-14 h-14 flex-shrink-0">
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} className="block hover:underline">
|
||||
<h4 className="text-white font-semibold truncate text-sm">{title}</h4>
|
||||
</Link>
|
||||
) : (
|
||||
<h4 className="text-white font-semibold truncate text-sm">{title}</h4>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link href={artistLink} className="block hover:underline">
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
</Link>
|
||||
) : mediaLink ? (
|
||||
<Link href={mediaLink} className="block hover:underline">
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Shuffle */}
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
isShuffle
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 30s */}
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia ? "text-gray-400 hover:text-white" : "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={previous}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={isBuffering ? undefined : isPlaying ? pause : resume}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200 relative group",
|
||||
hasMedia && !isBuffering
|
||||
? "bg-white text-black hover:scale-110 shadow-lg shadow-white/20 hover:shadow-white/30"
|
||||
: isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia || isBuffering}
|
||||
title={isBuffering ? "Buffering..." : isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{hasMedia && !isBuffering && (
|
||||
<div className="absolute inset-0 rounded-full bg-white blur-md opacity-0 group-hover:opacity-50 transition-opacity duration-200" />
|
||||
)}
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin relative z-10" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5 relative z-10" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5 relative z-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 30s */}
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia ? "text-gray-400 hover:text-white" : "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
repeatMode !== "off"
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
title={
|
||||
repeatMode === "off"
|
||||
? "Repeat: Off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat: All (loop queue)"
|
||||
: "Repeat: One (play current track twice)"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-4 h-4" />
|
||||
) : (
|
||||
<Repeat className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Vibe Mode Toggle */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
!hasMedia || playbackType !== "track"
|
||||
? "text-gray-600"
|
||||
: vibeMode
|
||||
? "text-[#ecb200] hover:text-[#d4a000]"
|
||||
: "text-gray-400 hover:text-[#ecb200]"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track" || isVibeLoading}
|
||||
title={vibeMode ? "Turn off vibe mode" : "Match this vibe - find similar sounding tracks"}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<span className={cn(
|
||||
"text-xs text-right font-medium tabular-nums",
|
||||
hasMedia ? "text-gray-400" : "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}>
|
||||
{formatTime(displayTime)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 h-1 bg-white/[0.15] rounded-full relative",
|
||||
seekEnabled ? "cursor-pointer group" : "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleSeek : undefined}
|
||||
title={
|
||||
!hasMedia
|
||||
? undefined
|
||||
: !canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached"
|
||||
: "Click to seek"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
seekEnabled ? "bg-white group-hover:bg-white" : hasMedia ? "bg-white/50" : "bg-gray-600"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
{seekEnabled && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg shadow-white/50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs font-medium tabular-nums",
|
||||
hasMedia ? "text-gray-400" : "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}>
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume & Expand */}
|
||||
<div className="flex items-center gap-3 w-52 justify-end">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="w-5 h-5" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-full h-1 bg-white/[0.15] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:shadow-white/30 [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Info */}
|
||||
<KeyboardShortcutsTooltip />
|
||||
|
||||
<button
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className={cn(
|
||||
"transition-all duration-200 border-l border-white/[0.08] pl-3",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white hover:scale-110"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { useAudioPlayback } from "@/lib/audio-playback-context";
|
||||
import { useAudioControls } from "@/lib/audio-controls-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { howlerEngine } from "@/lib/howler-engine";
|
||||
import { audioSeekEmitter } from "@/lib/audio-seek-emitter";
|
||||
import { useEffect, useLayoutEffect, useRef, memo, useCallback, useMemo } from "react";
|
||||
|
||||
function podcastDebugEnabled(): boolean {
|
||||
try {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage?.getItem("lidifyPodcastDebug") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function podcastDebugLog(message: string, data?: Record<string, unknown>) {
|
||||
if (!podcastDebugEnabled()) return;
|
||||
console.log(`[PodcastDebug] ${message}`, data || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* HowlerAudioElement - Unified audio playback using Howler.js
|
||||
*
|
||||
* Handles: web playback, progress saving for audiobooks/podcasts
|
||||
* Browser media controls are handled separately by useMediaSession hook
|
||||
*/
|
||||
export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
// State context
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
volume,
|
||||
isMuted,
|
||||
repeatMode,
|
||||
setCurrentAudiobook,
|
||||
setCurrentTrack,
|
||||
setCurrentPodcast,
|
||||
setPlaybackType,
|
||||
queue,
|
||||
} = useAudioState();
|
||||
|
||||
// Playback context
|
||||
const {
|
||||
isPlaying,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setIsPlaying,
|
||||
isBuffering,
|
||||
setIsBuffering,
|
||||
setTargetSeekPosition,
|
||||
canSeek,
|
||||
setCanSeek,
|
||||
setDownloadProgress,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Controls context
|
||||
const { pause, next } = useAudioControls();
|
||||
|
||||
// Refs
|
||||
const lastTrackIdRef = useRef<string | null>(null);
|
||||
const lastPlayingStateRef = useRef<boolean>(isPlaying);
|
||||
const progressSaveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastProgressSaveRef = useRef<number>(0);
|
||||
const isUserInitiatedRef = useRef<boolean>(false);
|
||||
const isLoadingRef = useRef<boolean>(false);
|
||||
const loadIdRef = useRef<number>(0);
|
||||
const cachePollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const seekCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const cacheStatusPollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const seekReloadListenerRef = useRef<(() => void) | null>(null);
|
||||
const seekReloadInProgressRef = useRef<boolean>(false);
|
||||
// Track when a seek operation is in progress to prevent load effect from interfering
|
||||
const isSeekingRef = useRef<boolean>(false);
|
||||
// Track load listeners for cleanup to prevent memory leaks
|
||||
const loadListenerRef = useRef<(() => void) | null>(null);
|
||||
const loadErrorListenerRef = useRef<(() => void) | null>(null);
|
||||
const cachePollingLoadListenerRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Reset duration when nothing is playing
|
||||
useEffect(() => {
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) {
|
||||
setDuration(0);
|
||||
}
|
||||
}, [currentTrack, currentAudiobook, currentPodcast, setDuration]);
|
||||
|
||||
// Subscribe to Howler events
|
||||
useEffect(() => {
|
||||
const handleTimeUpdate = (data: { time: number }) => {
|
||||
setCurrentTime(data.time);
|
||||
};
|
||||
|
||||
const handleLoad = (data: { duration: number }) => {
|
||||
const fallbackDuration =
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0;
|
||||
setDuration(data.duration || fallbackDuration);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
// Save final progress for audiobooks/podcasts
|
||||
if (playbackType === "audiobook" && currentAudiobook) {
|
||||
saveAudiobookProgress(true);
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
savePodcastProgress(true);
|
||||
}
|
||||
|
||||
// Handle track advancement based on repeat mode
|
||||
if (playbackType === "track") {
|
||||
if (repeatMode === "one") {
|
||||
howlerEngine.seek(0);
|
||||
howlerEngine.play();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (data: { error: any }) => {
|
||||
console.error("[HowlerAudioElement] Playback error:", data.error);
|
||||
setIsPlaying(false);
|
||||
isUserInitiatedRef.current = false;
|
||||
|
||||
if (playbackType === "track") {
|
||||
if (queue.length > 1) {
|
||||
console.log("[HowlerAudioElement] Track failed, trying next in queue");
|
||||
lastTrackIdRef.current = null;
|
||||
isLoadingRef.current = false;
|
||||
next();
|
||||
} else {
|
||||
console.log("[HowlerAudioElement] Track failed, no more in queue - clearing");
|
||||
lastTrackIdRef.current = null;
|
||||
isLoadingRef.current = false;
|
||||
setCurrentTrack(null);
|
||||
setPlaybackType(null);
|
||||
}
|
||||
} else if (playbackType === "audiobook") {
|
||||
setCurrentAudiobook(null);
|
||||
setPlaybackType(null);
|
||||
} else if (playbackType === "podcast") {
|
||||
setCurrentPodcast(null);
|
||||
setPlaybackType(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!isUserInitiatedRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
isUserInitiatedRef.current = false;
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (isLoadingRef.current) return;
|
||||
if (seekReloadInProgressRef.current) return;
|
||||
|
||||
if (!isUserInitiatedRef.current) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
isUserInitiatedRef.current = false;
|
||||
};
|
||||
|
||||
howlerEngine.on("timeupdate", handleTimeUpdate);
|
||||
howlerEngine.on("load", handleLoad);
|
||||
howlerEngine.on("end", handleEnd);
|
||||
howlerEngine.on("loaderror", handleError);
|
||||
howlerEngine.on("playerror", handleError);
|
||||
howlerEngine.on("play", handlePlay);
|
||||
howlerEngine.on("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
howlerEngine.off("timeupdate", handleTimeUpdate);
|
||||
howlerEngine.off("load", handleLoad);
|
||||
howlerEngine.off("end", handleEnd);
|
||||
howlerEngine.off("loaderror", handleError);
|
||||
howlerEngine.off("playerror", handleError);
|
||||
howlerEngine.off("play", handlePlay);
|
||||
howlerEngine.off("pause", handlePause);
|
||||
};
|
||||
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTime, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
|
||||
|
||||
// Save audiobook progress
|
||||
const saveAudiobookProgress = useCallback(
|
||||
async (isFinished: boolean = false) => {
|
||||
if (!currentAudiobook) return;
|
||||
|
||||
const currentTime = howlerEngine.getCurrentTime();
|
||||
const duration =
|
||||
howlerEngine.getDuration() || currentAudiobook.duration;
|
||||
|
||||
if (currentTime === lastProgressSaveRef.current && !isFinished)
|
||||
return;
|
||||
lastProgressSaveRef.current = currentTime;
|
||||
|
||||
try {
|
||||
await api.updateAudiobookProgress(
|
||||
currentAudiobook.id,
|
||||
isFinished ? duration : currentTime,
|
||||
duration,
|
||||
isFinished
|
||||
);
|
||||
|
||||
setCurrentAudiobook({
|
||||
...currentAudiobook,
|
||||
progress: {
|
||||
currentTime: isFinished ? duration : currentTime,
|
||||
progress:
|
||||
duration > 0
|
||||
? ((isFinished ? duration : currentTime) /
|
||||
duration) *
|
||||
100
|
||||
: 0,
|
||||
isFinished,
|
||||
lastPlayedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save audiobook progress:",
|
||||
err
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentAudiobook, setCurrentAudiobook]
|
||||
);
|
||||
|
||||
// Save podcast progress
|
||||
const savePodcastProgress = useCallback(
|
||||
async (isFinished: boolean = false) => {
|
||||
if (!currentPodcast) return;
|
||||
|
||||
if (isBuffering && !isFinished) return;
|
||||
|
||||
const currentTime = howlerEngine.getCurrentTime();
|
||||
const duration =
|
||||
howlerEngine.getDuration() || currentPodcast.duration;
|
||||
|
||||
if (currentTime <= 0 && !isFinished) return;
|
||||
|
||||
try {
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
await api.updatePodcastProgress(
|
||||
podcastId,
|
||||
episodeId,
|
||||
isFinished ? duration : currentTime,
|
||||
duration,
|
||||
isFinished
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save podcast progress:",
|
||||
err
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentPodcast, isBuffering]
|
||||
);
|
||||
|
||||
// Load and play audio when track changes
|
||||
useEffect(() => {
|
||||
const currentMediaId =
|
||||
currentTrack?.id ||
|
||||
currentAudiobook?.id ||
|
||||
currentPodcast?.id ||
|
||||
null;
|
||||
|
||||
if (!currentMediaId) {
|
||||
howlerEngine.stop();
|
||||
lastTrackIdRef.current = null;
|
||||
isLoadingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMediaId === lastTrackIdRef.current) {
|
||||
// Skip if a seek operation is in progress - the seek handler will manage playback
|
||||
if (isSeekingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPlay = lastPlayingStateRef.current || isPlaying;
|
||||
const isCurrentlyPlaying = howlerEngine.isPlaying();
|
||||
|
||||
if (shouldPlay && !isCurrentlyPlaying) {
|
||||
howlerEngine.seek(0);
|
||||
howlerEngine.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
isLoadingRef.current = true;
|
||||
lastTrackIdRef.current = currentMediaId;
|
||||
loadIdRef.current += 1;
|
||||
const thisLoadId = loadIdRef.current;
|
||||
|
||||
let streamUrl: string | null = null;
|
||||
let startTime = 0;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
streamUrl = api.getStreamUrl(currentTrack.id);
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
streamUrl = api.getAudiobookStreamUrl(currentAudiobook.id);
|
||||
startTime = currentAudiobook.progress?.currentTime || 0;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
streamUrl = api.getPodcastEpisodeStreamUrl(podcastId, episodeId);
|
||||
startTime = currentPodcast.progress?.currentTime || 0;
|
||||
podcastDebugLog("load podcast", {
|
||||
currentPodcastId: currentPodcast.id,
|
||||
podcastId,
|
||||
episodeId,
|
||||
title: currentPodcast.title,
|
||||
podcastTitle: currentPodcast.podcastTitle,
|
||||
startTime,
|
||||
loadId: thisLoadId,
|
||||
});
|
||||
}
|
||||
|
||||
if (streamUrl) {
|
||||
const wasHowlerPlayingBeforeLoad = howlerEngine.isPlaying();
|
||||
|
||||
const fallbackDuration =
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0;
|
||||
setDuration(fallbackDuration);
|
||||
|
||||
let format = "mp3";
|
||||
const filePath = currentTrack?.filePath || "";
|
||||
if (filePath) {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase();
|
||||
if (ext === "flac") format = "flac";
|
||||
else if (ext === "m4a" || ext === "aac") format = "mp4";
|
||||
else if (ext === "ogg" || ext === "opus") format = "webm";
|
||||
else if (ext === "wav") format = "wav";
|
||||
}
|
||||
|
||||
howlerEngine.load(streamUrl, false, format);
|
||||
if (playbackType === "podcast" && currentPodcast) {
|
||||
podcastDebugLog("howlerEngine.load()", {
|
||||
url: streamUrl,
|
||||
format,
|
||||
loadId: thisLoadId,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up any previous load listeners before adding new ones
|
||||
if (loadListenerRef.current) {
|
||||
howlerEngine.off("load", loadListenerRef.current);
|
||||
loadListenerRef.current = null;
|
||||
}
|
||||
if (loadErrorListenerRef.current) {
|
||||
howlerEngine.off("loaderror", loadErrorListenerRef.current);
|
||||
loadErrorListenerRef.current = null;
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
if (loadIdRef.current !== thisLoadId) return;
|
||||
|
||||
isLoadingRef.current = false;
|
||||
|
||||
if (startTime > 0) {
|
||||
howlerEngine.seek(startTime);
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast) {
|
||||
podcastDebugLog("loaded", {
|
||||
loadId: thisLoadId,
|
||||
durationHowler: howlerEngine.getDuration(),
|
||||
howlerTime: howlerEngine.getCurrentTime(),
|
||||
actualTime: howlerEngine.getActualCurrentTime(),
|
||||
startTime,
|
||||
canSeek,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldAutoPlay = lastPlayingStateRef.current || wasHowlerPlayingBeforeLoad;
|
||||
|
||||
if (shouldAutoPlay) {
|
||||
howlerEngine.play();
|
||||
if (!lastPlayingStateRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up both listeners
|
||||
howlerEngine.off("load", handleLoaded);
|
||||
howlerEngine.off("loaderror", handleLoadError);
|
||||
loadListenerRef.current = null;
|
||||
loadErrorListenerRef.current = null;
|
||||
};
|
||||
|
||||
const handleLoadError = () => {
|
||||
isLoadingRef.current = false;
|
||||
howlerEngine.off("load", handleLoaded);
|
||||
howlerEngine.off("loaderror", handleLoadError);
|
||||
loadListenerRef.current = null;
|
||||
loadErrorListenerRef.current = null;
|
||||
};
|
||||
|
||||
// Store refs for cleanup on unmount
|
||||
loadListenerRef.current = handleLoaded;
|
||||
loadErrorListenerRef.current = handleLoadError;
|
||||
|
||||
howlerEngine.on("load", handleLoaded);
|
||||
howlerEngine.on("loaderror", handleLoadError);
|
||||
} else {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
}, [currentTrack, currentAudiobook, currentPodcast, playbackType, setDuration]);
|
||||
|
||||
// Check podcast cache status and control canSeek
|
||||
useEffect(() => {
|
||||
if (playbackType !== "podcast") {
|
||||
setCanSeek(true);
|
||||
setDownloadProgress(null);
|
||||
if (cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPodcast) {
|
||||
setCanSeek(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
|
||||
const checkCacheStatus = async () => {
|
||||
try {
|
||||
const status = await api.getPodcastEpisodeCacheStatus(
|
||||
podcastId,
|
||||
episodeId
|
||||
);
|
||||
|
||||
if (status.cached) {
|
||||
setCanSeek(true);
|
||||
setDownloadProgress(null);
|
||||
if (cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
} else {
|
||||
setCanSeek(false);
|
||||
setDownloadProgress(
|
||||
status.downloadProgress ??
|
||||
(status.downloading ? 0 : null)
|
||||
);
|
||||
}
|
||||
|
||||
return status.cached;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to check cache status:",
|
||||
err
|
||||
);
|
||||
setCanSeek(true);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
checkCacheStatus();
|
||||
|
||||
cacheStatusPollingRef.current = setInterval(async () => {
|
||||
const isCached = await checkCacheStatus();
|
||||
if (isCached && cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (cacheStatusPollingRef.current) {
|
||||
clearInterval(cacheStatusPollingRef.current);
|
||||
cacheStatusPollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [currentPodcast, playbackType, setCanSeek, setDownloadProgress]);
|
||||
|
||||
// Keep lastPlayingStateRef always in sync
|
||||
useLayoutEffect(() => {
|
||||
lastPlayingStateRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Handle play/pause changes from UI
|
||||
useEffect(() => {
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
isUserInitiatedRef.current = true;
|
||||
|
||||
if (isPlaying) {
|
||||
howlerEngine.play();
|
||||
} else {
|
||||
howlerEngine.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Handle volume changes
|
||||
useEffect(() => {
|
||||
howlerEngine.setVolume(volume);
|
||||
}, [volume]);
|
||||
|
||||
// Handle mute changes
|
||||
useEffect(() => {
|
||||
howlerEngine.setMuted(isMuted);
|
||||
}, [isMuted]);
|
||||
|
||||
// Poll for podcast cache and reload when ready
|
||||
const startCachePolling = useCallback(
|
||||
(podcastId: string, episodeId: string, targetTime: number) => {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
}
|
||||
|
||||
let pollCount = 0;
|
||||
const maxPolls = 60;
|
||||
|
||||
cachePollingRef.current = setInterval(async () => {
|
||||
pollCount++;
|
||||
|
||||
try {
|
||||
const status = await api.getPodcastEpisodeCacheStatus(
|
||||
podcastId,
|
||||
episodeId
|
||||
);
|
||||
podcastDebugLog("cache poll", {
|
||||
podcastId,
|
||||
episodeId,
|
||||
pollCount,
|
||||
cached: status.cached,
|
||||
downloading: status.downloading,
|
||||
downloadProgress: status.downloadProgress,
|
||||
targetTime,
|
||||
});
|
||||
|
||||
if (status.cached) {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
cachePollingRef.current = null;
|
||||
}
|
||||
|
||||
podcastDebugLog("cache ready -> howlerEngine.reload()", {
|
||||
podcastId,
|
||||
episodeId,
|
||||
targetTime,
|
||||
});
|
||||
// Clean up any previous cache polling load listener
|
||||
if (cachePollingLoadListenerRef.current) {
|
||||
howlerEngine.off("load", cachePollingLoadListenerRef.current);
|
||||
cachePollingLoadListenerRef.current = null;
|
||||
}
|
||||
|
||||
howlerEngine.reload();
|
||||
|
||||
const onLoad = () => {
|
||||
howlerEngine.off("load", onLoad);
|
||||
cachePollingLoadListenerRef.current = null;
|
||||
|
||||
howlerEngine.seek(targetTime);
|
||||
setCurrentTime(targetTime);
|
||||
howlerEngine.play();
|
||||
podcastDebugLog("post-reload seek+play", {
|
||||
podcastId,
|
||||
episodeId,
|
||||
targetTime,
|
||||
howlerTime: howlerEngine.getCurrentTime(),
|
||||
actualTime: howlerEngine.getActualCurrentTime(),
|
||||
});
|
||||
|
||||
setIsBuffering(false);
|
||||
setTargetSeekPosition(null);
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
cachePollingLoadListenerRef.current = onLoad;
|
||||
howlerEngine.on("load", onLoad);
|
||||
} else if (pollCount >= maxPolls) {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
cachePollingRef.current = null;
|
||||
}
|
||||
|
||||
console.warn("[HowlerAudioElement] Cache polling timeout");
|
||||
setIsBuffering(false);
|
||||
setTargetSeekPosition(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HowlerAudioElement] Cache polling error:", error);
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[setCurrentTime, setIsBuffering, setTargetSeekPosition, setIsPlaying]
|
||||
);
|
||||
|
||||
// Handle seeking via event emitter
|
||||
useEffect(() => {
|
||||
const handleSeek = async (time: number) => {
|
||||
const wasPlayingAtSeekStart = howlerEngine.isPlaying();
|
||||
|
||||
setCurrentTime(time);
|
||||
|
||||
if (playbackType === "podcast" && currentPodcast) {
|
||||
if (seekCheckTimeoutRef.current) {
|
||||
clearTimeout(seekCheckTimeoutRef.current);
|
||||
}
|
||||
|
||||
const [podcastId, episodeId] = currentPodcast.id.split(":");
|
||||
try {
|
||||
const status = await api.getPodcastEpisodeCacheStatus(
|
||||
podcastId,
|
||||
episodeId
|
||||
);
|
||||
|
||||
if (status.cached) {
|
||||
podcastDebugLog("seek: cached=true, using reload+seek pattern", {
|
||||
time,
|
||||
podcastId,
|
||||
episodeId,
|
||||
});
|
||||
|
||||
if (seekReloadListenerRef.current) {
|
||||
howlerEngine.off("load", seekReloadListenerRef.current);
|
||||
seekReloadListenerRef.current = null;
|
||||
}
|
||||
|
||||
seekReloadInProgressRef.current = true;
|
||||
|
||||
howlerEngine.reload();
|
||||
|
||||
const onLoad = () => {
|
||||
howlerEngine.off("load", onLoad);
|
||||
seekReloadListenerRef.current = null;
|
||||
seekReloadInProgressRef.current = false;
|
||||
|
||||
howlerEngine.seek(time);
|
||||
setCurrentTime(time);
|
||||
|
||||
if (wasPlayingAtSeekStart) {
|
||||
howlerEngine.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
seekReloadListenerRef.current = onLoad;
|
||||
howlerEngine.on("load", onLoad);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[HowlerAudioElement] Could not check cache status:", e);
|
||||
}
|
||||
|
||||
howlerEngine.seek(time);
|
||||
|
||||
seekCheckTimeoutRef.current = setTimeout(() => {
|
||||
try {
|
||||
const actualPos = howlerEngine.getActualCurrentTime();
|
||||
const seekFailed = time > 30 && actualPos < 30;
|
||||
podcastDebugLog("seek check", {
|
||||
time,
|
||||
actualPos,
|
||||
seekFailed,
|
||||
podcastId,
|
||||
episodeId,
|
||||
});
|
||||
|
||||
if (seekFailed) {
|
||||
howlerEngine.pause();
|
||||
setIsBuffering(true);
|
||||
setTargetSeekPosition(time);
|
||||
setIsPlaying(false);
|
||||
startCachePolling(podcastId, episodeId, time);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[HowlerAudioElement] Seek check error:", e);
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// For audiobooks and tracks, set seeking flag to prevent load effect interference
|
||||
isSeekingRef.current = true;
|
||||
howlerEngine.seek(time);
|
||||
|
||||
// Reset seeking flag after a short delay to allow seek to complete
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const unsubscribe = audioSeekEmitter.subscribe(handleSeek);
|
||||
return unsubscribe;
|
||||
}, [setCurrentTime, playbackType, currentPodcast, setIsBuffering, setTargetSeekPosition, setIsPlaying, startCachePolling]);
|
||||
|
||||
// Cleanup cache polling, seek timeout, and seek-reload listener on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cachePollingRef.current) {
|
||||
clearInterval(cachePollingRef.current);
|
||||
}
|
||||
if (seekCheckTimeoutRef.current) {
|
||||
clearTimeout(seekCheckTimeoutRef.current);
|
||||
}
|
||||
if (seekReloadListenerRef.current) {
|
||||
howlerEngine.off("load", seekReloadListenerRef.current);
|
||||
seekReloadListenerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Periodic progress saving for audiobooks and podcasts
|
||||
useEffect(() => {
|
||||
if (playbackType !== "audiobook" && playbackType !== "podcast") {
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
progressSaveIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
if (playbackType === "audiobook") {
|
||||
saveAudiobookProgress();
|
||||
} else if (playbackType === "podcast") {
|
||||
savePodcastProgress();
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
// Clear any existing interval before creating a new one
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
}
|
||||
progressSaveIntervalRef.current = setInterval(() => {
|
||||
if (playbackType === "audiobook") {
|
||||
saveAudiobookProgress();
|
||||
} else if (playbackType === "podcast") {
|
||||
savePodcastProgress();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
progressSaveIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [playbackType, isPlaying, saveAudiobookProgress, savePodcastProgress]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
howlerEngine.stop();
|
||||
|
||||
if (progressSaveIntervalRef.current) {
|
||||
clearInterval(progressSaveIntervalRef.current);
|
||||
}
|
||||
// Clean up all listener refs to prevent memory leaks
|
||||
if (loadListenerRef.current) {
|
||||
howlerEngine.off("load", loadListenerRef.current);
|
||||
loadListenerRef.current = null;
|
||||
}
|
||||
if (loadErrorListenerRef.current) {
|
||||
howlerEngine.off("loaderror", loadErrorListenerRef.current);
|
||||
loadErrorListenerRef.current = null;
|
||||
}
|
||||
if (cachePollingLoadListenerRef.current) {
|
||||
howlerEngine.off("load", cachePollingLoadListenerRef.current);
|
||||
cachePollingLoadListenerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function KeyboardShortcutsTooltip() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const shortcuts = [
|
||||
{ key: "Space", action: "Play / Pause" },
|
||||
{ key: "→", action: "Seek forward 10s" },
|
||||
{ key: "←", action: "Seek backward 10s" },
|
||||
{ key: "↑", action: "Volume up 10%" },
|
||||
{ key: "↓", action: "Volume down 10%" },
|
||||
{ key: "M", action: "Toggle mute" },
|
||||
{ key: "N", action: "Next track" },
|
||||
{ key: "P", action: "Previous track" },
|
||||
{ key: "S", action: "Toggle shuffle" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="p-1.5 rounded transition-colors text-gray-400 hover:text-white"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isVisible && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 bg-[#1a1a1a] border border-white/10 rounded-lg shadow-2xl shadow-black/50 p-4 z-50 backdrop-blur-xl">
|
||||
{/* Pointer arrow */}
|
||||
<div className="absolute -bottom-1 right-3 w-2 h-2 bg-[#1a1a1a] border-r border-b border-white/10 rotate-45" />
|
||||
|
||||
<h3 className="text-white font-bold text-sm mb-3 flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-gray-400">
|
||||
{shortcut.action}
|
||||
</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-white font-mono text-xs min-w-[40px] text-center">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<p className="text-[10px] text-gray-500 leading-relaxed">
|
||||
Shortcuts work anywhere except when typing in text fields.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||
import { useMediaSession } from "@/hooks/useMediaSession";
|
||||
|
||||
/**
|
||||
* Invisible component that registers keyboard shortcuts and Media Session API
|
||||
* Should be placed at the root level of the app
|
||||
*/
|
||||
export function MediaControlsHandler() {
|
||||
useKeyboardShortcuts();
|
||||
useMediaSession();
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
"use client";
|
||||
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Maximize2,
|
||||
Music as MusicIcon,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
MonitorUp,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Loader2,
|
||||
AudioWaveform,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
|
||||
export function MiniPlayer() {
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
currentTime,
|
||||
duration: playbackDuration,
|
||||
canSeek,
|
||||
downloadProgress,
|
||||
vibeMode,
|
||||
queue,
|
||||
currentIndex,
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
setPlayerMode,
|
||||
setUpcoming,
|
||||
startVibeMode,
|
||||
stopVibeMode,
|
||||
} = useAudio();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
const [isVibeLoading, setIsVibeLoading] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isVibePanelExpanded, setIsVibePanelExpanded] = useState(false);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const lastMediaIdRef = useRef<string | null>(null);
|
||||
|
||||
// Get current track's audio features for vibe comparison
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
// Reset dismissed/minimized state when a new track starts playing
|
||||
const currentMediaId =
|
||||
currentTrack?.id || currentAudiobook?.id || currentPodcast?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMediaId && currentMediaId !== lastMediaIdRef.current) {
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
setIsDismissed(false);
|
||||
setIsMinimized(false);
|
||||
}
|
||||
}, [currentMediaId]);
|
||||
|
||||
// Handle Vibe Match toggle - finds tracks that sound like the current track
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
// If vibe mode is on, turn it off
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, start vibe mode
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks(
|
||||
"vibe",
|
||||
currentTrack.id,
|
||||
50
|
||||
);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
// Get the source track's features from the API response
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
bpm: sf?.bpm,
|
||||
energy: sf?.energy,
|
||||
valence: sf?.valence,
|
||||
arousal: sf?.arousal,
|
||||
danceability: sf?.danceability,
|
||||
keyScale: sf?.keyScale,
|
||||
instrumentalness: sf?.instrumentalness,
|
||||
analysisMode: sf?.analysisMode,
|
||||
// ML Mood predictions
|
||||
moodHappy: sf?.moodHappy,
|
||||
moodSad: sf?.moodSad,
|
||||
moodRelaxed: sf?.moodRelaxed,
|
||||
moodAggressive: sf?.moodAggressive,
|
||||
moodParty: sf?.moodParty,
|
||||
moodAcoustic: sf?.moodAcoustic,
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
// Start vibe mode with the queue IDs (include current track)
|
||||
const queueIds = [
|
||||
currentTrack.id,
|
||||
...response.tracks.map((t: any) => t.id),
|
||||
];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
|
||||
// Add vibe tracks as upcoming (after current song finishes)
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued up next`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-brand" />,
|
||||
});
|
||||
} else {
|
||||
toast.error("Couldn't find matching tracks in your library");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start vibe match:", error);
|
||||
toast.error("Failed to match vibe");
|
||||
} finally {
|
||||
setIsVibeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
// Get current media info
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
let coverUrl: string | null = null;
|
||||
let mediaLink: string | null = null;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
title = currentTrack.title;
|
||||
subtitle = currentTrack.artist?.name || "Unknown Artist";
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 100)
|
||||
: null;
|
||||
mediaLink = currentTrack.album?.id
|
||||
? `/album/${currentTrack.album.id}`
|
||||
: null;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
subtitle = currentAudiobook.author;
|
||||
coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 100)
|
||||
: null;
|
||||
mediaLink = `/audiobooks/${currentAudiobook.id}`;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
title = currentPodcast.title;
|
||||
subtitle = currentPodcast.podcastTitle;
|
||||
coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 100)
|
||||
: null;
|
||||
const podcastId = currentPodcast.id.split(":")[0];
|
||||
mediaLink = `/podcasts/${podcastId}`;
|
||||
} else {
|
||||
title = "Not Playing";
|
||||
subtitle = "Select something to play";
|
||||
}
|
||||
|
||||
// Check if controls should be enabled (only for tracks)
|
||||
const canSkip = playbackType === "track";
|
||||
|
||||
// Calculate progress percentage
|
||||
const duration = (() => {
|
||||
if (playbackType === "podcast" && currentPodcast?.duration) {
|
||||
return currentPodcast.duration;
|
||||
}
|
||||
if (playbackType === "audiobook" && currentAudiobook?.duration) {
|
||||
return currentAudiobook.duration;
|
||||
}
|
||||
return (
|
||||
playbackDuration ||
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0
|
||||
);
|
||||
})();
|
||||
const progress =
|
||||
duration > 0
|
||||
? Math.min(100, Math.max(0, (currentTime / duration) * 100))
|
||||
: 0;
|
||||
|
||||
// Handle progress bar click
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const newTime = percentage * duration;
|
||||
seek(newTime);
|
||||
};
|
||||
|
||||
const seekEnabled = hasMedia && canSeek;
|
||||
|
||||
// ============================================
|
||||
// MOBILE/TABLET: Spotify-style compact player
|
||||
// ============================================
|
||||
if (isMobileOrTablet) {
|
||||
// Don't render if no media
|
||||
if (!hasMedia) return null;
|
||||
|
||||
// Handle swipe gestures:
|
||||
// - Swipe RIGHT: minimize to tab
|
||||
// - Swipe LEFT + playing: open overlay
|
||||
// - Swipe LEFT + not playing: dismiss completely
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return;
|
||||
const deltaX = e.touches[0].clientX - touchStartX.current;
|
||||
// Track both directions, cap at ±150px
|
||||
setSwipeOffset(Math.max(-150, Math.min(150, deltaX)));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchStartX.current === null) return;
|
||||
|
||||
// Swipe RIGHT (positive) → minimize to tab
|
||||
if (swipeOffset > 80) {
|
||||
setIsMinimized(true);
|
||||
}
|
||||
// Swipe LEFT (negative) → open overlay OR dismiss
|
||||
else if (swipeOffset < -80) {
|
||||
if (isPlaying) {
|
||||
// If playing, open full-screen overlay
|
||||
setPlayerMode("overlay");
|
||||
} else {
|
||||
// If not playing, dismiss completely
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset
|
||||
setSwipeOffset(0);
|
||||
touchStartX.current = null;
|
||||
};
|
||||
|
||||
// Completely dismissed - don't render anything
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimized tab - small pill on RIGHT to bring player back
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className="fixed right-0 z-50 bg-gradient-to-l from-[#f5c518] via-[#e6a700] to-[#a855f7] rounded-l-full pl-3 pr-2 py-2 shadow-lg flex items-center gap-2 transition-transform hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 16px)",
|
||||
}}
|
||||
title="Show player"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-black" />
|
||||
{coverUrl ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden ring-2 ring-black/20">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<MusicIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate opacity for swipe feedback
|
||||
const swipeOpacity = 1 - Math.abs(swipeOffset) / 200;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed left-2 right-2 z-50 rounded-xl overflow-hidden shadow-xl transition-transform"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
transform: `translateX(${swipeOffset}px)`,
|
||||
opacity: swipeOpacity,
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Gradient background - richer, more vibrant colors */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#1a1a2e] via-[#2d1847] to-[#1a1a2e]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#f5c518]/30 via-[#a855f7]/40 to-[#f5c518]/30" />
|
||||
{/* Edge glow effects */}
|
||||
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-[#f5c518] via-[#e6a700] to-[#f5c518]" />
|
||||
<div className="absolute inset-y-0 right-0 w-1 bg-gradient-to-b from-[#a855f7] via-[#7c3aed] to-[#a855f7]" />
|
||||
|
||||
{/* Progress bar at top */}
|
||||
<div className="relative h-[2px] bg-white/20 w-full">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#f5c518] via-[#e6a700] to-[#a855f7] transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player content */}
|
||||
<div
|
||||
className="relative flex items-center gap-2.5 px-3 py-2 cursor-pointer"
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
>
|
||||
{/* Album Art */}
|
||||
<div className="relative w-10 h-10 flex-shrink-0 rounded-md overflow-hidden bg-black/30 shadow-md">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-[13px] font-medium truncate leading-tight">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-gray-300/70 text-[11px] truncate leading-tight">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls - Vibe & Play/Pause */}
|
||||
<div
|
||||
className="flex items-center gap-1 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Vibe Button */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"w-9 h-9 flex items-center justify-center rounded-full transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-600"
|
||||
: vibeMode
|
||||
? "text-[#f5c518]"
|
||||
: "text-white/80 hover:text-[#f5c518]"
|
||||
)}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-[18px] h-[18px] animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-[18px] h-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isBuffering) {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-full flex items-center justify-center transition shadow-md",
|
||||
isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-white text-black hover:scale-105"
|
||||
)}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-[18px] h-[18px] animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-[18px] h-[18px]" />
|
||||
) : (
|
||||
<Play className="w-[18px] h-[18px] ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DESKTOP: Full-featured mini player
|
||||
// ============================================
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Collapsible Vibe Panel - slides up from player */}
|
||||
{vibeMode && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 right-0 bottom-full transition-all duration-300 ease-out overflow-hidden border-t border-white/[0.08]",
|
||||
isVibePanelExpanded ? "max-h-[500px]" : "max-h-0"
|
||||
)}
|
||||
>
|
||||
<div className="bg-[#121212]">
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="inline"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vibe Tab - shows when vibe mode is active */}
|
||||
{vibeMode && (
|
||||
<button
|
||||
onClick={() => setIsVibePanelExpanded(!isVibePanelExpanded)}
|
||||
className={cn(
|
||||
"absolute -top-8 left-1/2 -translate-x-1/2 z-10",
|
||||
"flex items-center gap-1.5 px-3 py-1 rounded-t-lg",
|
||||
"bg-[#121212] border border-b-0 border-white/[0.08]",
|
||||
"text-xs font-medium transition-colors",
|
||||
isVibePanelExpanded
|
||||
? "text-brand"
|
||||
: "text-white/70 hover:text-brand"
|
||||
)}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
{isVibePanelExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="bg-gradient-to-t from-[#0a0a0a] via-[#0f0f0f] to-[#0a0a0a] border-t border-white/[0.08] relative backdrop-blur-xl">
|
||||
{/* Subtle top glow */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 h-1 bg-white/[0.15] transition-all",
|
||||
seekEnabled
|
||||
? "cursor-pointer group hover:h-2"
|
||||
: "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleProgressClick : undefined}
|
||||
title={
|
||||
!hasMedia
|
||||
? undefined
|
||||
: !canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached"
|
||||
: "Click to seek"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
seekEnabled
|
||||
? "bg-white"
|
||||
: hasMedia
|
||||
? "bg-white/50"
|
||||
: "bg-gray-600"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
{seekEnabled && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg shadow-white/50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Content */}
|
||||
<div className="px-3 py-2.5 pt-3">
|
||||
{/* Artwork & Track Info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Artwork */}
|
||||
{mediaLink ? (
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="relative flex-shrink-0 group w-12 h-12"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent rounded-full blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="relative flex-shrink-0 w-12 h-12">
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{mediaLink ? (
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<p className="text-white font-semibold truncate text-sm">
|
||||
{title}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-white font-semibold truncate text-sm">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-400 truncate text-xs">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mode Switch Buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setPlayerMode("full")}
|
||||
className="text-gray-400 hover:text-white transition p-1"
|
||||
title="Show bottom player"
|
||||
>
|
||||
<MonitorUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className={cn(
|
||||
"transition p-1",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
{/* Shuffle */}
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? isShuffle
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={canSkip ? "Shuffle" : "Shuffle (music only)"}
|
||||
>
|
||||
<Shuffle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 30s */}
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
disabled={!hasMedia}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors relative",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={previous}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={
|
||||
canSkip ? "Previous" : "Previous (music only)"
|
||||
}
|
||||
>
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={
|
||||
isBuffering
|
||||
? undefined
|
||||
: isPlaying
|
||||
? pause
|
||||
: resume
|
||||
}
|
||||
disabled={!hasMedia || isBuffering}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center transition",
|
||||
hasMedia && !isBuffering
|
||||
? "bg-white text-black hover:scale-105"
|
||||
: isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={canSkip ? "Next" : "Next (music only)"}
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 30s */}
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
disabled={!hasMedia}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors relative",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
disabled={!hasMedia || !canSkip}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
hasMedia && canSkip
|
||||
? repeatMode !== "off"
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
title={
|
||||
canSkip
|
||||
? repeatMode === "off"
|
||||
? "Repeat: Off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat: All"
|
||||
: "Repeat: One"
|
||||
: "Repeat (music only)"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Repeat className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Vibe Mode Toggle */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!hasMedia || !canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"rounded p-1.5 transition-colors",
|
||||
!hasMedia || !canSkip
|
||||
? "text-gray-600 cursor-not-allowed"
|
||||
: vibeMode
|
||||
? "text-brand hover:text-brand-hover"
|
||||
: "text-gray-400 hover:text-brand"
|
||||
)}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe - find similar sounding tracks"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<KeyboardShortcutsTooltip />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
ChevronDown,
|
||||
Music as MusicIcon,
|
||||
Shuffle,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
AudioWaveform,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { toast } from "sonner";
|
||||
import { VibeComparisonArt } from "./VibeOverlay";
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
|
||||
export function OverlayPlayer() {
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
canSeek,
|
||||
downloadProgress,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
vibeMode,
|
||||
queue,
|
||||
currentIndex,
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
returnToPreviousMode,
|
||||
seek,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setUpcoming,
|
||||
startVibeMode,
|
||||
stopVibeMode,
|
||||
duration: playbackDuration,
|
||||
} = useAudio();
|
||||
|
||||
// Get current track's audio features for vibe comparison
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
// Swipe state for track skipping
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isVibeLoading, setIsVibeLoading] = useState(false);
|
||||
|
||||
const duration = (() => {
|
||||
if (playbackType === "podcast" && currentPodcast?.duration) {
|
||||
return currentPodcast.duration;
|
||||
}
|
||||
if (playbackType === "audiobook" && currentAudiobook?.duration) {
|
||||
return currentAudiobook.duration;
|
||||
}
|
||||
return (
|
||||
playbackDuration ||
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0
|
||||
);
|
||||
})();
|
||||
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) return null;
|
||||
|
||||
const displayTime = (() => {
|
||||
if (currentTime > 0) return currentTime;
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.currentTime;
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
return currentTime;
|
||||
})();
|
||||
|
||||
const progress = duration > 0 ? Math.min(100, Math.max(0, (displayTime / duration) * 100)) : 0;
|
||||
const seekEnabled = canSeek;
|
||||
const canSkip = playbackType === "track";
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const x = clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const time = percentage * duration;
|
||||
seek(time);
|
||||
};
|
||||
|
||||
// Swipe handlers for track skipping
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return;
|
||||
const deltaX = e.touches[0].clientX - touchStartX.current;
|
||||
setSwipeOffset(Math.max(-100, Math.min(100, deltaX)));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchStartX.current === null) return;
|
||||
|
||||
if (canSkip) {
|
||||
if (swipeOffset > 60) {
|
||||
previous();
|
||||
} else if (swipeOffset < -60) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
setSwipeOffset(0);
|
||||
touchStartX.current = null;
|
||||
};
|
||||
|
||||
// Handle Vibe toggle
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks("vibe", currentTrack.id, 50);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
bpm: sf?.bpm,
|
||||
energy: sf?.energy,
|
||||
valence: sf?.valence,
|
||||
arousal: sf?.arousal,
|
||||
danceability: sf?.danceability,
|
||||
keyScale: sf?.keyScale,
|
||||
instrumentalness: sf?.instrumentalness,
|
||||
analysisMode: sf?.analysisMode,
|
||||
// ML Mood predictions
|
||||
moodHappy: sf?.moodHappy,
|
||||
moodSad: sf?.moodSad,
|
||||
moodRelaxed: sf?.moodRelaxed,
|
||||
moodAggressive: sf?.moodAggressive,
|
||||
moodParty: sf?.moodParty,
|
||||
moodAcoustic: sf?.moodAcoustic,
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-[#f5c518]" />,
|
||||
});
|
||||
} else {
|
||||
toast.error("Couldn't find matching tracks");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start vibe match:", error);
|
||||
toast.error("Failed to match vibe");
|
||||
} finally {
|
||||
setIsVibeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get current media info
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
let coverUrl: string | null = null;
|
||||
let albumLink: string | null = null;
|
||||
let artistLink: string | null = null;
|
||||
let mediaLink: string | null = null;
|
||||
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
title = currentTrack.title;
|
||||
subtitle = currentTrack.artist?.name || "Unknown Artist";
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 500)
|
||||
: null;
|
||||
albumLink = currentTrack.album?.id ? `/album/${currentTrack.album.id}` : null;
|
||||
artistLink = currentTrack.artist?.id ? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}` : null;
|
||||
mediaLink = albumLink;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
subtitle = currentAudiobook.author;
|
||||
coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 500)
|
||||
: null;
|
||||
mediaLink = `/audiobooks/${currentAudiobook.id}`;
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
title = currentPodcast.title;
|
||||
subtitle = currentPodcast.podcastTitle;
|
||||
coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 500)
|
||||
: null;
|
||||
const podcastId = currentPodcast.id.split(":")[0];
|
||||
mediaLink = `/podcasts/${podcastId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-b from-[#1a1a2e] via-[#121218] to-[#000000] z-[9999] flex flex-col overflow-hidden"
|
||||
onTouchStart={isMobileOrTablet ? handleTouchStart : undefined}
|
||||
onTouchMove={isMobileOrTablet ? handleTouchMove : undefined}
|
||||
onTouchEnd={isMobileOrTablet ? handleTouchEnd : undefined}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ paddingTop: 'calc(12px + env(safe-area-inset-top))' }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
returnToPreviousMode();
|
||||
}}
|
||||
className="text-gray-400 hover:text-white transition-colors p-2 -ml-2 rounded-full hover:bg-white/10"
|
||||
title="Close"
|
||||
>
|
||||
<ChevronDown className="w-7 h-7" />
|
||||
</button>
|
||||
|
||||
{/* Now Playing indicator */}
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest font-medium">
|
||||
Now Playing
|
||||
</span>
|
||||
|
||||
<div className="w-11" /> {/* Spacer for centering */}
|
||||
</div>
|
||||
|
||||
{/* Main Content - Portrait vs Landscape */}
|
||||
<div className="flex-1 flex flex-col landscape:flex-row items-center justify-center px-6 pb-6 landscape:px-8 landscape:gap-8 overflow-hidden">
|
||||
|
||||
{/* Artwork Section */}
|
||||
<div
|
||||
className="w-full max-w-[280px] landscape:max-w-[240px] landscape:w-[240px] aspect-square flex-shrink-0 mb-6 landscape:mb-0 relative"
|
||||
style={{
|
||||
transform: `translateX(${swipeOffset * 0.5}px)`,
|
||||
opacity: 1 - Math.abs(swipeOffset) / 200
|
||||
}}
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 rounded-2xl blur-2xl opacity-50",
|
||||
vibeMode
|
||||
? "bg-gradient-to-br from-brand/30 via-transparent to-purple-500/30"
|
||||
: "bg-gradient-to-br from-[#f5c518]/20 via-transparent to-[#a855f7]/20"
|
||||
)} />
|
||||
|
||||
{/* Album art OR Vibe Comparison when in vibe mode */}
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-2xl overflow-hidden shadow-2xl">
|
||||
{vibeMode && currentTrackFeatures ? (
|
||||
<VibeComparisonArt currentTrackFeatures={currentTrackFeatures} />
|
||||
) : coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="280px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-24 h-24 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swipe hint indicators */}
|
||||
{canSkip && isMobileOrTablet && Math.abs(swipeOffset) > 20 && (
|
||||
<div className={cn(
|
||||
"absolute top-1/2 -translate-y-1/2 text-white/60",
|
||||
swipeOffset > 0 ? "-left-8" : "-right-8"
|
||||
)}>
|
||||
{swipeOffset > 0 ? (
|
||||
<SkipBack className="w-6 h-6" />
|
||||
) : (
|
||||
<SkipForward className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info & Controls Section */}
|
||||
<div className="w-full max-w-[320px] landscape:max-w-[280px] landscape:flex-1 flex flex-col">
|
||||
{/* Track Info */}
|
||||
<div className="text-center landscape:text-left mb-6">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<h1 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
</Link>
|
||||
) : (
|
||||
<h1 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link href={artistLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<p className="text-base text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-base text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-1 bg-white/20 rounded-full mb-2",
|
||||
seekEnabled ? "cursor-pointer" : "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleSeek : undefined}
|
||||
title={!canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%...`
|
||||
: "Downloading..."
|
||||
: "Tap to seek"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-150",
|
||||
seekEnabled
|
||||
? "bg-gradient-to-r from-[#f5c518] to-[#a855f7]"
|
||||
: "bg-white/40"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 font-medium tabular-nums">
|
||||
<span>{formatTime(displayTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-6 mb-6">
|
||||
<button
|
||||
onClick={previous}
|
||||
className={cn(
|
||||
"text-white/80 hover:text-white transition-all hover:scale-110",
|
||||
!canSkip && "opacity-30 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
disabled={!canSkip}
|
||||
title={canSkip ? "Previous" : "Skip only for music"}
|
||||
>
|
||||
<SkipBack className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={isPlaying ? pause : resume}
|
||||
className="w-16 h-16 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-all shadow-xl"
|
||||
title={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-7 h-7" />
|
||||
) : (
|
||||
<Play className="w-7 h-7 ml-1" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className={cn(
|
||||
"text-white/80 hover:text-white transition-all hover:scale-110",
|
||||
!canSkip && "opacity-30 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
disabled={!canSkip}
|
||||
title={canSkip ? "Next" : "Skip only for music"}
|
||||
>
|
||||
<SkipForward className="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
disabled={!canSkip}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-700 cursor-not-allowed"
|
||||
: isShuffle
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-white"
|
||||
)}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
disabled={!canSkip}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-700 cursor-not-allowed"
|
||||
: repeatMode !== "off"
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-white"
|
||||
)}
|
||||
title={repeatMode === "one" ? "Repeat One" : repeatMode === "all" ? "Repeat All" : "Repeat Off"}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-5 h-5" />
|
||||
) : (
|
||||
<Repeat className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-700 cursor-not-allowed"
|
||||
: vibeMode
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-[#f5c518]"
|
||||
)}
|
||||
title={vibeMode ? "Turn off vibe mode" : "Match this vibe"}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Safe area padding at bottom */}
|
||||
<div style={{ height: 'env(safe-area-inset-bottom)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { usePlayerMode } from "@/hooks/usePlayerMode";
|
||||
|
||||
export function PlayerModeWrapper({ children }: { children: ReactNode }) {
|
||||
// This component exists solely to call the usePlayerMode hook
|
||||
// which must be in a client component
|
||||
usePlayerMode();
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { MiniPlayer } from "./MiniPlayer";
|
||||
import { FullPlayer } from "./FullPlayer";
|
||||
import { OverlayPlayer } from "./OverlayPlayer";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* UniversalPlayer - Manages player UI rendering based on mode and device
|
||||
* NOTE: The AudioElement is rendered by ConditionalAudioProvider, NOT here
|
||||
* This component only handles the UI (MiniPlayer, FullPlayer, OverlayPlayer)
|
||||
*
|
||||
* Mobile/Tablet behavior:
|
||||
* - Defaults to overlay mode when new media starts
|
||||
* - If user closes overlay, shows mini player at bottom
|
||||
* - No full-width player on mobile
|
||||
*/
|
||||
export function UniversalPlayer() {
|
||||
const { playerMode, setPlayerMode, currentTrack, currentAudiobook, currentPodcast, isPlaying } =
|
||||
useAudio();
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
const lastMediaIdRef = useRef<string | null>(null);
|
||||
const hasAutoSwitchedRef = useRef(false);
|
||||
|
||||
// Auto-switch to overlay mode on mobile/tablet when user starts playing new media
|
||||
useEffect(() => {
|
||||
if (!isMobileOrTablet) return;
|
||||
|
||||
const currentMediaId =
|
||||
currentTrack?.id ||
|
||||
currentAudiobook?.id ||
|
||||
currentPodcast?.id ||
|
||||
null;
|
||||
|
||||
// Only switch to overlay if:
|
||||
// 1. Media changed to a new track
|
||||
// 2. User is actively playing (not just page load with paused track)
|
||||
// 3. We haven't already auto-switched for this track
|
||||
const mediaChanged = currentMediaId && currentMediaId !== lastMediaIdRef.current;
|
||||
|
||||
if (mediaChanged && isPlaying && !hasAutoSwitchedRef.current) {
|
||||
setPlayerMode("overlay");
|
||||
hasAutoSwitchedRef.current = true;
|
||||
}
|
||||
|
||||
// Reset flag when media changes
|
||||
if (currentMediaId !== lastMediaIdRef.current) {
|
||||
hasAutoSwitchedRef.current = false;
|
||||
}
|
||||
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
}, [currentTrack?.id, currentAudiobook?.id, currentPodcast?.id, isPlaying, isMobileOrTablet, setPlayerMode]);
|
||||
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Conditional UI rendering based on mode and device */}
|
||||
{/* Note: AudioElement is rendered by ConditionalAudioProvider */}
|
||||
{/* Always show player UI (like Spotify), even when no media is playing */}
|
||||
{playerMode === "overlay" && hasMedia ? (
|
||||
<OverlayPlayer />
|
||||
) : isMobileOrTablet ? (
|
||||
/* On mobile/tablet: only mini player (no full player) */
|
||||
<MiniPlayer />
|
||||
) : (
|
||||
/* Desktop: always show full-width bottom player */
|
||||
<FullPlayer />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface AudioFeatures {
|
||||
bpm?: number | null;
|
||||
energy?: number | null;
|
||||
valence?: number | null;
|
||||
danceability?: number | null;
|
||||
keyScale?: string | null;
|
||||
}
|
||||
|
||||
interface VibeGraphProps {
|
||||
className?: string;
|
||||
currentTrackFeatures?: AudioFeatures | null;
|
||||
}
|
||||
|
||||
// Feature labels and their normalization ranges
|
||||
const FEATURES = [
|
||||
{ key: "energy", label: "Energy", min: 0, max: 1 },
|
||||
{ key: "valence", label: "Mood", min: 0, max: 1 },
|
||||
{ key: "danceability", label: "Dance", min: 0, max: 1 },
|
||||
{ key: "bpm", label: "BPM", min: 60, max: 200 },
|
||||
] as const;
|
||||
|
||||
function normalizeValue(value: number | null | undefined, min: number, max: number): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
}
|
||||
|
||||
export function VibeGraph({ className, currentTrackFeatures }: VibeGraphProps) {
|
||||
const { vibeMode, vibeSourceFeatures } = useAudioState();
|
||||
|
||||
// Calculate normalized values for both source and current track
|
||||
const { sourceValues, currentValues } = useMemo(() => {
|
||||
const source: number[] = [];
|
||||
const current: number[] = [];
|
||||
|
||||
FEATURES.forEach((feature) => {
|
||||
const sourceVal = vibeSourceFeatures?.[feature.key as keyof AudioFeatures];
|
||||
const currentVal = currentTrackFeatures?.[feature.key as keyof AudioFeatures];
|
||||
|
||||
source.push(normalizeValue(sourceVal as number, feature.min, feature.max));
|
||||
current.push(normalizeValue(currentVal as number, feature.min, feature.max));
|
||||
});
|
||||
|
||||
return { sourceValues: source, currentValues: current };
|
||||
}, [vibeSourceFeatures, currentTrackFeatures]);
|
||||
|
||||
// Don't render if not in vibe mode
|
||||
if (!vibeMode) return null;
|
||||
|
||||
// SVG dimensions
|
||||
const size = 80;
|
||||
const center = size / 2;
|
||||
const maxRadius = 32;
|
||||
|
||||
// Calculate polygon points for radar chart
|
||||
const getPolygonPoints = (values: number[]) => {
|
||||
const angleStep = (2 * Math.PI) / values.length;
|
||||
return values
|
||||
.map((value, i) => {
|
||||
const angle = angleStep * i - Math.PI / 2; // Start from top
|
||||
const radius = value * maxRadius;
|
||||
const x = center + radius * Math.cos(angle);
|
||||
const y = center + radius * Math.sin(angle);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Calculate label positions
|
||||
const getLabelPosition = (index: number) => {
|
||||
const angleStep = (2 * Math.PI) / FEATURES.length;
|
||||
const angle = angleStep * index - Math.PI / 2;
|
||||
const radius = maxRadius + 8;
|
||||
return {
|
||||
x: center + radius * Math.cos(angle),
|
||||
y: center + radius * Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
// Calculate match percentage
|
||||
const matchScore = useMemo(() => {
|
||||
if (!vibeSourceFeatures || !currentTrackFeatures) return null;
|
||||
|
||||
let totalDiff = 0;
|
||||
let count = 0;
|
||||
|
||||
FEATURES.forEach((feature, i) => {
|
||||
if (sourceValues[i] > 0 || currentValues[i] > 0) {
|
||||
totalDiff += Math.abs(sourceValues[i] - currentValues[i]);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
if (count === 0) return null;
|
||||
return Math.round((1 - totalDiff / count) * 100);
|
||||
}, [sourceValues, currentValues, vibeSourceFeatures, currentTrackFeatures]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative">
|
||||
<svg width={size} height={size} className="opacity-90">
|
||||
{/* Background circles */}
|
||||
{[0.25, 0.5, 0.75, 1].map((scale) => (
|
||||
<circle
|
||||
key={scale}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={maxRadius * scale}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Axis lines */}
|
||||
{FEATURES.map((_, i) => {
|
||||
const angleStep = (2 * Math.PI) / FEATURES.length;
|
||||
const angle = angleStep * i - Math.PI / 2;
|
||||
const x = center + maxRadius * Math.cos(angle);
|
||||
const y = center + maxRadius * Math.sin(angle);
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="rgba(255,255,255,0.15)"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Source track polygon (yellow, dashed) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(sourceValues)}
|
||||
fill="rgba(236, 178, 0, 0.15)"
|
||||
stroke="#ecb200"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3,2"
|
||||
/>
|
||||
|
||||
{/* Current track polygon (white, solid) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(currentValues)}
|
||||
fill="rgba(255, 255, 255, 0.1)"
|
||||
stroke="rgba(255, 255, 255, 0.8)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Feature labels */}
|
||||
{FEATURES.map((feature, i) => {
|
||||
const pos = getLabelPosition(i);
|
||||
return (
|
||||
<text
|
||||
key={feature.key}
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-gray-500 text-[6px] font-medium"
|
||||
>
|
||||
{feature.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Match score */}
|
||||
{matchScore !== null && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold tabular-nums",
|
||||
matchScore >= 80 ? "text-green-400" :
|
||||
matchScore >= 60 ? "text-[#ecb200]" :
|
||||
"text-gray-400"
|
||||
)}
|
||||
>
|
||||
{matchScore}%
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-500">match</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState, AudioFeatures } from "@/lib/audio-state-context";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
AudioWaveform,
|
||||
Music,
|
||||
Zap,
|
||||
Heart,
|
||||
Footprints,
|
||||
Gauge,
|
||||
Smile,
|
||||
Frown,
|
||||
Coffee,
|
||||
Flame,
|
||||
PartyPopper,
|
||||
Guitar,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
|
||||
interface VibeOverlayProps {
|
||||
className?: string;
|
||||
currentTrackFeatures?: AudioFeatures | null;
|
||||
variant?: "floating" | "inline";
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Extended features for detailed analysis
|
||||
interface ExtendedFeatures extends AudioFeatures {
|
||||
arousal?: number | null;
|
||||
instrumentalness?: number | null;
|
||||
acousticness?: number | null;
|
||||
// All 7 ML mood predictions
|
||||
moodHappy?: number | null;
|
||||
moodSad?: number | null;
|
||||
moodRelaxed?: number | null;
|
||||
moodAggressive?: number | null;
|
||||
moodParty?: number | null;
|
||||
moodAcoustic?: number | null;
|
||||
moodElectronic?: number | null;
|
||||
analysisMode?: string | null;
|
||||
}
|
||||
|
||||
// Feature configuration with icons and descriptions
|
||||
const FEATURE_CONFIG = [
|
||||
{
|
||||
key: "energy",
|
||||
label: "Energy",
|
||||
icon: Zap,
|
||||
min: 0,
|
||||
max: 1,
|
||||
description: "Intensity and power",
|
||||
lowLabel: "Calm",
|
||||
highLabel: "Intense",
|
||||
unit: null as string | null,
|
||||
},
|
||||
{
|
||||
key: "valence",
|
||||
label: "Mood",
|
||||
icon: Heart,
|
||||
min: 0,
|
||||
max: 1,
|
||||
description: "Emotional positivity",
|
||||
lowLabel: "Melancholic",
|
||||
highLabel: "Happy",
|
||||
unit: null as string | null,
|
||||
},
|
||||
{
|
||||
key: "danceability",
|
||||
label: "Groove",
|
||||
icon: Footprints,
|
||||
min: 0,
|
||||
max: 1,
|
||||
description: "Rhythm & movement",
|
||||
lowLabel: "Freeform",
|
||||
highLabel: "Danceable",
|
||||
unit: null as string | null,
|
||||
},
|
||||
{
|
||||
key: "bpm",
|
||||
label: "Tempo",
|
||||
icon: Gauge,
|
||||
min: 60,
|
||||
max: 180,
|
||||
description: "Beats per minute",
|
||||
lowLabel: "Slow",
|
||||
highLabel: "Fast",
|
||||
unit: "BPM" as string | null,
|
||||
},
|
||||
{
|
||||
key: "arousal",
|
||||
label: "Arousal",
|
||||
icon: AudioWaveform,
|
||||
min: 0,
|
||||
max: 1,
|
||||
description: "Excitement level",
|
||||
lowLabel: "Peaceful",
|
||||
highLabel: "Energetic",
|
||||
unit: null as string | null,
|
||||
},
|
||||
];
|
||||
|
||||
// ML Mood predictions (Enhanced mode only)
|
||||
const ML_MOOD_CONFIG = [
|
||||
{ key: "moodHappy", label: "Happy", icon: Smile, color: "text-yellow-400" },
|
||||
{ key: "moodSad", label: "Sad", icon: Frown, color: "text-blue-400" },
|
||||
{
|
||||
key: "moodRelaxed",
|
||||
label: "Relaxed",
|
||||
icon: Coffee,
|
||||
color: "text-green-400",
|
||||
},
|
||||
{
|
||||
key: "moodAggressive",
|
||||
label: "Aggressive",
|
||||
icon: Flame,
|
||||
color: "text-red-400",
|
||||
},
|
||||
{
|
||||
key: "moodParty",
|
||||
label: "Party",
|
||||
icon: PartyPopper,
|
||||
color: "text-pink-400",
|
||||
},
|
||||
{
|
||||
key: "moodAcoustic",
|
||||
label: "Acoustic",
|
||||
icon: Guitar,
|
||||
color: "text-amber-400",
|
||||
},
|
||||
{
|
||||
key: "moodElectronic",
|
||||
label: "Electronic",
|
||||
icon: Radio,
|
||||
color: "text-purple-400",
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeValue(
|
||||
value: number | null | undefined,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
}
|
||||
|
||||
function getMatchColor(diff: number): string {
|
||||
if (diff < 0.15) return "text-green-400";
|
||||
if (diff < 0.3) return "text-brand";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
function getMatchBgColor(diff: number): string {
|
||||
if (diff < 0.15) return "bg-green-500/20";
|
||||
if (diff < 0.3) return "bg-brand/20";
|
||||
return "bg-red-500/20";
|
||||
}
|
||||
|
||||
export function VibeOverlay({
|
||||
className,
|
||||
currentTrackFeatures,
|
||||
variant = "floating",
|
||||
onClose,
|
||||
}: VibeOverlayProps) {
|
||||
const { vibeMode, vibeSourceFeatures } = useAudioState();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
// Calculate match scores for each feature
|
||||
const featureComparisons = useMemo(() => {
|
||||
if (!vibeSourceFeatures || !currentTrackFeatures) return null;
|
||||
|
||||
return FEATURE_CONFIG.map((feature) => {
|
||||
const sourceVal = (vibeSourceFeatures as ExtendedFeatures)?.[
|
||||
feature.key as keyof ExtendedFeatures
|
||||
];
|
||||
const currentVal = (currentTrackFeatures as ExtendedFeatures)?.[
|
||||
feature.key as keyof ExtendedFeatures
|
||||
];
|
||||
|
||||
const sourceNorm = normalizeValue(
|
||||
sourceVal as number,
|
||||
feature.min,
|
||||
feature.max
|
||||
);
|
||||
const currentNorm = normalizeValue(
|
||||
currentVal as number,
|
||||
feature.min,
|
||||
feature.max
|
||||
);
|
||||
const diff = Math.abs(sourceNorm - currentNorm);
|
||||
const match = Math.round((1 - diff) * 100);
|
||||
|
||||
return {
|
||||
...feature,
|
||||
sourceValue: sourceVal,
|
||||
currentValue: currentVal,
|
||||
sourceNorm,
|
||||
currentNorm,
|
||||
diff,
|
||||
match,
|
||||
hasData: sourceVal != null && currentVal != null,
|
||||
};
|
||||
}).filter((f) => f.hasData);
|
||||
}, [vibeSourceFeatures, currentTrackFeatures]);
|
||||
|
||||
// Overall match score
|
||||
const overallMatch = useMemo(() => {
|
||||
if (!featureComparisons || featureComparisons.length === 0) return null;
|
||||
const totalMatch = featureComparisons.reduce(
|
||||
(sum, f) => sum + f.match,
|
||||
0
|
||||
);
|
||||
return Math.round(totalMatch / featureComparisons.length);
|
||||
}, [featureComparisons]);
|
||||
|
||||
// Don't render if not in vibe mode
|
||||
if (!vibeMode) return null;
|
||||
|
||||
const isFloating = variant === "floating";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-black/90 backdrop-blur-xl border border-white/10 text-white",
|
||||
isFloating &&
|
||||
"fixed bottom-24 right-4 z-50 rounded-2xl shadow-2xl w-72 animate-in slide-in-from-right-5 duration-300",
|
||||
!isFloating && "rounded-xl w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-3 border-b border-white/10 cursor-pointer",
|
||||
isFloating && "hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => isFloating && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AudioWaveform className="w-4 h-4 text-brand" />
|
||||
<span className="text-sm font-semibold">Vibe Analysis</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{overallMatch !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold tabular-nums",
|
||||
overallMatch >= 80
|
||||
? "text-green-400"
|
||||
: overallMatch >= 60
|
||||
? "text-brand"
|
||||
: "text-red-400"
|
||||
)}
|
||||
>
|
||||
{overallMatch}%
|
||||
</span>
|
||||
)}
|
||||
{isFloating && onClose && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="p-1 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* What is this? */}
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
Comparing current track to your vibe source.
|
||||
{(vibeSourceFeatures as ExtendedFeatures)
|
||||
?.analysisMode === "enhanced"
|
||||
? " Using ML mood predictions for accurate matching."
|
||||
: " Using audio signal analysis for matching."}
|
||||
</p>
|
||||
|
||||
{/* Feature Bars */}
|
||||
<div className="space-y-3">
|
||||
{featureComparisons?.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div key={feature.key} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-xs font-medium text-gray-300">
|
||||
{feature.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold tabular-nums",
|
||||
getMatchColor(feature.diff)
|
||||
)}
|
||||
>
|
||||
{feature.match}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comparison Bar */}
|
||||
<div className="relative h-2 bg-white/5 rounded-full overflow-hidden">
|
||||
{/* Source marker (yellow dashed) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-brand z-10"
|
||||
style={{
|
||||
left: `${
|
||||
feature.sourceNorm * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Current value bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 left-0 rounded-full transition-all duration-500",
|
||||
getMatchBgColor(feature.diff)
|
||||
)}
|
||||
style={{
|
||||
width: `${
|
||||
feature.currentNorm * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Current marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 bg-white rounded-full transition-all duration-500"
|
||||
style={{
|
||||
left: `calc(${
|
||||
feature.currentNorm * 100
|
||||
}% - 2px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex justify-between text-[10px] text-gray-600">
|
||||
<span>{feature.lowLabel}</span>
|
||||
{feature.unit &&
|
||||
feature.currentValue && (
|
||||
<span className="text-gray-400">
|
||||
{Math.round(
|
||||
feature.currentValue as number
|
||||
)}{" "}
|
||||
{feature.unit}
|
||||
</span>
|
||||
)}
|
||||
<span>{feature.highLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ML Moods (Enhanced mode only) */}
|
||||
{(vibeSourceFeatures as ExtendedFeatures)?.analysisMode ===
|
||||
"enhanced" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 pb-1 border-b border-white/5">
|
||||
<Music className="w-3 h-3 text-gray-500" />
|
||||
<span className="text-[10px] font-medium text-gray-400 uppercase tracking-wider">
|
||||
ML Mood Analysis
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{ML_MOOD_CONFIG.map((mood) => {
|
||||
const sourceVal = (
|
||||
vibeSourceFeatures as ExtendedFeatures
|
||||
)?.[mood.key as keyof ExtendedFeatures] as
|
||||
| number
|
||||
| null;
|
||||
const currentVal = (
|
||||
currentTrackFeatures as ExtendedFeatures
|
||||
)?.[mood.key as keyof ExtendedFeatures] as
|
||||
| number
|
||||
| null;
|
||||
const hasData =
|
||||
sourceVal != null && currentVal != null;
|
||||
const diff = hasData
|
||||
? Math.abs(sourceVal - currentVal)
|
||||
: 0;
|
||||
const match = hasData
|
||||
? Math.round((1 - diff) * 100)
|
||||
: null;
|
||||
const Icon = mood.icon;
|
||||
|
||||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mood.key}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 p-1.5 rounded-lg",
|
||||
match !== null && match >= 80
|
||||
? "bg-green-500/10"
|
||||
: match !== null &&
|
||||
match >= 60
|
||||
? "bg-white/5"
|
||||
: "bg-red-500/10"
|
||||
)}
|
||||
title={`Source: ${Math.round(
|
||||
sourceVal * 100
|
||||
)}% | Current: ${Math.round(
|
||||
currentVal * 100
|
||||
)}%`}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"w-3.5 h-3.5",
|
||||
mood.color
|
||||
)}
|
||||
/>
|
||||
<span className="text-[9px] text-gray-400">
|
||||
{mood.label}
|
||||
</span>
|
||||
{match !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
match >= 80
|
||||
? "text-green-400"
|
||||
: match >= 60
|
||||
? "text-gray-300"
|
||||
: "text-red-400"
|
||||
)}
|
||||
>
|
||||
{match}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-4 pt-2 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-brand" />
|
||||
<span className="text-[10px] text-gray-500">
|
||||
Source
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-2 bg-white/40 rounded-sm" />
|
||||
<span className="text-[10px] text-gray-500">
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Explanation */}
|
||||
{overallMatch !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-center py-2 px-3 rounded-lg text-xs",
|
||||
overallMatch >= 80
|
||||
? "bg-green-500/10 text-green-400"
|
||||
: overallMatch >= 60
|
||||
? "bg-brand/10 text-brand"
|
||||
: "bg-red-500/10 text-red-400"
|
||||
)}
|
||||
>
|
||||
{overallMatch >= 80 &&
|
||||
"Excellent match - very similar vibe"}
|
||||
{overallMatch >= 60 &&
|
||||
overallMatch < 80 &&
|
||||
"Good match - similar energy"}
|
||||
{overallMatch < 60 &&
|
||||
"Different vibe - exploring variety"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact version for mobile overlay player - shows as album art replacement
|
||||
export function VibeComparisonArt({
|
||||
currentTrackFeatures,
|
||||
className,
|
||||
}: {
|
||||
currentTrackFeatures?: AudioFeatures | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const { vibeMode, vibeSourceFeatures } = useAudioState();
|
||||
|
||||
// Calculate feature comparisons
|
||||
const comparisons = useMemo(() => {
|
||||
if (!vibeSourceFeatures || !currentTrackFeatures) return null;
|
||||
|
||||
return FEATURE_CONFIG.slice(0, 4)
|
||||
.map((feature) => {
|
||||
const sourceVal = (vibeSourceFeatures as ExtendedFeatures)?.[
|
||||
feature.key as keyof ExtendedFeatures
|
||||
];
|
||||
const currentVal = (currentTrackFeatures as ExtendedFeatures)?.[
|
||||
feature.key as keyof ExtendedFeatures
|
||||
];
|
||||
|
||||
const sourceNorm = normalizeValue(
|
||||
sourceVal as number,
|
||||
feature.min,
|
||||
feature.max
|
||||
);
|
||||
const currentNorm = normalizeValue(
|
||||
currentVal as number,
|
||||
feature.min,
|
||||
feature.max
|
||||
);
|
||||
const diff = Math.abs(sourceNorm - currentNorm);
|
||||
|
||||
return {
|
||||
...feature,
|
||||
sourceNorm,
|
||||
currentNorm,
|
||||
diff,
|
||||
match: Math.round((1 - diff) * 100),
|
||||
hasData: sourceVal != null && currentVal != null,
|
||||
};
|
||||
})
|
||||
.filter((f) => f.hasData);
|
||||
}, [vibeSourceFeatures, currentTrackFeatures]);
|
||||
|
||||
const overallMatch = useMemo(() => {
|
||||
if (!comparisons || comparisons.length === 0) return null;
|
||||
const totalMatch = comparisons.reduce((sum, f) => sum + f.match, 0);
|
||||
return Math.round(totalMatch / comparisons.length);
|
||||
}, [comparisons]);
|
||||
|
||||
if (!vibeMode || !comparisons) return null;
|
||||
|
||||
// Radar chart dimensions
|
||||
const size = 280;
|
||||
const center = size / 2;
|
||||
const maxRadius = 110;
|
||||
|
||||
const getPolygonPoints = (values: number[]) => {
|
||||
const angleStep = (2 * Math.PI) / values.length;
|
||||
return values
|
||||
.map((value, i) => {
|
||||
const angle = angleStep * i - Math.PI / 2;
|
||||
const radius = value * maxRadius;
|
||||
const x = center + radius * Math.cos(angle);
|
||||
const y = center + radius * Math.sin(angle);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const sourceValues = comparisons.map((c) => c.sourceNorm);
|
||||
const currentValues = comparisons.map((c) => c.currentNorm);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full h-full bg-gradient-to-br from-[#1a1a2e] via-[#0f0f1a] to-[#000000] flex items-center justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Animated background glow */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-brand/20 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl animate-pulse delay-1000" />
|
||||
</div>
|
||||
|
||||
{/* Radar Chart */}
|
||||
<svg width={size} height={size} className="relative z-10">
|
||||
{/* Background circles */}
|
||||
{[0.25, 0.5, 0.75, 1].map((scale) => (
|
||||
<circle
|
||||
key={scale}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={maxRadius * scale}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Axis lines */}
|
||||
{comparisons.map((_, i) => {
|
||||
const angleStep = (2 * Math.PI) / comparisons.length;
|
||||
const angle = angleStep * i - Math.PI / 2;
|
||||
const x = center + maxRadius * Math.cos(angle);
|
||||
const y = center + maxRadius * Math.sin(angle);
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Source polygon (yellow, dashed) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(sourceValues)}
|
||||
fill="rgba(236, 178, 0, 0.15)"
|
||||
stroke="#ecb200"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6,4"
|
||||
/>
|
||||
|
||||
{/* Current polygon (white/gradient, solid) */}
|
||||
<polygon
|
||||
points={getPolygonPoints(currentValues)}
|
||||
fill="url(#currentGradient)"
|
||||
stroke="rgba(255, 255, 255, 0.9)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Gradient definition */}
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="currentGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="rgba(255, 255, 255, 0.2)"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="rgba(168, 85, 247, 0.2)"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Feature labels */}
|
||||
{comparisons.map((feature, i) => {
|
||||
const angleStep = (2 * Math.PI) / comparisons.length;
|
||||
const angle = angleStep * i - Math.PI / 2;
|
||||
const labelRadius = maxRadius + 25;
|
||||
const x = center + labelRadius * Math.cos(angle);
|
||||
const y = center + labelRadius * Math.sin(angle);
|
||||
const Icon = feature.icon;
|
||||
|
||||
return (
|
||||
<g key={feature.key}>
|
||||
{/* Icon background */}
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={14}
|
||||
fill="rgba(0,0,0,0.5)"
|
||||
stroke={
|
||||
feature.match >= 70
|
||||
? "rgba(74, 222, 128, 0.5)"
|
||||
: "rgba(255,255,255,0.2)"
|
||||
}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* Feature label */}
|
||||
<text
|
||||
x={x}
|
||||
y={y + 28}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-400 text-[10px] font-medium"
|
||||
>
|
||||
{feature.label}
|
||||
</text>
|
||||
{/* Match percentage */}
|
||||
<text
|
||||
x={x}
|
||||
y={y + 40}
|
||||
textAnchor="middle"
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
feature.match >= 70
|
||||
? "fill-green-400"
|
||||
: feature.match >= 50
|
||||
? "fill-yellow-400"
|
||||
: "fill-red-400"
|
||||
)}
|
||||
>
|
||||
{feature.match}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Center match score */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={35}
|
||||
fill="rgba(0,0,0,0.7)"
|
||||
stroke={
|
||||
overallMatch && overallMatch >= 70
|
||||
? "#4ade80"
|
||||
: overallMatch && overallMatch >= 50
|
||||
? "#facc15"
|
||||
: "#f87171"
|
||||
}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={center - 5}
|
||||
textAnchor="middle"
|
||||
className={cn(
|
||||
"text-2xl font-bold",
|
||||
overallMatch && overallMatch >= 70
|
||||
? "fill-green-400"
|
||||
: overallMatch && overallMatch >= 50
|
||||
? "fill-yellow-400"
|
||||
: "fill-red-400"
|
||||
)}
|
||||
>
|
||||
{overallMatch}%
|
||||
</text>
|
||||
<text
|
||||
x={center}
|
||||
y={center + 12}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-400 text-[10px] font-medium uppercase tracking-wider"
|
||||
>
|
||||
Match
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-0.5 bg-brand border-dashed"
|
||||
style={{ borderStyle: "dashed" }}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Source Vibe
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-2 bg-white/30 rounded-sm" />
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Current Track
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Container component that manages the floating EnhancedVibeOverlay.
|
||||
* Shows automatically when vibe mode is active on desktop.
|
||||
*/
|
||||
export function VibeOverlayContainer() {
|
||||
const { vibeMode, queue, currentIndex } = useAudioState();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
// Auto-show when vibe mode activates, reset dismissed state
|
||||
useEffect(() => {
|
||||
if (vibeMode) {
|
||||
setIsVisible(true);
|
||||
setIsDismissed(false);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setIsDismissed(false);
|
||||
}
|
||||
}, [vibeMode]);
|
||||
|
||||
// Get current track's audio features from the queue
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
// Don't render if not in vibe mode or dismissed
|
||||
if (!vibeMode || isDismissed || !isVisible) return null;
|
||||
|
||||
return (
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsDismissed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState, AudioFeatures } from "@/lib/audio-state-context";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useMemo, useState, useEffect, useRef, memo } from "react";
|
||||
import { X, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
Radar,
|
||||
} from "recharts";
|
||||
|
||||
// Extended feature interface for all enhanced vibe data
|
||||
interface ExtendedFeatures extends AudioFeatures {
|
||||
danceabilityMl?: number | null;
|
||||
}
|
||||
|
||||
// Feature configurations for radar chart
|
||||
const RADAR_FEATURES = [
|
||||
{ key: "energy", label: "Energy", min: 0, max: 1 },
|
||||
{ key: "valence", label: "Mood", min: 0, max: 1 },
|
||||
{ key: "arousal", label: "Arousal", min: 0, max: 1 },
|
||||
{ key: "danceability", label: "Dance", min: 0, max: 1 },
|
||||
{ key: "bpm", label: "Tempo", min: 60, max: 200 },
|
||||
{ key: "moodHappy", label: "Happy", min: 0, max: 1 },
|
||||
{ key: "moodSad", label: "Sad", min: 0, max: 1 },
|
||||
{ key: "moodRelaxed", label: "Relaxed", min: 0, max: 1 },
|
||||
{ key: "moodAggressive", label: "Aggressive", min: 0, max: 1 },
|
||||
{ key: "moodParty", label: "Party", min: 0, max: 1 },
|
||||
{ key: "moodAcoustic", label: "Acoustic", min: 0, max: 1 },
|
||||
{ key: "moodElectronic", label: "Electronic", min: 0, max: 1 },
|
||||
];
|
||||
|
||||
const ML_MOODS = [
|
||||
{ key: "moodHappy", label: "Happy", color: "#ecb200" },
|
||||
{ key: "moodSad", label: "Sad", color: "#5c8dd6" },
|
||||
{ key: "moodRelaxed", label: "Relaxed", color: "#1db954" },
|
||||
{ key: "moodAggressive", label: "Aggressive", color: "#e35656" },
|
||||
{ key: "moodParty", label: "Party", color: "#e056a0" },
|
||||
{ key: "moodAcoustic", label: "Acoustic", color: "#d4a656" },
|
||||
{ key: "moodElectronic", label: "Electronic", color: "#a056e0" },
|
||||
];
|
||||
|
||||
interface EnhancedVibeOverlayProps {
|
||||
className?: string;
|
||||
currentTrackFeatures?: ExtendedFeatures | null;
|
||||
variant?: "floating" | "inline";
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Helper to normalize values
|
||||
function normalizeValue(
|
||||
value: number | null | undefined,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
if (value == null) return 0;
|
||||
return Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
}
|
||||
|
||||
// Helper to get feature value safely
|
||||
function getFeatureValue(
|
||||
features: ExtendedFeatures | null | undefined,
|
||||
key: string
|
||||
): number | null {
|
||||
if (!features) return null;
|
||||
const value = (features as Record<string, unknown>)[key];
|
||||
if (typeof value === "number") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Audio waveform visualization component - slower, smoother animation
|
||||
const AudioWaveform = memo(function AudioWaveform({
|
||||
energy,
|
||||
bpm,
|
||||
}: {
|
||||
energy: number;
|
||||
bpm: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const timeRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.offsetWidth * 2;
|
||||
canvas.height = canvas.offsetHeight * 2;
|
||||
ctx.scale(2, 2);
|
||||
};
|
||||
resize();
|
||||
|
||||
const animate = () => {
|
||||
const width = canvas.offsetWidth;
|
||||
const height = canvas.offsetHeight;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Calculate wave parameters based on audio features - SLOWER
|
||||
const baseAmplitude = height * 0.35 * Math.max(0.3, energy);
|
||||
const frequency = (bpm / 120) * 0.015; // Reduced frequency
|
||||
const speed = (bpm / 120) * 0.015; // Slower speed (was 0.05)
|
||||
|
||||
// Draw multiple layered waves with #ecb200 accent
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
const layerOffset = layer * 0.4;
|
||||
const layerAmplitude = baseAmplitude * (1 - layer * 0.2);
|
||||
const alpha = 0.5 - layer * 0.12;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height / 2);
|
||||
|
||||
for (let x = 0; x <= width; x += 2) {
|
||||
const y =
|
||||
height / 2 +
|
||||
Math.sin(
|
||||
(x * frequency + timeRef.current * speed + layerOffset) *
|
||||
Math.PI
|
||||
) *
|
||||
layerAmplitude +
|
||||
Math.sin(
|
||||
(x * frequency * 1.5 + timeRef.current * speed * 0.8) *
|
||||
Math.PI
|
||||
) *
|
||||
(layerAmplitude * 0.25);
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "#ecb200";
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = 2 - layer * 0.4;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw glow effect
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.filter = "blur(10px)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height / 2);
|
||||
for (let x = 0; x <= width; x += 2) {
|
||||
const y =
|
||||
height / 2 +
|
||||
Math.sin((x * frequency + timeRef.current * speed) * Math.PI) *
|
||||
baseAmplitude;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = "#ecb200";
|
||||
ctx.lineWidth = 8;
|
||||
ctx.stroke();
|
||||
ctx.filter = "none";
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
timeRef.current += 1;
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [energy, bpm]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-14"
|
||||
style={{ opacity: 0.7 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export function EnhancedVibeOverlay({
|
||||
className,
|
||||
currentTrackFeatures,
|
||||
variant = "floating",
|
||||
onClose,
|
||||
}: EnhancedVibeOverlayProps) {
|
||||
const { vibeMode, vibeSourceFeatures } = useAudioState();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<"full" | "minimal">("full");
|
||||
|
||||
// Use vibeSourceFeatures as the source
|
||||
const sourceFeatures = vibeSourceFeatures as ExtendedFeatures | null;
|
||||
// Current features should be from the actual current track, NOT fallback to source
|
||||
const currentFeatures = currentTrackFeatures as ExtendedFeatures | null;
|
||||
// For display, use currentFeatures if available, otherwise show source (for first track)
|
||||
const displayFeatures = currentFeatures || sourceFeatures;
|
||||
|
||||
// Prepare radar chart data
|
||||
const radarData = useMemo(() => {
|
||||
return RADAR_FEATURES.map((feature) => {
|
||||
const sourceVal = getFeatureValue(sourceFeatures, feature.key);
|
||||
// For current, use displayFeatures (falls back to source if current is null)
|
||||
const currentVal = getFeatureValue(displayFeatures, feature.key);
|
||||
|
||||
return {
|
||||
feature: feature.label,
|
||||
source: normalizeValue(sourceVal, feature.min, feature.max) * 100,
|
||||
current: normalizeValue(currentVal, feature.min, feature.max) * 100,
|
||||
fullMark: 100,
|
||||
};
|
||||
});
|
||||
}, [sourceFeatures, displayFeatures]);
|
||||
|
||||
// Calculate overall match score using cosine similarity (same as backend)
|
||||
const matchScore = useMemo(() => {
|
||||
if (!sourceFeatures || !currentFeatures) return null;
|
||||
|
||||
// Build feature vectors for cosine similarity
|
||||
const sourceVector: number[] = [];
|
||||
const currentVector: number[] = [];
|
||||
|
||||
RADAR_FEATURES.forEach((feature) => {
|
||||
const sourceVal = getFeatureValue(sourceFeatures, feature.key);
|
||||
const currentVal = getFeatureValue(currentFeatures, feature.key);
|
||||
|
||||
// Use 0.5 as default for missing values (neutral)
|
||||
const sourceNorm = normalizeValue(sourceVal ?? (feature.min + feature.max) / 2, feature.min, feature.max);
|
||||
const currentNorm = normalizeValue(currentVal ?? (feature.min + feature.max) / 2, feature.min, feature.max);
|
||||
|
||||
// Weight ML mood features higher (1.3x) like backend does
|
||||
const weight = feature.key.startsWith("mood") ? 1.3 : 1.0;
|
||||
sourceVector.push(sourceNorm * weight);
|
||||
currentVector.push(currentNorm * weight);
|
||||
});
|
||||
|
||||
// Calculate cosine similarity
|
||||
let dotProduct = 0;
|
||||
let magSource = 0;
|
||||
let magCurrent = 0;
|
||||
|
||||
for (let i = 0; i < sourceVector.length; i++) {
|
||||
dotProduct += sourceVector[i] * currentVector[i];
|
||||
magSource += sourceVector[i] * sourceVector[i];
|
||||
magCurrent += currentVector[i] * currentVector[i];
|
||||
}
|
||||
|
||||
const magnitude = Math.sqrt(magSource) * Math.sqrt(magCurrent);
|
||||
if (magnitude === 0) return null;
|
||||
|
||||
const similarity = dotProduct / magnitude;
|
||||
return Math.round(similarity * 100);
|
||||
}, [sourceFeatures, currentFeatures]);
|
||||
|
||||
// Get audio features for waveform - use current if available, fallback to source
|
||||
const energy = getFeatureValue(currentFeatures, "energy") ?? getFeatureValue(sourceFeatures, "energy") ?? 0.5;
|
||||
const bpm = getFeatureValue(currentFeatures, "bpm") ?? getFeatureValue(sourceFeatures, "bpm") ?? 120;
|
||||
|
||||
// Don't render if not in vibe mode
|
||||
if (!vibeMode) return null;
|
||||
|
||||
const isFloating = variant === "floating";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className={cn(
|
||||
// Spotify-inspired dark theme
|
||||
"bg-[#121212] text-white relative overflow-hidden",
|
||||
isFloating
|
||||
? "fixed bottom-24 right-4 z-50 rounded-xl shadow-2xl w-[380px] border border-[#282828]"
|
||||
: "rounded-xl w-full border border-[#282828]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-3 border-b border-[#282828] cursor-pointer",
|
||||
isFloating && "hover:bg-[#1a1a1a] transition-colors"
|
||||
)}
|
||||
onClick={() => isFloating && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
animate={{
|
||||
boxShadow: [
|
||||
"0 0 8px #ecb200",
|
||||
"0 0 16px #ecb200",
|
||||
"0 0 8px #ecb200",
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-2.5 h-2.5 rounded-full bg-[#ecb200]"
|
||||
/>
|
||||
<span className="text-sm font-semibold tracking-wide">
|
||||
Vibe Analysis
|
||||
</span>
|
||||
{matchScore !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-bold px-2.5 py-1 rounded-full",
|
||||
matchScore >= 80
|
||||
? "bg-[#1db954]/20 text-[#1db954]"
|
||||
: matchScore >= 60
|
||||
? "bg-[#ecb200]/20 text-[#ecb200]"
|
||||
: "bg-[#e35656]/20 text-[#e35656]"
|
||||
)}
|
||||
>
|
||||
{matchScore}% Match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setViewMode(viewMode === "full" ? "minimal" : "full");
|
||||
}}
|
||||
className="p-2 rounded-full hover:bg-[#282828] transition-colors"
|
||||
title={viewMode === "full" ? "Minimal view" : "Full view"}
|
||||
>
|
||||
{viewMode === "full" ? (
|
||||
<Minimize2 className="w-4 h-4 text-[#b3b3b3]" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4 text-[#b3b3b3]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isFloating && onClose && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="p-2 hover:bg-[#282828] rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-[#b3b3b3]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* Waveform visualization */}
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<AudioWaveform energy={energy} bpm={bpm} />
|
||||
</div>
|
||||
|
||||
{viewMode === "full" ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Radar Chart */}
|
||||
<div className="h-[260px] w-full bg-[#181818] rounded-lg p-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart
|
||||
data={radarData}
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 30 }}
|
||||
>
|
||||
<PolarGrid
|
||||
stroke="#282828"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<PolarAngleAxis
|
||||
dataKey="feature"
|
||||
tick={{
|
||||
fill: "#b3b3b3",
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
tickLine={false}
|
||||
/>
|
||||
{/* Source track (dashed yellow) */}
|
||||
<Radar
|
||||
name="Source"
|
||||
dataKey="source"
|
||||
stroke="#ecb200"
|
||||
fill="#ecb200"
|
||||
fillOpacity={0.1}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
{/* Current track (solid white) */}
|
||||
<Radar
|
||||
name="Current"
|
||||
dataKey="current"
|
||||
stroke="#ffffff"
|
||||
fill="#ffffff"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-[#181818] rounded-lg p-3">
|
||||
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-1">
|
||||
Analysis Mode
|
||||
</div>
|
||||
<div className="text-sm font-semibold capitalize text-white">
|
||||
{sourceFeatures?.analysisMode || "Standard"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#181818] rounded-lg p-3">
|
||||
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-1">
|
||||
Tempo
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{bpm ? `${Math.round(bpm)} BPM` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ML Mood Spectrum - shows current track's moods */}
|
||||
<div className="bg-[#181818] rounded-lg p-4">
|
||||
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-3">
|
||||
Mood Spectrum {!currentFeatures && "(Source Track)"}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{ML_MOODS.map((mood) => {
|
||||
const value = getFeatureValue(
|
||||
displayFeatures,
|
||||
mood.key
|
||||
);
|
||||
// Show the bar even if value is 0, only hide if null/undefined
|
||||
const percentage = value != null ? Math.round(value * 100) : 0;
|
||||
const hasValue = value != null;
|
||||
|
||||
return (
|
||||
<div key={mood.key} className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: mood.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<span className="text-xs text-[#b3b3b3]">
|
||||
{mood.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium tabular-nums text-white">
|
||||
{hasValue ? `${percentage}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#282828] rounded-full h-1 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: hasValue ? `${Math.max(percentage, 2)}%` : "0%",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: mood.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Minimal view - just radar */
|
||||
<div className="p-4">
|
||||
<div className="h-[180px] w-full bg-[#181818] rounded-lg p-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={radarData}>
|
||||
<PolarGrid stroke="#282828" />
|
||||
<PolarAngleAxis
|
||||
dataKey="feature"
|
||||
tick={{ fill: "#b3b3b3", fontSize: 9 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Radar
|
||||
name="Source"
|
||||
dataKey="source"
|
||||
stroke="#ecb200"
|
||||
fill="#ecb200"
|
||||
fillOpacity={0.1}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
<Radar
|
||||
name="Current"
|
||||
dataKey="current"
|
||||
stroke="#ffffff"
|
||||
fill="#ffffff"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-6 py-3 border-t border-[#282828]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#ecb200]" style={{ borderStyle: "dashed" }} />
|
||||
<span className="text-[10px] text-[#b3b3b3]">Source</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-2 rounded-sm bg-white/30" />
|
||||
<span className="text-[10px] text-[#b3b3b3]">Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary specifically for audio-related errors.
|
||||
* Catches errors in the audio provider hierarchy and allows the rest of the app to continue.
|
||||
* Falls through gracefully without breaking the entire application.
|
||||
*/
|
||||
export class AudioErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log the error for debugging
|
||||
console.error("[AudioErrorBoundary] Audio system error:", error);
|
||||
console.error("[AudioErrorBoundary] Component stack:", errorInfo.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// If there's a custom fallback, use it
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Otherwise, render children without audio functionality
|
||||
// This allows the app to continue working, just without audio
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AudioStateProvider } from "@/lib/audio-state-context";
|
||||
import { AudioPlaybackProvider } from "@/lib/audio-playback-context";
|
||||
import { AudioControlsProvider } from "@/lib/audio-controls-context";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { HowlerAudioElement } from "@/components/player/HowlerAudioElement";
|
||||
import { AudioErrorBoundary } from "@/components/providers/AudioErrorBoundary";
|
||||
|
||||
export function ConditionalAudioProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// Don't load audio provider on public pages or when not authenticated
|
||||
const publicPages = ["/login", "/register", "/onboarding", "/setup"];
|
||||
const isPublicPage = publicPages.includes(pathname);
|
||||
|
||||
if (isPublicPage || !isAuthenticated) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Split contexts: State -> Playback -> Controls
|
||||
// This prevents re-renders from currentTime updates affecting all consumers
|
||||
// Wrapped in error boundary to prevent audio errors from crashing the app
|
||||
return (
|
||||
<AudioErrorBoundary>
|
||||
<AudioStateProvider>
|
||||
<AudioPlaybackProvider>
|
||||
<AudioControlsProvider>
|
||||
{/* HowlerAudioElement handles both web and native platforms */}
|
||||
<HowlerAudioElement />
|
||||
{children}
|
||||
</AudioControlsProvider>
|
||||
</AudioPlaybackProvider>
|
||||
</AudioStateProvider>
|
||||
</AudioErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Book, CheckCircle } from "lucide-react";
|
||||
import { CachedImage } from "./CachedImage";
|
||||
|
||||
interface AudiobookCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl: string | null;
|
||||
progress?: {
|
||||
progress: number;
|
||||
isFinished: boolean;
|
||||
} | null;
|
||||
seriesBadge?: string; // e.g., "5 books" for series cards
|
||||
index?: number;
|
||||
getCoverUrl: (url: string) => string | null;
|
||||
}
|
||||
|
||||
export function AudiobookCard({
|
||||
id,
|
||||
title,
|
||||
author,
|
||||
coverUrl,
|
||||
progress,
|
||||
seriesBadge,
|
||||
index = 0,
|
||||
getCoverUrl,
|
||||
}: AudiobookCardProps) {
|
||||
const resolvedCoverUrl = coverUrl ? getCoverUrl(coverUrl) : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={seriesBadge ? `/audiobooks/series/${encodeURIComponent(title)}` : `/audiobooks/${id}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="cursor-pointer group relative h-full flex flex-col">
|
||||
{/* Book Cover Container - Fixed Aspect Ratio */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="aspect-[2/3] rounded-sm overflow-hidden bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] shadow-2xl relative transform transition-all group-hover:-translate-y-2 group-hover:shadow-[0_20px_40px_rgba(0,0,0,0.8)]">
|
||||
{resolvedCoverUrl ? (
|
||||
<CachedImage
|
||||
src={resolvedCoverUrl}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Book className="w-16 h-16 text-gray-700" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Book Spine Shadow */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-2 bg-gradient-to-r from-black/40 to-transparent pointer-events-none" />
|
||||
|
||||
{/* Book Gloss */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/5 via-transparent to-black/20 pointer-events-none" />
|
||||
|
||||
{/* Progress Bar */}
|
||||
{progress && !progress.isFinished && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/60">
|
||||
<div
|
||||
className="h-full bg-purple-500"
|
||||
style={{ width: `${progress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion Badge */}
|
||||
{progress?.isFinished && (
|
||||
<div className="absolute top-2 right-2 bg-green-500 rounded-full p-1.5 shadow-lg">
|
||||
<CheckCircle className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Series Badge (for series cards only) */}
|
||||
{seriesBadge && (
|
||||
<div className="absolute top-2 right-2 bg-purple-500 rounded px-2 py-1 text-xs font-bold shadow-lg">
|
||||
{seriesBadge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shelf Shadow */}
|
||||
<div className="absolute -bottom-1 left-0 right-0 h-2 bg-gradient-to-b from-[#1a1a1a]/50 to-transparent rounded-b-sm" />
|
||||
</div>
|
||||
|
||||
{/* Text Container - Fixed Height for Uniformity */}
|
||||
<div className="mt-3 px-1 h-14 flex flex-col justify-start">
|
||||
<h3 className="text-sm font-bold text-white line-clamp-2 leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 line-clamp-1 mt-1">
|
||||
{author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HTMLAttributes, forwardRef, memo } from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: "success" | "warning" | "error" | "info" | "ai" | "default";
|
||||
}
|
||||
|
||||
const Badge = memo(forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant = "default", children, ...props }, ref) => {
|
||||
const variantStyles = {
|
||||
success: "bg-green-500/10 text-green-500 ring-green-500/20",
|
||||
warning: "bg-brand/10 text-brand ring-brand/20",
|
||||
error: "bg-red-500/10 text-red-500 ring-red-500/20",
|
||||
info: "bg-blue-500/10 text-blue-500 ring-blue-500/20",
|
||||
ai: "bg-purple-500/10 text-purple-500 ring-purple-500/20",
|
||||
default: "bg-[#1a1a1a] text-gray-400 ring-[#262626]",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ring-1",
|
||||
variantStyles[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
));
|
||||
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge };
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ButtonHTMLAttributes, forwardRef, memo } from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { GradientSpinner } from "./GradientSpinner";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger" | "ai" | "icon";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Button = memo(forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = "secondary",
|
||||
isLoading,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseStyles =
|
||||
"inline-flex items-center justify-center rounded-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0a0a] disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
// Lidify brand color: #fca200
|
||||
const variantStyles = {
|
||||
primary:
|
||||
"bg-brand hover:bg-brand-hover text-black px-4 py-2 shadow-lg shadow-brand/10",
|
||||
secondary:
|
||||
"bg-[#1a1a1a] hover:bg-[#222] text-white px-4 py-2 border border-[#262626]",
|
||||
ghost: "text-gray-400 hover:text-white hover:bg-[#1a1a1a] px-4 py-2",
|
||||
danger: "text-red-500 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40 px-4 py-2",
|
||||
ai: "bg-[#1a1a1a] hover:bg-brand/10 text-brand border border-[#1c1c1c] hover:border-brand/30 px-4 py-2",
|
||||
icon: "w-8 h-8 text-gray-400 hover:text-white hover:bg-[#1a1a1a]",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(baseStyles, variantStyles[variant], className)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<GradientSpinner size="sm" className="mr-2" />
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
));
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useCachedImage } from "@/hooks/useCachedImage";
|
||||
import { ImgHTMLAttributes, memo } from "react";
|
||||
|
||||
interface CachedImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
src: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image component that uses client-side caching to prevent reloading
|
||||
* Uses blob URLs to persist images across re-renders
|
||||
*/
|
||||
const CachedImage = memo(function CachedImage({ src, alt = "", ...props }: CachedImageProps) {
|
||||
const cachedSrc = useCachedImage(src || null);
|
||||
|
||||
if (!cachedSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <img src={cachedSrc} alt={alt} {...props} />;
|
||||
});
|
||||
|
||||
export { CachedImage };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { HTMLAttributes, forwardRef, memo } from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "ai" | "metric";
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
const Card = memo(forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{ className, variant = "default", hover = true, children, ...props },
|
||||
ref
|
||||
) => {
|
||||
const baseStyles = "rounded-md p-3 transition-all duration-200";
|
||||
|
||||
const variantStyles = {
|
||||
default: cn(
|
||||
"bg-transparent",
|
||||
hover && "hover:bg-white/5"
|
||||
),
|
||||
ai: cn(
|
||||
"bg-gradient-to-br from-[#121212] to-[#0f0f0f] border border-[#1c1c1c]",
|
||||
hover && "hover:border-yellow-500/30"
|
||||
),
|
||||
metric: "bg-[#0f0f0f] border border-[#1c1c1c]",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(baseStyles, variantStyles[variant], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
));
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
export { Card };
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "danger" | "warning" | "info";
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
variant = "danger",
|
||||
}: ConfirmDialogProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const variantStyles = {
|
||||
danger: {
|
||||
icon: "text-red-500",
|
||||
iconBg: "bg-red-500/10",
|
||||
confirmButton: "bg-red-500 hover:bg-red-600 text-white",
|
||||
},
|
||||
warning: {
|
||||
icon: "text-yellow-500",
|
||||
iconBg: "bg-yellow-500/10",
|
||||
confirmButton: "bg-yellow-500 hover:bg-yellow-600 text-black",
|
||||
},
|
||||
info: {
|
||||
icon: "text-blue-500",
|
||||
iconBg: "bg-blue-500/10",
|
||||
confirmButton: "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = variantStyles[variant];
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4 animate-fadeIn"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-[#121212] rounded-xl max-w-md w-full overflow-hidden border border-white/10 shadow-2xl animate-slideUp"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 p-6 border-b border-white/10">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-full ${styles.iconBg} flex items-center justify-center flex-shrink-0`}
|
||||
>
|
||||
<AlertTriangle className={`w-6 h-6 ${styles.icon}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-white mb-2">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 p-6 bg-[#0a0a0a]/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 bg-white/5 hover:bg-white/10 text-white font-semibold rounded-lg transition-all border border-white/10"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className={`flex-1 px-4 py-3 font-semibold rounded-lg transition-all ${styles.confirmButton}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, memo } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
};
|
||||
}
|
||||
|
||||
const EmptyState = memo(function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
action,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 md:py-16 text-center px-4">
|
||||
<div className="mb-4 text-gray-600">{icon}</div>
|
||||
<h3 className="text-lg md:text-xl font-medium text-white mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-gray-500 mb-6 max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
{children}
|
||||
{action && (
|
||||
<Button
|
||||
variant={action.variant || "primary"}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { EmptyState };
|
||||
@@ -0,0 +1,82 @@
|
||||
import { InputHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
// Input Component
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-[#0f0f0f] border text-white placeholder-gray-600 rounded-sm px-3 py-2 text-sm transition-colors",
|
||||
"focus:outline-none focus:ring-1",
|
||||
error
|
||||
? "border-red-500/50 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-[#262626] focus:border-[#1db954]/50 focus:ring-purple-500/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
// Textarea Component
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-[#0f0f0f] border text-white placeholder-gray-600 rounded-sm px-3 py-2 text-sm resize-none transition-colors",
|
||||
"focus:outline-none focus:ring-1",
|
||||
error
|
||||
? "border-red-500/50 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-[#262626] focus:border-[#1db954]/50 focus:ring-purple-500/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
// Select Component
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, error, children, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-[#0f0f0f] border text-white rounded-sm px-3 py-2 text-sm appearance-none cursor-pointer transition-colors",
|
||||
"focus:outline-none",
|
||||
error
|
||||
? "border-red-500/50 focus:border-red-500"
|
||||
: "border-[#262626] focus:border-[#1db954]/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = "Select";
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* GalaxyBackground Component
|
||||
*
|
||||
* Creates a cosmic background effect that fades up from the bottom of the page.
|
||||
* Features:
|
||||
* - Subtle gradient fading from bottom (prominent) to top (transparent)
|
||||
* - Floating star-like particles
|
||||
* - More prominent at the bottom, fading as it goes higher
|
||||
* - Customizable colors from Vibrant.js for artist/album pages
|
||||
*/
|
||||
|
||||
interface GalaxyBackgroundProps {
|
||||
/** Primary color extracted from Vibrant.js (e.g., "#8B4789") */
|
||||
primaryColor?: string;
|
||||
/** Optional secondary color */
|
||||
secondaryColor?: string;
|
||||
}
|
||||
|
||||
export function GalaxyBackground({ primaryColor, secondaryColor }: GalaxyBackgroundProps = {}) {
|
||||
// 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);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
};
|
||||
|
||||
// Use provided colors or default purple theme
|
||||
const baseColor = primaryColor ? hexToRgb(primaryColor) : null;
|
||||
const accentColor = secondaryColor ? hexToRgb(secondaryColor) : null;
|
||||
|
||||
// Memoize particle positions so they don't regenerate on every render
|
||||
const particles = useMemo(() => ({
|
||||
bottom: Array(30).fill(0).map(() => ({
|
||||
left: Math.random() * 100,
|
||||
bottom: Math.random() * 30,
|
||||
duration: 3 + Math.random() * 4,
|
||||
delay: Math.random() * 3,
|
||||
})),
|
||||
mid: Array(20).fill(0).map(() => ({
|
||||
left: Math.random() * 100,
|
||||
bottom: 30 + Math.random() * 30,
|
||||
duration: 4 + Math.random() * 3,
|
||||
delay: Math.random() * 2,
|
||||
})),
|
||||
top: Array(12).fill(0).map(() => ({
|
||||
left: Math.random() * 100,
|
||||
bottom: 60 + Math.random() * 40,
|
||||
duration: 5 + Math.random() * 3,
|
||||
delay: Math.random() * 2,
|
||||
})),
|
||||
white: Array(18).fill(0).map(() => ({
|
||||
left: Math.random() * 100,
|
||||
bottom: Math.random() * 50,
|
||||
duration: 2 + Math.random() * 3,
|
||||
delay: Math.random() * 2,
|
||||
})),
|
||||
accent: Array(10).fill(0).map(() => ({
|
||||
left: Math.random() * 100,
|
||||
bottom: Math.random() * 40,
|
||||
duration: 4 + Math.random() * 4,
|
||||
delay: Math.random() * 3,
|
||||
})),
|
||||
}), []); // Empty dependency array - generate once and never again
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden">
|
||||
{/* Subtle gradient - fades from bottom to top */}
|
||||
{baseColor ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t to-transparent"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to top, rgba(${baseColor.r}, ${baseColor.g}, ${baseColor.b}, 0.15), rgba(${baseColor.r}, ${baseColor.g}, ${baseColor.b}, 0.05), transparent)`
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-black/10 to-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-purple-950/15 via-purple-950/5 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-black/10 to-transparent" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floating Star Particles - more concentrated at bottom */}
|
||||
{/* Bottom layer - most prominent */}
|
||||
{particles.bottom.map((p, i) => (
|
||||
<div
|
||||
key={`bottom-purple-${i}`}
|
||||
className={baseColor ? "absolute w-0.5 h-0.5 rounded-full blur-[0.4px]" : "absolute w-0.5 h-0.5 bg-purple-300/35 rounded-full blur-[0.4px]"}
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
bottom: `${p.bottom}%`,
|
||||
animation: `galaxyFloat ${p.duration}s ease-in-out infinite`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
...(baseColor && {
|
||||
backgroundColor: `rgba(${baseColor.r}, ${baseColor.g}, ${baseColor.b}, 0.35)`
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Middle layer - medium prominence */}
|
||||
{particles.mid.map((p, i) => (
|
||||
<div
|
||||
key={`mid-purple-${i}`}
|
||||
className={baseColor ? "absolute w-0.5 h-0.5 rounded-full blur-[0.4px]" : "absolute w-0.5 h-0.5 bg-indigo-300/25 rounded-full blur-[0.4px]"}
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
bottom: `${p.bottom}%`,
|
||||
animation: `galaxyFloat ${p.duration}s ease-in-out infinite`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
...(baseColor && {
|
||||
backgroundColor: `rgba(${baseColor.r}, ${baseColor.g}, ${baseColor.b}, 0.25)`
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Top layer - subtle and sparse */}
|
||||
{particles.top.map((p, i) => (
|
||||
<div
|
||||
key={`top-purple-${i}`}
|
||||
className={baseColor ? "absolute w-0.5 h-0.5 rounded-full blur-[0.4px]" : "absolute w-0.5 h-0.5 bg-violet-300/15 rounded-full blur-[0.4px]"}
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
bottom: `${p.bottom}%`,
|
||||
animation: `galaxyFloat ${p.duration}s ease-in-out infinite`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
...(baseColor && {
|
||||
backgroundColor: `rgba(${baseColor.r}, ${baseColor.g}, ${baseColor.b}, 0.15)`
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Accent white/blue stars scattered throughout */}
|
||||
{particles.white.map((p, i) => (
|
||||
<div
|
||||
key={`white-star-${i}`}
|
||||
className="absolute w-0.5 h-0.5 bg-white/30 rounded-full blur-[0.3px]"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
bottom: `${p.bottom}%`,
|
||||
animation: `galaxyTwinkle ${p.duration}s ease-in-out infinite`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Very subtle accent particles - use secondary color if available */}
|
||||
{particles.accent.map((p, i) => (
|
||||
<div
|
||||
key={`blue-accent-${i}`}
|
||||
className={accentColor ? "absolute w-0.5 h-0.5 rounded-full blur-[0.4px]" : "absolute w-0.5 h-0.5 bg-blue-300/25 rounded-full blur-[0.4px]"}
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
bottom: `${p.bottom}%`,
|
||||
animation: `galaxyFloat ${p.duration}s ease-in-out infinite`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
...(accentColor && {
|
||||
backgroundColor: `rgba(${accentColor.r}, ${accentColor.g}, ${accentColor.b}, 0.25)`
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
interface GradientSpinnerProps {
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GradientSpinner = memo(function GradientSpinner({ size = "md", className = "" }: GradientSpinnerProps) {
|
||||
const sizeMap = {
|
||||
sm: { size: 16, strokeWidth: 2, radius: 6 },
|
||||
md: { size: 32, strokeWidth: 3, radius: 13 },
|
||||
lg: { size: 48, strokeWidth: 4, radius: 20 },
|
||||
xl: { size: 64, strokeWidth: 4, radius: 28 },
|
||||
};
|
||||
|
||||
// Fallback to "md" if invalid size is provided
|
||||
const sizeConfig = sizeMap[size] || sizeMap.md;
|
||||
const { size: viewBoxSize, strokeWidth, radius } = sizeConfig;
|
||||
const center = viewBoxSize / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${className}`}
|
||||
width={viewBoxSize}
|
||||
height={viewBoxSize}
|
||||
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`spinnerGrad-${size}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#facc15', stopOpacity: 1 }} />
|
||||
<stop offset="25%" style={{ stopColor: '#f59e0b', stopOpacity: 1 }} />
|
||||
<stop offset="50%" style={{ stopColor: '#c026d3', stopOpacity: 1 }} />
|
||||
<stop offset="75%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#facc15', stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={`url(#spinnerGrad-${size})`}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${radius * 5} ${radius * 1.5}`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
export { GradientSpinner };
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, ReactNode } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
|
||||
interface HorizontalCarouselProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
showArrows?: boolean;
|
||||
gap?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function HorizontalCarousel({
|
||||
children,
|
||||
className,
|
||||
itemClassName,
|
||||
showArrows = true,
|
||||
gap = "md",
|
||||
}: HorizontalCarouselProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
const gapClass = {
|
||||
sm: "gap-2",
|
||||
md: "gap-3",
|
||||
lg: "gap-4",
|
||||
}[gap];
|
||||
|
||||
const checkScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
setCanScrollLeft(el.scrollLeft > 0);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.addEventListener("scroll", checkScroll);
|
||||
window.addEventListener("resize", checkScroll);
|
||||
}
|
||||
return () => {
|
||||
if (el) {
|
||||
el.removeEventListener("scroll", checkScroll);
|
||||
}
|
||||
window.removeEventListener("resize", checkScroll);
|
||||
};
|
||||
}, [children]);
|
||||
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Scroll by approximately 3 items worth
|
||||
const scrollAmount = el.clientWidth * 0.8;
|
||||
el.scrollBy({
|
||||
left: direction === "left" ? -scrollAmount : scrollAmount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative group/carousel", className)}>
|
||||
{/* Left arrow */}
|
||||
{showArrows && !isMobileOrTablet && canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scroll("left")}
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 z-10",
|
||||
"w-10 h-10 rounded-full bg-black/80 ",
|
||||
"flex items-center justify-center",
|
||||
"opacity-0 group-hover/carousel:opacity-100 transition-opacity",
|
||||
"hover:bg-black hover:scale-105 transition-all",
|
||||
"border border-white/10 shadow-lg",
|
||||
"-translate-x-1/2"
|
||||
)}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"flex overflow-x-auto scrollbar-hide scroll-smooth",
|
||||
"snap-x snap-mandatory",
|
||||
gapClass,
|
||||
// Padding for edge items
|
||||
"px-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Right arrow */}
|
||||
{showArrows && !isMobileOrTablet && canScrollRight && (
|
||||
<button
|
||||
onClick={() => scroll("right")}
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 -translate-y-1/2 z-10",
|
||||
"w-10 h-10 rounded-full bg-black/80 ",
|
||||
"flex items-center justify-center",
|
||||
"opacity-0 group-hover/carousel:opacity-100 transition-opacity",
|
||||
"hover:bg-black hover:scale-105 transition-all",
|
||||
"border border-white/10 shadow-lg",
|
||||
"translate-x-1/2"
|
||||
)}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fade edges */}
|
||||
{canScrollLeft && !isMobileOrTablet && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-black/50 to-transparent pointer-events-none" />
|
||||
)}
|
||||
{canScrollRight && !isMobileOrTablet && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-black/50 to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrapper for carousel items with consistent sizing
|
||||
interface CarouselItemProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CarouselItem({ children, className }: CarouselItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 snap-start",
|
||||
// Responsive widths - smaller items that fit more on screen
|
||||
"w-[140px] sm:w-[160px] md:w-[170px] lg:w-[180px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Check, X, Loader2, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
export type StatusType = "idle" | "loading" | "success" | "error";
|
||||
|
||||
interface InlineStatusProps {
|
||||
status: StatusType;
|
||||
message?: string;
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
autoClear?: boolean; // Auto-clear success/error after delay
|
||||
clearDelay?: number; // Delay in ms before clearing (default 3000)
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline status indicator for form fields and buttons
|
||||
* Shows success/error/loading states without overlay toasts
|
||||
*/
|
||||
export function InlineStatus({
|
||||
status,
|
||||
message,
|
||||
className,
|
||||
showIcon = true,
|
||||
autoClear = true,
|
||||
clearDelay = 3000,
|
||||
onClear,
|
||||
}: InlineStatusProps) {
|
||||
const [visible, setVisible] = useState(status !== "idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "success" || status === "error") {
|
||||
setVisible(true);
|
||||
|
||||
if (autoClear) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
onClear?.();
|
||||
}, clearDelay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} else if (status === "loading") {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
}, [status, autoClear, clearDelay, onClear]);
|
||||
|
||||
if (status === "idle" || !visible) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-sm",
|
||||
status === "success" && "text-emerald-400",
|
||||
status === "error" && "text-red-400",
|
||||
status === "loading" && "text-white/60",
|
||||
className
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{showIcon && (
|
||||
<>
|
||||
{status === "success" && <Check className="w-4 h-4" />}
|
||||
{status === "error" && <X className="w-4 h-4" />}
|
||||
{status === "loading" && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
</>
|
||||
)}
|
||||
{message && <span>{message}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing inline status state
|
||||
*/
|
||||
export function useInlineStatus(initialStatus: StatusType = "idle") {
|
||||
const [status, setStatus] = useState<StatusType>(initialStatus);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
|
||||
const setSuccess = (msg?: string) => {
|
||||
setStatus("success");
|
||||
setMessage(msg || "");
|
||||
};
|
||||
|
||||
const setError = (msg?: string) => {
|
||||
setStatus("error");
|
||||
setMessage(msg || "");
|
||||
};
|
||||
|
||||
const setLoading = (msg?: string) => {
|
||||
setStatus("loading");
|
||||
setMessage(msg || "");
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStatus("idle");
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
setSuccess,
|
||||
setError,
|
||||
setLoading,
|
||||
reset,
|
||||
props: { status, message, onClear: reset },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection test button with inline status
|
||||
*/
|
||||
interface ConnectionTestButtonProps {
|
||||
label: string;
|
||||
onTest: () => Promise<boolean | string | null>;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionTestButton({
|
||||
label,
|
||||
onTest,
|
||||
className,
|
||||
disabled,
|
||||
}: ConnectionTestButtonProps) {
|
||||
const { status, message, setSuccess, setError, setLoading, reset, props } = useInlineStatus();
|
||||
|
||||
const handleTest = async () => {
|
||||
setLoading("Testing...");
|
||||
try {
|
||||
const result = await onTest();
|
||||
if (result === false || result === null) {
|
||||
setError("Failed");
|
||||
} else if (typeof result === "string") {
|
||||
setSuccess(result);
|
||||
} else {
|
||||
setSuccess("Connected");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={disabled || status === "loading"}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
"bg-white/10 hover:bg-white/15 text-white/70 hover:text-white",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{status === "loading" ? "Testing..." : label}
|
||||
</button>
|
||||
<InlineStatus {...props} showIcon={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save button with inline status
|
||||
*/
|
||||
interface SaveButtonProps {
|
||||
onSave: () => Promise<void>;
|
||||
label?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SaveButton({
|
||||
onSave,
|
||||
label = "Save",
|
||||
className,
|
||||
disabled,
|
||||
}: SaveButtonProps) {
|
||||
const { status, setSuccess, setError, setLoading, props } = useInlineStatus();
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading();
|
||||
try {
|
||||
await onSave();
|
||||
setSuccess("Saved");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={disabled || status === "loading"}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg font-medium transition-colors",
|
||||
"bg-amber-500 hover:bg-amber-400 text-black",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{status === "loading" ? "Saving..." : label}
|
||||
</button>
|
||||
<InlineStatus {...props} showIcon={true} autoClear={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
rightIcon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
error,
|
||||
rightIcon,
|
||||
className,
|
||||
...props
|
||||
}: InputProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium mb-2 text-white">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
className={cn(
|
||||
"w-full bg-[#1a1a1a] border border-[#1c1c1c] rounded-md px-4 py-2 text-white",
|
||||
"placeholder:text-gray-500",
|
||||
"focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500",
|
||||
"transition-all duration-200",
|
||||
error &&
|
||||
"border-red-500/50 focus:ring-red-500/50 focus:border-red-500",
|
||||
rightIcon && "pr-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white cursor-pointer transition-colors">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { GradientSpinner } from "./GradientSpinner";
|
||||
|
||||
interface LoadingScreenProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function LoadingScreen({ message = "Loading..." }: LoadingScreenProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<GradientSpinner size="lg" />
|
||||
{message && (
|
||||
<p className="text-white text-sm font-medium">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { isLocalUrl } from "@/utils/cn";
|
||||
|
||||
interface MediaCardProps {
|
||||
href: string;
|
||||
imageUrl: string | null | undefined;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
placeholderIcon: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
badge?: React.ReactNode;
|
||||
imageShape?: "circle" | "square";
|
||||
}
|
||||
|
||||
const MediaCard = memo(function MediaCard({
|
||||
href,
|
||||
imageUrl,
|
||||
title,
|
||||
subtitle,
|
||||
placeholderIcon,
|
||||
onClick,
|
||||
badge,
|
||||
imageShape = "circle",
|
||||
}: MediaCardProps) {
|
||||
const content = (
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#121212] to-[#121212] hover:from-[#181818] hover:to-[#1a1a1a] transition-all duration-300 p-4 rounded-lg cursor-pointer border border-white/5 hover:border-white/10 hover:scale-105 hover:shadow-2xl group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={`aspect-square bg-[#181818] ${
|
||||
imageShape === "circle" ? "rounded-full" : "rounded-md"
|
||||
} mb-4 flex items-center justify-center overflow-hidden relative shadow-lg`}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-all"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 16vw"
|
||||
priority={false}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
placeholderIcon
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-white line-clamp-1 mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-400 line-clamp-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{badge && <div className="flex-shrink-0">{badge}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <Link href={href}>{content}</Link>;
|
||||
});
|
||||
|
||||
export { MediaCard };
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 ">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gradient-to-br from-[#141414] to-[#0f0f0f] border border-[#262626] rounded-sm shadow-2xl max-w-md w-full p-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-[#1c1c1c]">
|
||||
<h2 className="text-lg font-medium text-white">{title}</h2>
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={onClose}
|
||||
className="hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-6">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex gap-3 justify-end">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* PageBackground - Reusable yellow-to-purple gradient background for main pages
|
||||
* The sidebar keeps its dark textured look, but content pages get this vibrant gradient
|
||||
*/
|
||||
export function PageBackground() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none -z-10">
|
||||
{/* Main gradient from yellow through purple */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-[#ecb200]/15 via-purple-900/10 to-transparent"
|
||||
style={{ height: "120vh" }}
|
||||
/>
|
||||
{/* Radial gradient for depth */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-[#ecb200]/8 via-transparent to-transparent"
|
||||
style={{ height: "100vh" }}
|
||||
/>
|
||||
{/* Subtle bottom fade to keep it clean */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-black to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
|
||||
interface PagedGridCarouselProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => ReactNode;
|
||||
keyExtractor: (item: T) => string;
|
||||
itemsPerPage?: number;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
gap?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PagedGridCarousel<T>({
|
||||
items,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
itemsPerPage = 6,
|
||||
columns = 3,
|
||||
rows = 2,
|
||||
gap = "gap-2",
|
||||
className,
|
||||
}: PagedGridCarouselProps<T>) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
// Group items into pages
|
||||
const pages = useMemo(() => {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += itemsPerPage) {
|
||||
result.push(items.slice(i, i + itemsPerPage));
|
||||
}
|
||||
return result;
|
||||
}, [items, itemsPerPage]);
|
||||
|
||||
// Check scroll state
|
||||
const checkScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 0);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
|
||||
|
||||
// Update current page based on scroll position
|
||||
const pageWidth = el.clientWidth;
|
||||
const newPage = Math.round(el.scrollLeft / pageWidth);
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.addEventListener("scroll", checkScroll);
|
||||
window.addEventListener("resize", checkScroll);
|
||||
}
|
||||
return () => {
|
||||
if (el) el.removeEventListener("scroll", checkScroll);
|
||||
window.removeEventListener("resize", checkScroll);
|
||||
};
|
||||
}, [pages]);
|
||||
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const scrollAmount = el.clientWidth;
|
||||
el.scrollBy({
|
||||
left: direction === "left" ? -scrollAmount : scrollAmount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const goToPage = (pageIndex: number) => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTo({
|
||||
left: pageIndex * el.clientWidth,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("relative group/carousel", className)}>
|
||||
{/* Left Arrow (desktop only) */}
|
||||
{!isMobileOrTablet && canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scroll("left")}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/80 flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-opacity hover:bg-black hover:scale-105 border border-white/10 shadow-lg -translate-x-1/2"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable Container */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex overflow-x-auto scrollbar-hide scroll-smooth snap-x snap-mandatory gap-3"
|
||||
>
|
||||
{pages.map((page, pageIndex) => (
|
||||
<div
|
||||
key={pageIndex}
|
||||
className={cn(
|
||||
"flex-shrink-0 snap-start w-full grid",
|
||||
gap
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{page.map((item, itemIndex) => (
|
||||
<div key={keyExtractor(item)}>
|
||||
{renderItem(
|
||||
item,
|
||||
pageIndex * itemsPerPage + itemIndex
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Fill empty slots */}
|
||||
{page.length < itemsPerPage &&
|
||||
Array.from({
|
||||
length: itemsPerPage - page.length,
|
||||
}).map((_, i) => <div key={`empty-${i}`} />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Arrow (desktop only) */}
|
||||
{!isMobileOrTablet && canScrollRight && (
|
||||
<button
|
||||
onClick={() => scroll("right")}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/80 flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-opacity hover:bg-black hover:scale-105 border border-white/10 shadow-lg translate-x-1/2"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page indicators */}
|
||||
{pages.length > 1 && (
|
||||
<div className="flex justify-center gap-1.5 mt-3">
|
||||
{pages.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToPage(index)}
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full transition-colors",
|
||||
index === currentPage
|
||||
? "bg-white"
|
||||
: "bg-white/30 hover:bg-white/50"
|
||||
)}
|
||||
aria-label={`Go to page ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, ReactNode, memo } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Play, Pause, Check, Download } from "lucide-react";
|
||||
import { Card, CardProps } from "./Card";
|
||||
import { cn } from "@/utils/cn";
|
||||
import type { ColorPalette } from "@/hooks/useImageColor";
|
||||
|
||||
// Lidify brand yellow for all on-page play buttons
|
||||
const LIDIFY_YELLOW = "#ecb200";
|
||||
|
||||
export interface PlayableCardProps extends Omit<CardProps, "onPlay"> {
|
||||
href?: string;
|
||||
coverArt?: string | null;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
placeholderIcon?: ReactNode;
|
||||
isPlaying?: boolean;
|
||||
onPlay?: (e: React.MouseEvent) => void;
|
||||
onDownload?: (e: React.MouseEvent) => void;
|
||||
showPlayButton?: boolean;
|
||||
circular?: boolean;
|
||||
badge?: "owned" | "download" | null;
|
||||
isDownloading?: boolean;
|
||||
colors?: ColorPalette | null;
|
||||
tvCardIndex?: number;
|
||||
}
|
||||
|
||||
const PlayableCard = memo(function PlayableCard({
|
||||
href,
|
||||
coverArt,
|
||||
title,
|
||||
subtitle,
|
||||
placeholderIcon,
|
||||
isPlaying = false,
|
||||
onPlay,
|
||||
onDownload,
|
||||
showPlayButton = true,
|
||||
circular = false,
|
||||
badge = null,
|
||||
isDownloading = false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
colors = null,
|
||||
className,
|
||||
variant = "default",
|
||||
tvCardIndex,
|
||||
...props
|
||||
}: PlayableCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Handle Link click to prevent navigation when clicking on interactive elements
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
{/* Image Container */}
|
||||
<div
|
||||
className="relative aspect-square mb-3"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative w-full h-full bg-[#282828] flex items-center justify-center overflow-hidden shadow-lg",
|
||||
circular ? "rounded-full" : "rounded-md"
|
||||
)}>
|
||||
{coverArt ? (
|
||||
<Image
|
||||
src={coverArt}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 16vw"
|
||||
className={cn(
|
||||
"object-cover transition-transform duration-300",
|
||||
isHovered && "scale-105"
|
||||
)}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
placeholderIcon || (
|
||||
<div className="w-12 h-12 bg-[#3e3e3e] rounded-full" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Play Button */}
|
||||
{showPlayButton && onPlay && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPlay(e);
|
||||
}}
|
||||
style={{ backgroundColor: LIDIFY_YELLOW }}
|
||||
className={cn(
|
||||
"absolute bottom-2 right-2 w-10 h-10 rounded-full flex items-center justify-center",
|
||||
"shadow-xl shadow-black/50 transition-all duration-200",
|
||||
"hover:scale-105 hover:brightness-110",
|
||||
isHovered || isPlaying
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-4 h-4 fill-current text-black" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 fill-current ml-0.5 text-black" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badge */}
|
||||
{badge && (
|
||||
<div className="mb-1.5">
|
||||
{badge === "owned" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-xs font-medium text-green-400">
|
||||
<Check className="w-3 h-3" />
|
||||
Owned
|
||||
</span>
|
||||
)}
|
||||
{badge === "download" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
if (!isDownloading && onDownload) {
|
||||
onDownload(e);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isDownloading}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium transition-all",
|
||||
isDownloading
|
||||
? "bg-gray-500/20 border border-gray-500/30 text-gray-500 cursor-not-allowed"
|
||||
: "bg-yellow-500/20 hover:bg-yellow-500/30 border border-yellow-500/30 hover:border-yellow-500/50 text-yellow-400 hover:text-yellow-300"
|
||||
)}
|
||||
title={
|
||||
isDownloading
|
||||
? "Downloading..."
|
||||
: "Download Album"
|
||||
}
|
||||
>
|
||||
<Download
|
||||
className={cn(
|
||||
"w-3 h-3",
|
||||
isDownloading && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
{isDownloading ? "Downloading..." : "Download"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and Subtitle */}
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const cardClassName = cn("group cursor-pointer", className);
|
||||
|
||||
// TV navigation attributes
|
||||
const tvNavProps = tvCardIndex !== undefined ? {
|
||||
"data-tv-card": true,
|
||||
"data-tv-card-index": tvCardIndex,
|
||||
tabIndex: 0
|
||||
} : {};
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={handleLinkClick}
|
||||
{...tvNavProps}
|
||||
>
|
||||
<Card variant={variant} className={cardClassName} {...props}>
|
||||
{cardContent}
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant={variant}
|
||||
className={cardClassName}
|
||||
{...tvNavProps}
|
||||
{...props}
|
||||
>
|
||||
{cardContent}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export { PlayableCard };
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { X, Plus, Music2 } from "lucide-react";
|
||||
import { GradientSpinner } from "./GradientSpinner";
|
||||
|
||||
interface PlaylistSelectorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectPlaylist: (playlistId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
loadingMessage?: string;
|
||||
}
|
||||
|
||||
export function PlaylistSelector({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectPlaylist,
|
||||
isLoading: isSaving,
|
||||
loadingMessage,
|
||||
}: PlaylistSelectorProps) {
|
||||
const [playlists, setPlaylists] = useState<any[]>([]);
|
||||
const [newPlaylistName, setNewPlaylistName] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadPlaylists();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getPlaylists();
|
||||
setPlaylists(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load playlists:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePlaylist = async () => {
|
||||
if (!newPlaylistName.trim()) return;
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
const playlist = await api.createPlaylist(
|
||||
newPlaylistName.trim(),
|
||||
isPublic
|
||||
);
|
||||
await onSelectPlaylist(playlist.id);
|
||||
setNewPlaylistName("");
|
||||
setIsPublic(false);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playlist-created", { detail: playlist })
|
||||
);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create playlist:", error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPlaylist = async (playlistId: string) => {
|
||||
try {
|
||||
await onSelectPlaylist(playlistId);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playlist-updated", { detail: { playlistId } })
|
||||
);
|
||||
await loadPlaylists();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to add to playlist:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-linear-to-b from-[#121212] to-[#121212] rounded-xl max-w-md w-full max-h-[80vh] overflow-hidden flex flex-col border border-white/10 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Add to Playlist
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSaving && (
|
||||
<div className="px-6 py-3 flex items-center gap-3 bg-black/30 border-b border-white/10 text-sm text-gray-300">
|
||||
<GradientSpinner size="sm" />
|
||||
<span>{loadingMessage || "Adding..."}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<GradientSpinner size="md" />
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Music2 className="w-12 h-12 text-gray-600 mb-3" />
|
||||
<p className="text-gray-400">No playlists yet</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Create one below to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
playlists.map((playlist) => (
|
||||
<button
|
||||
key={playlist.id}
|
||||
onClick={() =>
|
||||
handleSelectPlaylist(playlist.id)
|
||||
}
|
||||
className="w-full text-left px-4 py-4 rounded-lg bg-white/5 hover:bg-white/10 transition-all border border-white/5 hover:border-white/10 group"
|
||||
disabled={isSaving}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold truncate group-hover:text-[#ecb200] transition-colors">
|
||||
{playlist.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{playlist.trackCount || 0}{" "}
|
||||
{playlist.trackCount === 1
|
||||
? "track"
|
||||
: "tracks"}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="w-5 h-5 text-gray-400 group-hover:text-[#ecb200] transition-colors ml-2 shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-white/10 bg-[#0a0a0a]/50">
|
||||
<p className="text-sm text-gray-400 mb-3 font-medium">
|
||||
Create New Playlist
|
||||
</p>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter playlist name..."
|
||||
value={newPlaylistName}
|
||||
onChange={(e) => setNewPlaylistName(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleCreatePlaylist()
|
||||
}
|
||||
className="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-[#ecb200] focus:bg-white/10 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreatePlaylist}
|
||||
disabled={
|
||||
!newPlaylistName.trim() ||
|
||||
isCreating ||
|
||||
isSaving
|
||||
}
|
||||
className="px-5 py-3 bg-[#ecb200] hover:bg-[#d4a000] disabled:bg-gray-700 disabled:cursor-not-allowed text-black font-bold rounded-lg transition-all flex items-center gap-2 disabled:text-gray-500"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Create</span>
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-5 bg-white/10 rounded-full peer-checked:bg-[#ecb200] transition-colors" />
|
||||
<div className="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-5" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors">
|
||||
Share with other users
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { X, Copy, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface RestartModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
changedServices: string[];
|
||||
}
|
||||
|
||||
export function RestartModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
changedServices,
|
||||
}: RestartModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const command = "docker-compose restart";
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 z-50 "
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#111] border border-[#1c1c1c] rounded-lg shadow-2xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[#1c1c1c]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<Check className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
Settings Saved!
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-gray-300">
|
||||
Your settings have been saved successfully and the
|
||||
<code className="text-purple-400 bg-[#0a0a0a] px-1.5 py-0.5 rounded mx-1">
|
||||
.env
|
||||
</code>
|
||||
file has been updated.
|
||||
</p>
|
||||
|
||||
{changedServices.length > 0 && (
|
||||
<>
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-md p-4">
|
||||
<p className="text-sm font-medium text-yellow-500 mb-2">
|
||||
Restart Required
|
||||
</p>
|
||||
<p className="text-sm text-gray-300 mb-3">
|
||||
The following services need a restart to
|
||||
apply changes:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{changedServices.map((service) => (
|
||||
<li
|
||||
key={service}
|
||||
className="text-sm text-gray-300 flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500" />
|
||||
{service}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
Run this command in your terminal:
|
||||
</p>
|
||||
<div className="relative">
|
||||
<div className="bg-[#0a0a0a] border border-[#1c1c1c] rounded-md px-4 py-3 pr-12 font-mono text-sm text-white">
|
||||
{command}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 hover:bg-[#1a1a1a] rounded transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{changedServices.length === 0 && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-md p-4">
|
||||
<p className="text-sm text-gray-300">
|
||||
No restart needed! Changes are applied
|
||||
immediately.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-[#1c1c1c]">
|
||||
{changedServices.length > 0 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>
|
||||
{changedServices.length > 0
|
||||
? "I'll Restart Later"
|
||||
: "Close"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse bg-[#1a1a1a] rounded-sm", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtistCardSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gradient-to-br from-[#121212] to-[#0f0f0f] border border-[#1c1c1c] rounded-sm p-4"
|
||||
>
|
||||
<Skeleton className="aspect-square w-full mb-3" />
|
||||
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumCardSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gradient-to-br from-[#121212] to-[#0f0f0f] border border-[#1c1c1c] rounded-sm p-4"
|
||||
>
|
||||
<Skeleton className="aspect-square w-full mb-3" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-3 w-2/3 mb-1" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrackListSkeleton({ count = 10 }: { count?: number }) {
|
||||
return (
|
||||
<div className="bg-[#0f0f0f] border border-[#1c1c1c] rounded-sm divide-y divide-[#1c1c1c]">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-3">
|
||||
<Skeleton className="w-8 h-4" />
|
||||
<Skeleton className="w-12 h-12 flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroSkeleton() {
|
||||
return (
|
||||
<div className="relative bg-gradient-to-b from-purple-900/20 to-transparent">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-12">
|
||||
<div className="flex flex-col md:flex-row items-end gap-6">
|
||||
<Skeleton className="w-48 h-48 flex-shrink-0" />
|
||||
<div className="flex-1 pb-2 space-y-4 w-full">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-12 w-3/4 max-w-md" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user