Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
@@ -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>
);
}
+328
View File
@@ -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>
)}
</>
);
}
+86
View File
@@ -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 };
+635
View File
@@ -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>
);
}
+121
View File
@@ -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">&quot;Add to Home Screen&quot;</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>
);
}
+439
View File
@@ -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>
);
}
+269
View File
@@ -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&apos;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>
</>
);
}
+444
View File
@@ -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>
)}
</>
);
}
+317
View File
@@ -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>
</>
);
}
+335
View File
@@ -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>
);
}
+568
View File
@@ -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
}
+796
View File
@@ -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 />
)}
</>
);
}
+198
View File
@@ -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>
);
}
+761
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+37
View File
@@ -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 };
+59
View File
@@ -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 };
+24
View File
@@ -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 };
+42
View File
@@ -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 };
+131
View File
@@ -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>
);
}
+47
View File
@@ -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 };
+82
View File
@@ -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";
+176
View File
@@ -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>
);
}
+214
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+75
View File
@@ -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 };
+73
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+211
View File
@@ -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 };
+204
View File
@@ -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>
);
}
+156
View File
@@ -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>
</>
);
}
+85
View File
@@ -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>
);
}