Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
This commit is contained in:
@@ -370,6 +370,29 @@ function DownloadJobItem({
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceColor = () => {
|
||||
if (!job.metadata?.currentSource) return "text-white/60";
|
||||
switch (job.metadata.currentSource) {
|
||||
case "lidarr":
|
||||
return "text-purple-400";
|
||||
case "soulseek":
|
||||
return "text-teal-400";
|
||||
default:
|
||||
return "text-white/60";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (job.metadata?.statusText) {
|
||||
return job.metadata.statusText;
|
||||
}
|
||||
// Fallback for backward compatibility
|
||||
if (job.status === "processing" || job.status === "pending") {
|
||||
return "Processing";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
@@ -397,7 +420,7 @@ function DownloadJobItem({
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{job.subject}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium capitalize",
|
||||
@@ -406,6 +429,19 @@ function DownloadJobItem({
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
{getStatusText() && (
|
||||
<>
|
||||
<span className="text-xs text-white/40">•</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
getSourceColor()
|
||||
)}
|
||||
>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-white/40">•</span>
|
||||
<span className="text-xs text-white/40 capitalize">
|
||||
{job.type}
|
||||
@@ -457,13 +493,48 @@ function DownloadJobItemCompact({
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceColor = () => {
|
||||
if (!job.metadata?.currentSource) return "text-white/60";
|
||||
switch (job.metadata.currentSource) {
|
||||
case "lidarr":
|
||||
return "text-purple-400";
|
||||
case "soulseek":
|
||||
return "text-teal-400";
|
||||
default:
|
||||
return "text-white/60";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (job.metadata?.statusText) {
|
||||
return job.metadata.statusText;
|
||||
}
|
||||
// Fallback for backward compatibility
|
||||
if (job.status === "processing" || job.status === "pending") {
|
||||
return "Processing";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<div className="flex-shrink-0">{getStatusIcon()}</div>
|
||||
<p className="flex-1 text-xs font-medium text-white truncate">
|
||||
{job.subject}
|
||||
</p>
|
||||
<span className="text-[10px] text-white/40 capitalize">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-white truncate">
|
||||
{job.subject}
|
||||
</p>
|
||||
{getStatusText() && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] font-medium",
|
||||
getSourceColor()
|
||||
)}
|
||||
>
|
||||
{getStatusText()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-white/40 capitalize shrink-0">
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
373
frontend/components/EnrichmentFailuresModal.tsx
Normal file
373
frontend/components/EnrichmentFailuresModal.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { enrichmentApi, EnrichmentFailure } from "@/lib/enrichmentApi";
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
|
||||
interface EnrichmentFailuresModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EnrichmentFailuresModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: EnrichmentFailuresModalProps) {
|
||||
const [selectedType, setSelectedType] = useState<
|
||||
"all" | "artist" | "track" | "audio"
|
||||
>("all");
|
||||
const [selectedFailures, setSelectedFailures] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch failures
|
||||
const {
|
||||
data: failures,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["enrichment-failures", selectedType, currentPage],
|
||||
queryFn: async () => {
|
||||
const params: any = {
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
resolved: false,
|
||||
};
|
||||
if (selectedType !== "all") {
|
||||
params.entityType = selectedType;
|
||||
}
|
||||
return enrichmentApi.getFailures(params);
|
||||
},
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Fetch counts
|
||||
const { data: counts } = useQuery({
|
||||
queryKey: ["enrichment-failure-counts"],
|
||||
queryFn: () => enrichmentApi.getFailureCounts(),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Retry mutation
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (failureIds: string[]) =>
|
||||
enrichmentApi.retryFailures(failureIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["enrichment-failures"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["enrichment-failure-counts"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["enrichment-progress"],
|
||||
});
|
||||
setSelectedFailures(new Set());
|
||||
},
|
||||
});
|
||||
|
||||
// Skip mutation
|
||||
const skipMutation = useMutation({
|
||||
mutationFn: (failureIds: string[]) =>
|
||||
enrichmentApi.skipFailures(failureIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["enrichment-failures"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["enrichment-failure-counts"],
|
||||
});
|
||||
setSelectedFailures(new Set());
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (failureId: string) =>
|
||||
enrichmentApi.deleteFailure(failureId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["enrichment-failures"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["enrichment-failure-counts"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFailureSelection = (id: string) => {
|
||||
const newSelected = new Set(selectedFailures);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
}
|
||||
setSelectedFailures(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedFailures.size === failures?.failures.length) {
|
||||
setSelectedFailures(new Set());
|
||||
} else {
|
||||
setSelectedFailures(
|
||||
new Set(failures?.failures.map((f) => f.id) || [])
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetrySelected = () => {
|
||||
if (selectedFailures.size > 0) {
|
||||
retryMutation.mutate(Array.from(selectedFailures));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipSelected = () => {
|
||||
if (selectedFailures.size > 0) {
|
||||
skipMutation.mutate(Array.from(selectedFailures));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const totalFailures = counts?.total || 0;
|
||||
const totalPages = Math.ceil((failures?.total || 0) / pageSize);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#1a1a1a] rounded-lg w-full max-w-4xl max-h-[90vh] flex flex-col border border-white/10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Enrichment Failures
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 mt-1">
|
||||
{totalFailures} total failure
|
||||
{totalFailures !== 1 ? "s" : ""} to review
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-2 p-4 border-b border-white/10 overflow-x-auto">
|
||||
{(
|
||||
[
|
||||
{ key: "all" as const, label: "All", count: counts?.total || 0 },
|
||||
{
|
||||
key: "artist" as const,
|
||||
label: "Artists",
|
||||
count: counts?.artist || 0,
|
||||
},
|
||||
{
|
||||
key: "track" as const,
|
||||
label: "Tracks",
|
||||
count: counts?.track || 0,
|
||||
},
|
||||
{
|
||||
key: "audio" as const,
|
||||
label: "Audio Analysis",
|
||||
count: counts?.audio || 0,
|
||||
},
|
||||
]
|
||||
).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => {
|
||||
setSelectedType(tab.key);
|
||||
setCurrentPage(1);
|
||||
setSelectedFailures(new Set());
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
selectedType === tab.key
|
||||
? "bg-[#ecb200] text-black"
|
||||
: "bg-white/5 text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
{selectedFailures.size > 0 && (
|
||||
<div className="flex items-center gap-2 p-4 bg-white/5 border-b border-white/10">
|
||||
<span className="text-sm text-white/70">
|
||||
{selectedFailures.size} selected
|
||||
</span>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={handleRetrySelected}
|
||||
disabled={retryMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg
|
||||
hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSkipSelected}
|
||||
disabled={skipMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-white/10 text-white/70 rounded-lg
|
||||
hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failures List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/50">
|
||||
Loading failures...
|
||||
</div>
|
||||
</div>
|
||||
) : failures?.failures.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-white/50">
|
||||
<AlertCircle className="w-12 h-12 mb-3 opacity-50" />
|
||||
<p className="text-lg font-medium">
|
||||
No failures found
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
All items enriched successfully
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Select All */}
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selectedFailures.size ===
|
||||
failures?.failures.length
|
||||
}
|
||||
onChange={handleSelectAll}
|
||||
className="w-4 h-4 rounded border-white/20 bg-white/10"
|
||||
/>
|
||||
<span className="text-sm text-white/50">
|
||||
Select all
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{failures?.failures.map((failure) => (
|
||||
<div
|
||||
key={failure.id}
|
||||
className="flex items-start gap-3 p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFailures.has(
|
||||
failure.id
|
||||
)}
|
||||
onChange={() =>
|
||||
toggleFailureSelection(failure.id)
|
||||
}
|
||||
className="w-4 h-4 mt-1 rounded border-white/20 bg-white/10"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{failure.entityName ||
|
||||
failure.entityId}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-white/10 text-white/50 rounded uppercase">
|
||||
{failure.entityType}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-red-400 mt-1">
|
||||
{failure.errorMessage ||
|
||||
"Unknown error"}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-white/30">
|
||||
<span>
|
||||
Retry {failure.retryCount}/
|
||||
{failure.maxRetries}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Last:{" "}
|
||||
{new Date(
|
||||
failure.lastFailedAt
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
{failure.errorCode && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{failure.errorCode}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
deleteMutation.mutate(failure.id)
|
||||
}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-2 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||
title="Delete failure record"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between p-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.max(1, p - 1))
|
||||
}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 text-sm bg-white/10 text-white/70 rounded-lg
|
||||
hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-white/50">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) =>
|
||||
Math.min(totalPages, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 text-sm bg-white/10 text-white/70 rounded-lg
|
||||
hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,15 @@ interface MetadataEditorProps {
|
||||
rgMbid?: string;
|
||||
coverUrl?: string;
|
||||
heroUrl?: string;
|
||||
// Original values for comparison (when user overrides exist)
|
||||
_originalName?: string;
|
||||
_originalBio?: string | null;
|
||||
_originalGenres?: string[];
|
||||
_originalHeroUrl?: string | null;
|
||||
_originalTitle?: string;
|
||||
_originalYear?: number | null;
|
||||
_originalCoverUrl?: string | null;
|
||||
_hasUserOverrides?: boolean;
|
||||
};
|
||||
onSave?: (updatedData: any) => void;
|
||||
}
|
||||
@@ -36,7 +45,9 @@ export function MetadataEditor({
|
||||
}: MetadataEditorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [formData, setFormData] = useState(currentData);
|
||||
const hasOverrides = currentData._hasUserOverrides ?? false;
|
||||
|
||||
const handleOpen = () => {
|
||||
setFormData(currentData);
|
||||
@@ -48,6 +59,35 @@ export function MetadataEditor({
|
||||
setFormData(currentData);
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Reset all metadata to original values? This cannot be undone."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResetting(true);
|
||||
try {
|
||||
if (type === "artist") {
|
||||
await api.resetArtistMetadata(id);
|
||||
} else if (type === "album") {
|
||||
await api.resetAlbumMetadata(id);
|
||||
} else {
|
||||
await api.resetTrackMetadata(id);
|
||||
}
|
||||
|
||||
toast.success("Metadata reset to original values");
|
||||
onSave?.(null);
|
||||
setIsOpen(false);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to reset metadata");
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
@@ -144,6 +184,24 @@ export function MetadataEditor({
|
||||
}
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
{type === "artist" &&
|
||||
currentData._originalName &&
|
||||
currentData._originalName !==
|
||||
(formData.name || "") && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Original:{" "}
|
||||
{currentData._originalName}
|
||||
</p>
|
||||
)}
|
||||
{type !== "artist" &&
|
||||
currentData._originalTitle &&
|
||||
currentData._originalTitle !==
|
||||
(formData.title || "") && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Original:{" "}
|
||||
{currentData._originalTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bio (Artist only) */}
|
||||
@@ -160,6 +218,18 @@ export function MetadataEditor({
|
||||
rows={6}
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none resize-none"
|
||||
/>
|
||||
{currentData._originalBio &&
|
||||
currentData._originalBio !==
|
||||
(formData.bio || "") && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Original:{" "}
|
||||
{currentData._originalBio.substring(
|
||||
0,
|
||||
100
|
||||
)}
|
||||
...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -180,6 +250,14 @@ export function MetadataEditor({
|
||||
}
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
{currentData._originalYear &&
|
||||
currentData._originalYear !==
|
||||
(formData.year || null) && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Original:{" "}
|
||||
{currentData._originalYear}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -206,6 +284,21 @@ export function MetadataEditor({
|
||||
placeholder="Rock, Alternative, Indie"
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
{currentData._originalGenres &&
|
||||
currentData._originalGenres.length > 0 &&
|
||||
JSON.stringify(
|
||||
currentData._originalGenres.sort()
|
||||
) !==
|
||||
JSON.stringify(
|
||||
(formData.genres || []).sort()
|
||||
) && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Original:{" "}
|
||||
{currentData._originalGenres.join(
|
||||
", "
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MusicBrainz ID */}
|
||||
@@ -268,6 +361,24 @@ export function MetadataEditor({
|
||||
placeholder="https://..."
|
||||
className="w-full px-4 py-2 bg-[#181818] border border-white/10 rounded text-white focus:border-white/30 focus:outline-none text-sm"
|
||||
/>
|
||||
{type === "artist" &&
|
||||
currentData._originalHeroUrl &&
|
||||
currentData._originalHeroUrl !==
|
||||
(formData.heroUrl || "") && (
|
||||
<p className="mt-1 text-xs text-gray-500 truncate">
|
||||
Original:{" "}
|
||||
{currentData._originalHeroUrl}
|
||||
</p>
|
||||
)}
|
||||
{type === "album" &&
|
||||
currentData._originalCoverUrl &&
|
||||
currentData._originalCoverUrl !==
|
||||
(formData.coverUrl || "") && (
|
||||
<p className="mt-1 text-xs text-gray-500 truncate">
|
||||
Original:{" "}
|
||||
{currentData._originalCoverUrl}
|
||||
</p>
|
||||
)}
|
||||
{/* Image Preview */}
|
||||
{(formData.heroUrl || formData.coverUrl) && (
|
||||
<div className="mt-2">
|
||||
@@ -295,6 +406,17 @@ export function MetadataEditor({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-white/10">
|
||||
{hasOverrides && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isSaving || isResetting}
|
||||
className="px-6 py-2 rounded-full bg-red-500/20 hover:bg-red-500/30 text-red-400 font-bold transition-all border border-red-500/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isResetting
|
||||
? "Resetting..."
|
||||
: "Reset to Original"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-6 py-2 rounded-full bg-white/10 hover:bg-white/20 text-white font-bold transition-all"
|
||||
|
||||
@@ -13,6 +13,14 @@ export function PWAInstallPrompt() {
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
|
||||
const isDismissedRecently = (): boolean => {
|
||||
const dismissedAt = localStorage.getItem("pwa-prompt-dismissed");
|
||||
if (!dismissedAt) return false;
|
||||
const dismissedTime = parseInt(dismissedAt, 10);
|
||||
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
||||
return Date.now() - dismissedTime < sevenDays;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already installed as PWA
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
@@ -20,13 +28,8 @@ export function PWAInstallPrompt() {
|
||||
}
|
||||
|
||||
// Check if dismissed recently (within 7 days)
|
||||
const dismissedAt = localStorage.getItem("pwa-prompt-dismissed");
|
||||
if (dismissedAt) {
|
||||
const dismissedTime = parseInt(dismissedAt, 10);
|
||||
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
||||
if (Date.now() - dismissedTime < sevenDays) {
|
||||
return;
|
||||
}
|
||||
if (isDismissedRecently()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for iOS
|
||||
@@ -38,14 +41,22 @@ export function PWAInstallPrompt() {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
// Show prompt after a short delay
|
||||
setTimeout(() => setShowPrompt(true), 3000);
|
||||
setTimeout(() => {
|
||||
if (!isDismissedRecently()) {
|
||||
setShowPrompt(true);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
|
||||
// For iOS, show instructions after delay if on mobile
|
||||
if (isIOSDevice) {
|
||||
setTimeout(() => setShowPrompt(true), 5000);
|
||||
setTimeout(() => {
|
||||
if (!isDismissedRecently()) {
|
||||
setShowPrompt(true);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -147,7 +147,10 @@ export function PodcastPlayer({
|
||||
false
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save podcast progress on pause:", error);
|
||||
console.error(
|
||||
"Failed to save podcast progress on pause:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -229,9 +232,7 @@ export function PodcastPlayer({
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const streamUrl = `${
|
||||
process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3006"
|
||||
}/podcasts/${podcastId}/episodes/${episode.id}/stream`;
|
||||
const streamUrl = api.getPodcastEpisodeStreamUrl(podcastId, episode.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -12,6 +12,12 @@ interface ActiveDownload {
|
||||
type: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
metadata?: {
|
||||
statusText?: string;
|
||||
currentSource?: "lidarr" | "soulseek";
|
||||
lidarrAttempts?: number;
|
||||
soulseekAttempts?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function ActiveDownloadsTab() {
|
||||
@@ -31,15 +37,15 @@ export function ActiveDownloadsTab() {
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
setCancelling(prev => new Set(prev).add(id));
|
||||
setCancelling((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
// Optimistically remove from list
|
||||
setDownloads(prev => prev.filter(d => d.id !== id));
|
||||
setDownloads((prev) => prev.filter((d) => d.id !== id));
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel download:", error);
|
||||
} finally {
|
||||
setCancelling(prev => {
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
@@ -48,11 +54,11 @@ export function ActiveDownloadsTab() {
|
||||
};
|
||||
|
||||
const handleCancelAll = async () => {
|
||||
const ids = downloads.map(d => d.id);
|
||||
const ids = downloads.map((d) => d.id);
|
||||
setCancelling(new Set(ids));
|
||||
try {
|
||||
// Cancel all downloads in parallel
|
||||
await Promise.all(ids.map(id => api.deleteDownload(id)));
|
||||
await Promise.all(ids.map((id) => api.deleteDownload(id)));
|
||||
setDownloads([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel all downloads:", error);
|
||||
@@ -75,7 +81,7 @@ export function ActiveDownloadsTab() {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
|
||||
if (diff < 60000) return "Just started";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||
@@ -95,7 +101,9 @@ export function ActiveDownloadsTab() {
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Download className="w-8 h-8 text-white/20 mb-3" />
|
||||
<p className="text-sm text-white/40">No active downloads</p>
|
||||
<p className="text-xs text-white/30 mt-1">Downloads will appear here</p>
|
||||
<p className="text-xs text-white/30 mt-1">
|
||||
Downloads will appear here
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,14 +149,39 @@ export function ActiveDownloadsTab() {
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{download.subject}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn(
|
||||
"text-xs font-medium capitalize",
|
||||
download.status === "processing" ? "text-blue-400" : "text-yellow-400"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium capitalize",
|
||||
download.status === "processing"
|
||||
? "text-blue-400"
|
||||
: "text-yellow-400"
|
||||
)}
|
||||
>
|
||||
{download.status}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">•</span>
|
||||
{download.metadata?.statusText && (
|
||||
<>
|
||||
<span className="text-xs text-white/30">
|
||||
•
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
download.metadata
|
||||
.currentSource ===
|
||||
"lidarr"
|
||||
? "text-purple-400"
|
||||
: "text-teal-400"
|
||||
)}
|
||||
>
|
||||
{download.metadata.statusText}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-white/30">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-white/30 capitalize flex items-center gap-1">
|
||||
{download.type === "album" ? (
|
||||
<Disc className="w-3 h-3" />
|
||||
@@ -157,7 +190,9 @@ export function ActiveDownloadsTab() {
|
||||
)}
|
||||
{download.type}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">•</span>
|
||||
<span className="text-xs text-white/30">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-white/30">
|
||||
{formatTime(download.createdAt)}
|
||||
</span>
|
||||
|
||||
@@ -36,9 +36,7 @@ export function NotificationsTab() {
|
||||
} = useQuery<Notification[]>({
|
||||
queryKey: ["notifications"],
|
||||
queryFn: async () => {
|
||||
console.log("[NotificationsTab] Fetching notifications...");
|
||||
const result = await api.getNotifications();
|
||||
console.log("[NotificationsTab] Got notifications:", result);
|
||||
return result;
|
||||
},
|
||||
refetchInterval: 30000, // Poll every 30 seconds
|
||||
@@ -58,9 +56,6 @@ export function NotificationsTab() {
|
||||
notification.type === "playlist_ready" ||
|
||||
notification.type === "import_complete"
|
||||
) {
|
||||
console.log(
|
||||
"[NotificationsTab] New playlist notification, dispatching event"
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("playlist-created"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ActivityPanel } from "./ActivityPanel";
|
||||
import { GalaxyBackground } from "../ui/GalaxyBackground";
|
||||
import { GradientSpinner } from "../ui/GradientSpinner";
|
||||
import { PWAInstallPrompt } from "../PWAInstallPrompt";
|
||||
import { PullToRefresh } from "../ui/PullToRefresh";
|
||||
import { ReactNode } from "react";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { useIsTV } from "@/lib/tv-utils";
|
||||
@@ -84,6 +85,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
if (isTV) {
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<MediaControlsHandler />
|
||||
<TVLayout>{children}</TVLayout>
|
||||
</PlayerModeWrapper>
|
||||
@@ -94,6 +101,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div className="h-screen bg-black overflow-hidden flex flex-col">
|
||||
<MediaControlsHandler />
|
||||
<TopBar />
|
||||
@@ -110,18 +123,22 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
/>
|
||||
|
||||
{/* Main content area with rounded corners */}
|
||||
<main
|
||||
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
|
||||
style={{
|
||||
marginTop: "58px",
|
||||
marginBottom:
|
||||
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
}}
|
||||
>
|
||||
<GalaxyBackground />
|
||||
{/* Padding at bottom for mini player floating above */}
|
||||
<div className="pb-24">{children}</div>
|
||||
</main>
|
||||
<PullToRefresh>
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative focus:outline-none"
|
||||
style={{
|
||||
marginTop: "58px",
|
||||
marginBottom:
|
||||
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
}}
|
||||
>
|
||||
<GalaxyBackground />
|
||||
{/* Padding at bottom for mini player floating above */}
|
||||
<div className="pb-24">{children}</div>
|
||||
</main>
|
||||
</PullToRefresh>
|
||||
|
||||
{/* Mini Player - fixed, positioned above bottom nav */}
|
||||
<UniversalPlayer />
|
||||
@@ -137,6 +154,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
// Desktop Layout
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div
|
||||
className="h-screen bg-black overflow-hidden flex flex-col"
|
||||
style={{ paddingTop: "64px" }}
|
||||
@@ -145,7 +168,11 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
<TopBar />
|
||||
<div className="flex-1 flex gap-2 p-2 pt-0 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative">
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative focus:outline-none"
|
||||
>
|
||||
<GalaxyBackground />
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -43,9 +43,11 @@ export function BottomNavigation() {
|
||||
if (!isMobileOrTablet) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-40 bg-black border-t border-white/10"
|
||||
style={{
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
|
||||
}}
|
||||
>
|
||||
@@ -60,10 +62,12 @@ export function BottomNavigation() {
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center flex-1 h-full py-2 transition-colors",
|
||||
isActive
|
||||
? "text-white"
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-gray-500 active:text-gray-300"
|
||||
)}
|
||||
aria-label={item.name}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
|
||||
@@ -72,6 +72,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Sidebar Drawer */}
|
||||
@@ -109,7 +110,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Menu Content */}
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
<nav className="flex-1 overflow-y-auto py-4" role="navigation" aria-label="Mobile menu">
|
||||
{/* Quick Links Section */}
|
||||
<div className="px-3 mb-6">
|
||||
<div className="text-[10px] font-semibold text-gray-600 uppercase tracking-widest px-3 mb-2">
|
||||
@@ -118,6 +119,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/discover"
|
||||
aria-current={pathname === "/discover" ? "page" : undefined}
|
||||
aria-label="Discover"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/discover"
|
||||
@@ -133,6 +136,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/radio"
|
||||
aria-current={pathname === "/radio" ? "page" : undefined}
|
||||
aria-label="Radio"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/radio"
|
||||
@@ -148,6 +153,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/releases"
|
||||
aria-current={pathname === "/releases" ? "page" : undefined}
|
||||
aria-label="Releases"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/releases"
|
||||
@@ -191,6 +198,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/settings"
|
||||
|
||||
@@ -90,17 +90,9 @@ export function Sidebar() {
|
||||
|
||||
// Listen for playlist events to refresh playlists
|
||||
const handlePlaylistEvent = async () => {
|
||||
console.log(
|
||||
"[Sidebar] Playlist event received, refreshing playlists..."
|
||||
);
|
||||
if (!isAuthenticated) return;
|
||||
try {
|
||||
const data = await api.getPlaylists();
|
||||
console.log(
|
||||
"[Sidebar] Playlists refreshed:",
|
||||
data.length,
|
||||
"playlists"
|
||||
);
|
||||
setPlaylists(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to reload playlists:", error);
|
||||
@@ -215,6 +207,7 @@ export function Sidebar() {
|
||||
? "bg-[#1DB954] text-black"
|
||||
: "bg-white/10 text-white hover:bg-white/15 active:scale-95"
|
||||
)}
|
||||
aria-label={isSyncing ? "Syncing library" : "Sync library"}
|
||||
title={isSyncing ? "Syncing..." : "Sync Library"}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -233,6 +226,7 @@ export function Sidebar() {
|
||||
? "bg-white text-black"
|
||||
: "bg-white/10 text-gray-400 hover:text-white hover:bg-white/15 active:scale-95"
|
||||
)}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
@@ -247,6 +241,8 @@ export function Sidebar() {
|
||||
"pt-6 space-y-1",
|
||||
isMobileOrTablet ? "px-6" : "px-3"
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
@@ -257,6 +253,7 @@ export function Sidebar() {
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
prefetch={false}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"block rounded-lg transition-all duration-200 group relative overflow-hidden",
|
||||
isMobileOrTablet ? "px-4 py-3.5" : "px-4 py-3",
|
||||
@@ -310,6 +307,7 @@ export function Sidebar() {
|
||||
href="/playlists"
|
||||
prefetch={false}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md bg-white/5 text-gray-400 hover:text-white hover:bg-gradient-to-br hover:from-purple-500 hover:to-pink-500 hover:scale-110 transition-all duration-300 shadow-lg shadow-transparent hover:shadow-purple-500/30 border border-white/5 hover:border-transparent"
|
||||
aria-label="Create playlist"
|
||||
title="Create Playlist"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { DPAD_KEYS } from "@/lib/tv-utils";
|
||||
import { useTVNavigation } from "@/hooks/useTVNavigation";
|
||||
import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime";
|
||||
import { RefreshCw, SkipBack, SkipForward, Shuffle, Repeat } from "lucide-react";
|
||||
|
||||
const tvNavigation = [
|
||||
@@ -99,12 +100,8 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
// CRITICAL: Clamp currentTime to prevent display of invalid times
|
||||
const clampedCurrentTime = clampTime(currentTime, duration);
|
||||
|
||||
// Sync library
|
||||
const handleSync = async () => {
|
||||
@@ -260,7 +257,11 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
{/* Time counter */}
|
||||
<div className="tv-np-time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
{formatTime(clampedCurrentTime)} / {
|
||||
playbackType === "podcast" || playbackType === "audiobook"
|
||||
? formatTimeRemaining(Math.max(0, duration - clampedCurrentTime))
|
||||
: formatTime(duration)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Shuffle */}
|
||||
@@ -309,7 +310,7 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<main ref={contentRef} className="tv-content">
|
||||
<main id="main-content" tabIndex={-1} ref={contentRef} className="tv-content">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -44,8 +44,11 @@ export function TopBar() {
|
||||
|
||||
// Track download status from context (single source of truth)
|
||||
const { pendingDownloads, downloadStatus } = useDownloadContext();
|
||||
const hasActiveDownloads =
|
||||
downloadStatus.hasActiveDownloads || pendingDownloads.length > 0;
|
||||
// Only use API-driven state for the icon
|
||||
// pendingDownloads is optimistic local state that can become stale
|
||||
const hasActiveDownloads = downloadStatus.hasActiveDownloads;
|
||||
const hasPendingUploads = pendingDownloads.length > 0 &&
|
||||
pendingDownloads.some(p => Date.now() - p.timestamp < 30000); // Only count recent pending
|
||||
const hasFailedDownloads = downloadStatus.failedDownloads.length > 0;
|
||||
|
||||
const handleSync = async () => {
|
||||
@@ -167,6 +170,7 @@ export function TopBar() {
|
||||
? "bg-white text-black"
|
||||
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
|
||||
)}
|
||||
aria-label="Home"
|
||||
title="Home"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
@@ -186,6 +190,7 @@ export function TopBar() {
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
placeholder="Search..."
|
||||
aria-label="Search"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
tabIndex={0}
|
||||
@@ -241,6 +246,7 @@ export function TopBar() {
|
||||
? "bg-white text-black"
|
||||
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white hover:scale-105"
|
||||
)}
|
||||
aria-label="Home"
|
||||
title="Home"
|
||||
>
|
||||
<Home className="w-6 h-6" />
|
||||
@@ -262,6 +268,7 @@ export function TopBar() {
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
placeholder="What do you want to play?"
|
||||
aria-label="Search"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
tabIndex={0}
|
||||
@@ -276,6 +283,17 @@ export function TopBar() {
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isPolling}
|
||||
aria-label={
|
||||
isPolling
|
||||
? "Library scan in progress"
|
||||
: hasActiveDownloads
|
||||
? `${downloadStatus.activeDownloads.length} download(s) in progress`
|
||||
: hasPendingUploads
|
||||
? `${pendingDownloads.length} download(s) starting`
|
||||
: hasFailedDownloads
|
||||
? `${downloadStatus.failedDownloads.length} download(s) failed`
|
||||
: "Sync library"
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 h-10 rounded-full transition-all text-sm font-medium",
|
||||
isPolling
|
||||
@@ -290,10 +308,9 @@ export function TopBar() {
|
||||
isPolling
|
||||
? "Library scan in progress..."
|
||||
: hasActiveDownloads
|
||||
? `${
|
||||
downloadStatus.activeDownloads
|
||||
.length + pendingDownloads.length
|
||||
} download(s) in progress`
|
||||
? `${downloadStatus.activeDownloads.length} download(s) in progress`
|
||||
: hasPendingUploads
|
||||
? `${pendingDownloads.length} download(s) starting...`
|
||||
: hasFailedDownloads
|
||||
? `${downloadStatus.failedDownloads.length} download(s) failed`
|
||||
: "Sync Library"
|
||||
@@ -316,6 +333,7 @@ export function TopBar() {
|
||||
? "bg-white text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
)}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
@@ -323,6 +341,7 @@ export function TopBar() {
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-all text-red-400 hover:text-red-300"
|
||||
aria-label="Logout"
|
||||
title="Logout"
|
||||
>
|
||||
<Power className="w-5 h-5" />
|
||||
|
||||
@@ -25,12 +25,15 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, lazy, Suspense } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { cn, isLocalUrl } from "@/utils/cn";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime";
|
||||
import { SeekSlider } from "./SeekSlider";
|
||||
|
||||
// Lazy load VibeOverlayEnhanced - only loads when vibe mode is active
|
||||
const EnhancedVibeOverlay = lazy(() => import("./VibeOverlayEnhanced").then(mod => ({ default: mod.EnhancedVibeOverlay })));
|
||||
|
||||
/**
|
||||
* FullPlayer - UI-only component for desktop bottom player
|
||||
@@ -90,19 +93,23 @@ export function FullPlayer() {
|
||||
// Handle Vibe Mode toggle - finds tracks that sound like the current track
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
|
||||
// If vibe mode is on, turn it off
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Otherwise, start vibe mode
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks("vibe", currentTrack.id, 50);
|
||||
|
||||
const response = await api.getRadioTracks(
|
||||
"vibe",
|
||||
currentTrack.id,
|
||||
50
|
||||
);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
// Get the source track's features from the API response
|
||||
const sf = (response as any).sourceFeatures;
|
||||
@@ -126,12 +133,15 @@ export function FullPlayer() {
|
||||
};
|
||||
|
||||
// Start vibe mode with the queue IDs (include current track)
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
const queueIds = [
|
||||
currentTrack.id,
|
||||
...response.tracks.map((t: any) => t.id),
|
||||
];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
|
||||
// Add vibe tracks as upcoming (after current song finishes)
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued up next`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-[#ecb200]" />,
|
||||
@@ -169,32 +179,34 @@ export function FullPlayer() {
|
||||
// For audiobooks/podcasts, show saved progress even before playback starts
|
||||
// This provides immediate visual feedback of where the user left off
|
||||
const displayTime = (() => {
|
||||
let time = currentTime;
|
||||
|
||||
// If we're actively playing or have seeked, use the live currentTime
|
||||
if (currentTime > 0) return currentTime;
|
||||
|
||||
// Otherwise, show saved progress for audiobooks/podcasts
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.currentTime;
|
||||
if (time <= 0) {
|
||||
// Otherwise, show saved progress for audiobooks/podcasts
|
||||
if (
|
||||
playbackType === "audiobook" &&
|
||||
currentAudiobook?.progress?.currentTime
|
||||
) {
|
||||
time = currentAudiobook.progress.currentTime;
|
||||
} else if (
|
||||
playbackType === "podcast" &&
|
||||
currentPodcast?.progress?.currentTime
|
||||
) {
|
||||
time = currentPodcast.progress.currentTime;
|
||||
}
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
|
||||
return currentTime;
|
||||
|
||||
// CRITICAL: Clamp to duration to prevent display of invalid times
|
||||
return clampTime(time, duration);
|
||||
})();
|
||||
|
||||
const progress = duration > 0 ? Math.min(100, Math.max(0, (displayTime / duration) * 100)) : 0;
|
||||
const progress =
|
||||
duration > 0
|
||||
? Math.min(100, Math.max(0, (displayTime / duration) * 100))
|
||||
: 0;
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Don't allow seeking if canSeek is false (uncached podcast)
|
||||
if (!canSeek) {
|
||||
console.log("[FullPlayer] Seeking disabled - podcast not cached");
|
||||
return;
|
||||
}
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const time = percentage * duration;
|
||||
const handleSeek = (time: number) => {
|
||||
seek(time);
|
||||
};
|
||||
|
||||
@@ -220,8 +232,12 @@ export function FullPlayer() {
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 100)
|
||||
: null;
|
||||
albumLink = currentTrack.album?.id ? `/album/${currentTrack.album.id}` : null;
|
||||
artistLink = currentTrack.artist?.id ? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}` : null;
|
||||
albumLink = currentTrack.album?.id
|
||||
? `/album/${currentTrack.album.id}`
|
||||
: null;
|
||||
artistLink = currentTrack.artist?.id
|
||||
? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}`
|
||||
: null;
|
||||
mediaLink = albumLink;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
@@ -249,11 +265,13 @@ export function FullPlayer() {
|
||||
{/* Floating Vibe Overlay - shows when tab is clicked */}
|
||||
{vibeMode && isVibePanelExpanded && (
|
||||
<div className="absolute bottom-full right-4 mb-2 z-50">
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
<Suspense fallback={<div className="bg-[#181818] border border-white/10 rounded-lg p-4 text-white/50">Loading vibe analysis...</div>}>
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -266,8 +284,12 @@ export function FullPlayer() {
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-t-lg",
|
||||
"bg-[#181818] border border-b-0 border-white/10",
|
||||
"text-xs font-medium transition-colors",
|
||||
isVibePanelExpanded ? "text-brand" : "text-white/70 hover:text-brand"
|
||||
isVibePanelExpanded
|
||||
? "text-brand"
|
||||
: "text-white/70 hover:text-brand"
|
||||
)}
|
||||
aria-label={isVibePanelExpanded ? "Hide vibe analysis" : "Show vibe analysis"}
|
||||
aria-expanded={isVibePanelExpanded}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
@@ -282,287 +304,368 @@ export function FullPlayer() {
|
||||
<div className="h-24 bg-black border-t border-white/[0.08]">
|
||||
{/* Subtle top glow */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
<div className="flex items-center h-full px-6 gap-6">
|
||||
{/* Artwork & Info */}
|
||||
<div className="flex items-center gap-4 w-80">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} className="relative w-14 h-14 flex-shrink-0 group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent rounded-full blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="relative w-14 h-14 flex-shrink-0">
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center h-full px-6 gap-6">
|
||||
{/* Artwork & Info */}
|
||||
<div className="flex items-center gap-4 w-80">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} className="block hover:underline">
|
||||
<h4 className="text-white font-semibold truncate text-sm">{title}</h4>
|
||||
</Link>
|
||||
) : (
|
||||
<h4 className="text-white font-semibold truncate text-sm">{title}</h4>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link href={artistLink} className="block hover:underline">
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
</Link>
|
||||
) : mediaLink ? (
|
||||
<Link href={mediaLink} className="block hover:underline">
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Shuffle */}
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
isShuffle
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 30s */}
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia ? "text-gray-400 hover:text-white" : "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={previous}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={isBuffering ? undefined : isPlaying ? pause : resume}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200 relative group",
|
||||
hasMedia && !isBuffering
|
||||
? "bg-white text-black hover:scale-110 shadow-lg shadow-white/20 hover:shadow-white/30"
|
||||
: isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia || isBuffering}
|
||||
title={isBuffering ? "Buffering..." : isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{hasMedia && !isBuffering && (
|
||||
<div className="absolute inset-0 rounded-full bg-white blur-md opacity-0 group-hover:opacity-50 transition-opacity duration-200" />
|
||||
)}
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin relative z-10" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5 relative z-10" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5 relative z-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 30s */}
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia ? "text-gray-400 hover:text-white" : "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
repeatMode !== "off"
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
title={
|
||||
repeatMode === "off"
|
||||
? "Repeat: Off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat: All (loop queue)"
|
||||
: "Repeat: One (play current track twice)"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-4 h-4" />
|
||||
) : (
|
||||
<Repeat className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Vibe Mode Toggle */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
!hasMedia || playbackType !== "track"
|
||||
? "text-gray-600"
|
||||
: vibeMode
|
||||
? "text-[#ecb200] hover:text-[#d4a000]"
|
||||
: "text-gray-400 hover:text-[#ecb200]"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track" || isVibeLoading}
|
||||
title={vibeMode ? "Turn off vibe mode" : "Match this vibe - find similar sounding tracks"}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<span className={cn(
|
||||
"text-xs text-right font-medium tabular-nums",
|
||||
hasMedia ? "text-gray-400" : "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}>
|
||||
{formatTime(displayTime)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 h-1 bg-white/[0.15] rounded-full relative",
|
||||
seekEnabled ? "cursor-pointer group" : "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleSeek : undefined}
|
||||
title={
|
||||
!hasMedia
|
||||
? undefined
|
||||
: !canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached"
|
||||
: "Click to seek"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
seekEnabled ? "bg-white group-hover:bg-white" : hasMedia ? "bg-white/50" : "bg-gray-600"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="relative w-14 h-14 flex-shrink-0 group"
|
||||
>
|
||||
{seekEnabled && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg shadow-white/50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs font-medium tabular-nums",
|
||||
hasMedia ? "text-gray-400" : "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}>
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume & Expand */}
|
||||
<div className="flex items-center gap-3 w-52 justify-end">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="w-5 h-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent rounded-full blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
<div className="relative w-14 h-14 flex-shrink-0">
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-full overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<MusicIcon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-full h-1 bg-white/[0.15] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:shadow-white/30 [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:hover:scale-110"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
{mediaLink ? (
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<h4 className="text-white font-semibold truncate text-sm">
|
||||
{title}
|
||||
</h4>
|
||||
</Link>
|
||||
) : (
|
||||
<h4 className="text-white font-semibold truncate text-sm">
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link
|
||||
href={artistLink}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
</Link>
|
||||
) : mediaLink ? (
|
||||
<Link
|
||||
href={mediaLink}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Info */}
|
||||
<KeyboardShortcutsTooltip />
|
||||
{/* Controls */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-5" role="group" aria-label="Playback controls">
|
||||
{/* Shuffle */}
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
isShuffle
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
aria-label="Shuffle"
|
||||
aria-pressed={isShuffle}
|
||||
title="Shuffle"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className={cn(
|
||||
"transition-all duration-200 border-l border-white/[0.08] pl-3",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white hover:scale-110"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Skip Backward 30s */}
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
aria-label="Rewind 30 seconds"
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={previous}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
aria-label="Previous track"
|
||||
title="Previous track"
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={
|
||||
isBuffering
|
||||
? undefined
|
||||
: isPlaying
|
||||
? pause
|
||||
: resume
|
||||
}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-all duration-200 relative group",
|
||||
hasMedia && !isBuffering
|
||||
? "bg-white text-black hover:scale-110 shadow-lg shadow-white/20 hover:shadow-white/30"
|
||||
: isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia || isBuffering}
|
||||
aria-label={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{hasMedia && !isBuffering && (
|
||||
<div className="absolute inset-0 rounded-full bg-white blur-md opacity-0 group-hover:opacity-50 transition-opacity duration-200" />
|
||||
)}
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin relative z-10" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5 relative z-10" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5 relative z-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
aria-label="Next track"
|
||||
title="Next track"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 30s */}
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 relative disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
aria-label="Forward 30 seconds"
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
<span className="absolute text-[8px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={toggleRepeat}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
repeatMode !== "off"
|
||||
? "text-green-500 hover:text-green-400"
|
||||
: "text-gray-400 hover:text-white"
|
||||
)}
|
||||
disabled={!hasMedia || playbackType !== "track"}
|
||||
aria-label={
|
||||
repeatMode === "off"
|
||||
? "Repeat off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat all"
|
||||
: "Repeat one"
|
||||
}
|
||||
aria-pressed={repeatMode !== "off"}
|
||||
title={
|
||||
repeatMode === "off"
|
||||
? "Repeat: Off"
|
||||
: repeatMode === "all"
|
||||
? "Repeat: All (loop queue)"
|
||||
: "Repeat: One (play current track twice)"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-4 h-4" />
|
||||
) : (
|
||||
<Repeat className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Vibe Mode Toggle */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:scale-110 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100",
|
||||
!hasMedia || playbackType !== "track"
|
||||
? "text-gray-600"
|
||||
: vibeMode
|
||||
? "text-[#ecb200] hover:text-[#d4a000]"
|
||||
: "text-gray-400 hover:text-[#ecb200]"
|
||||
)}
|
||||
disabled={
|
||||
!hasMedia ||
|
||||
playbackType !== "track" ||
|
||||
isVibeLoading
|
||||
}
|
||||
aria-label={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
aria-pressed={vibeMode}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe - find similar sounding tracks"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs text-right font-medium tabular-nums",
|
||||
hasMedia
|
||||
? "text-gray-400"
|
||||
: "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}
|
||||
>
|
||||
{formatTime(displayTime)}
|
||||
</span>
|
||||
<SeekSlider
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
currentTime={displayTime}
|
||||
onSeek={handleSeek}
|
||||
canSeek={canSeek}
|
||||
hasMedia={hasMedia}
|
||||
downloadProgress={downloadProgress}
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium tabular-nums",
|
||||
hasMedia
|
||||
? "text-gray-400"
|
||||
: "text-gray-600",
|
||||
duration >= 3600 ? "w-14" : "w-10" // Wider for h:mm:ss format
|
||||
)}
|
||||
>
|
||||
{playbackType === "podcast" ||
|
||||
playbackType === "audiobook"
|
||||
? formatTimeRemaining(
|
||||
Math.max(0, duration - displayTime)
|
||||
)
|
||||
: formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume & Expand */}
|
||||
<div className="flex items-center gap-3 w-52 justify-end">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="text-gray-400 hover:text-white transition-all duration-200 hover:scale-110"
|
||||
aria-label={volume === 0 ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="w-5 h-5" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
aria-label="Volume"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(volume * 100)}
|
||||
aria-valuetext={`${Math.round(volume * 100)} percent`}
|
||||
style={{
|
||||
background: `linear-gradient(to right, #fff ${volume * 100}%, rgba(255,255,255,0.15) ${volume * 100}%)`
|
||||
}}
|
||||
className="w-full h-1 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:shadow-white/30 [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Info */}
|
||||
<KeyboardShortcutsTooltip />
|
||||
|
||||
<button
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className={cn(
|
||||
"transition-all duration-200 border-l border-white/[0.08] pl-3",
|
||||
hasMedia
|
||||
? "text-gray-400 hover:text-white hover:scale-110"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
aria-label="Expand player"
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAudioControls } from "@/lib/audio-controls-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { howlerEngine } from "@/lib/howler-engine";
|
||||
import { audioSeekEmitter } from "@/lib/audio-seek-emitter";
|
||||
import { dispatchQueryEvent } from "@/lib/query-events";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -71,7 +72,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Controls context
|
||||
const { pause, next } = useAudioControls();
|
||||
const { pause, next, nextPodcastEpisode } = useAudioControls();
|
||||
|
||||
// Refs
|
||||
const lastTrackIdRef = useRef<string | null>(null);
|
||||
@@ -130,8 +131,12 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
savePodcastProgress(true);
|
||||
}
|
||||
|
||||
// Handle track advancement based on repeat mode
|
||||
if (playbackType === "track") {
|
||||
// Handle track advancement based on playback type
|
||||
if (playbackType === "podcast") {
|
||||
nextPodcastEpisode(); // Auto-advance to next episode
|
||||
} else if (playbackType === "audiobook") {
|
||||
pause();
|
||||
} else if (playbackType === "track") {
|
||||
if (repeatMode === "one") {
|
||||
howlerEngine.seek(0);
|
||||
howlerEngine.play();
|
||||
@@ -208,7 +213,7 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
howlerEngine.off("play", handlePlay);
|
||||
howlerEngine.off("pause", handlePause);
|
||||
};
|
||||
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTimeFromEngine, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
|
||||
}, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, nextPodcastEpisode, pause, setCurrentTimeFromEngine, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]);
|
||||
|
||||
// Save audiobook progress
|
||||
const saveAudiobookProgress = useCallback(
|
||||
@@ -245,6 +250,8 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
lastPlayedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
dispatchQueryEvent("audiobook-progress-updated");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save audiobook progress:",
|
||||
@@ -277,6 +284,8 @@ export const HowlerAudioElement = memo(function HowlerAudioElement() {
|
||||
duration,
|
||||
isFinished
|
||||
);
|
||||
|
||||
dispatchQueryEvent("podcast-progress-updated");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[HowlerAudioElement] Failed to save podcast progress:",
|
||||
|
||||
@@ -26,9 +26,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { clampTime } from "@/utils/formatTime";
|
||||
import { useState, useRef, useEffect, lazy, Suspense } from "react";
|
||||
import { KeyboardShortcutsTooltip } from "./KeyboardShortcutsTooltip";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { SeekSlider } from "./SeekSlider";
|
||||
|
||||
// Lazy load VibeOverlayEnhanced - only loads when vibe mode is active
|
||||
const EnhancedVibeOverlay = lazy(() => import("./VibeOverlayEnhanced").then(mod => ({ default: mod.EnhancedVibeOverlay })));
|
||||
|
||||
export function MiniPlayer() {
|
||||
const {
|
||||
@@ -80,12 +84,19 @@ export function MiniPlayer() {
|
||||
currentTrack?.id || currentAudiobook?.id || currentPodcast?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMediaId && currentMediaId !== lastMediaIdRef.current) {
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
setIsDismissed(false);
|
||||
setIsMinimized(false);
|
||||
// Reset dismissed state when new media loads OR when same media starts playing again
|
||||
if (currentMediaId) {
|
||||
if (currentMediaId !== lastMediaIdRef.current) {
|
||||
// Different media - reset everything
|
||||
lastMediaIdRef.current = currentMediaId;
|
||||
setIsDismissed(false);
|
||||
setIsMinimized(false);
|
||||
} else if (isDismissed && isPlaying) {
|
||||
// Same media but user started playing again - show the player
|
||||
setIsDismissed(false);
|
||||
}
|
||||
}
|
||||
}, [currentMediaId]);
|
||||
}, [currentMediaId, isDismissed, isPlaying]);
|
||||
|
||||
// Handle Vibe Match toggle - finds tracks that sound like the current track
|
||||
const handleVibeToggle = async () => {
|
||||
@@ -210,19 +221,18 @@ export function MiniPlayer() {
|
||||
0
|
||||
);
|
||||
})();
|
||||
|
||||
// CRITICAL: Clamp currentTime to prevent invalid progress display
|
||||
const clampedCurrentTime = clampTime(currentTime, duration);
|
||||
|
||||
const progress =
|
||||
duration > 0
|
||||
? Math.min(100, Math.max(0, (currentTime / duration) * 100))
|
||||
? Math.min(100, Math.max(0, (clampedCurrentTime / duration) * 100))
|
||||
: 0;
|
||||
|
||||
// Handle progress bar click
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const newTime = percentage * duration;
|
||||
seek(newTime);
|
||||
// Handle progress bar seek
|
||||
const handleSeek = (time: number) => {
|
||||
seek(time);
|
||||
};
|
||||
|
||||
const seekEnabled = hasMedia && canSeek;
|
||||
@@ -277,34 +287,59 @@ export function MiniPlayer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimized tab - small pill on RIGHT to bring player back
|
||||
// Minimized tab - matches full player height, slides from right
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className="fixed right-0 z-50 bg-gradient-to-l from-[#f5c518] via-[#e6a700] to-[#a855f7] rounded-l-full pl-3 pr-2 py-2 shadow-lg flex items-center gap-2 transition-transform hover:scale-105 active:scale-95"
|
||||
className="fixed right-0 z-50 shadow-2xl transition-transform hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 16px)",
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
}}
|
||||
aria-label="Show player"
|
||||
title="Show player"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-black" />
|
||||
{coverUrl ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden ring-2 ring-black/20">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="rounded-l-xl p-[2px]"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #a855f7 0%, #f5c518 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="rounded-l-[10px] overflow-hidden">
|
||||
<div className="relative bg-gradient-to-r from-[#2d1847] to-[#1a1a2e]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#a855f7]/40 to-[#f5c518]/30" />
|
||||
|
||||
{/* Progress bar at top */}
|
||||
<div className="relative h-[2px] bg-white/20 w-full">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#a855f7] to-[#f5c518] transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex items-center gap-2 pl-3 pr-2 py-3">
|
||||
<ChevronLeft className="w-4 h-4 text-white flex-shrink-0" />
|
||||
{coverUrl ? (
|
||||
<div className="relative w-12 h-12 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-black/30 flex items-center justify-center">
|
||||
<MusicIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<MusicIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -314,128 +349,155 @@ export function MiniPlayer() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed left-2 right-2 z-50 rounded-xl overflow-hidden shadow-xl"
|
||||
className="fixed left-2 right-2 z-50 shadow-2xl"
|
||||
style={{
|
||||
bottom: "calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
transform: `translateX(${swipeOffset}px)`,
|
||||
opacity: swipeOpacity,
|
||||
transition: swipeOffset === 0 ? 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
|
||||
transition:
|
||||
swipeOffset === 0
|
||||
? "transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
: "none",
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Gradient background - richer, more vibrant colors */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#1a1a2e] via-[#2d1847] to-[#1a1a2e]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#f5c518]/30 via-[#a855f7]/40 to-[#f5c518]/30" />
|
||||
{/* Edge glow effects */}
|
||||
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-[#f5c518] via-[#e6a700] to-[#f5c518]" />
|
||||
<div className="absolute inset-y-0 right-0 w-1 bg-gradient-to-b from-[#a855f7] via-[#7c3aed] to-[#a855f7]" />
|
||||
|
||||
{/* Progress bar at top */}
|
||||
<div className="relative h-[2px] bg-white/20 w-full">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#f5c518] via-[#e6a700] to-[#a855f7] transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player content - more spacious padding */}
|
||||
{/* Gradient border container - uses padding technique for gradient border */}
|
||||
<div
|
||||
className="relative flex items-center gap-3 px-3 py-3 cursor-pointer"
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
className="rounded-[14px] p-[2px]"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #f5c518 0%, #a855f7 50%, #f5c518 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Album Art - slightly larger */}
|
||||
<div className="relative w-12 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-black/30 shadow-md">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-5 h-5 text-gray-400" />
|
||||
{/* Inner container with overflow hidden for proper clipping */}
|
||||
<div className="rounded-[12px] overflow-hidden">
|
||||
{/* Single solid background with gradient overlay - prevents corner bleed */}
|
||||
<div className="relative bg-gradient-to-r from-[#2a1a3f] via-[#3d2060] to-[#2a1a3f]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#f5c518]/25 via-[#a855f7]/35 to-[#f5c518]/25" />
|
||||
|
||||
{/* Progress bar at top - inside the clipped container */}
|
||||
<div className="relative h-[2px] bg-white/20 w-full">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#f5c518] via-[#e6a700] to-[#a855f7] transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate leading-tight">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-gray-300/70 text-xs truncate leading-tight mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{/* Player content - more spacious padding */}
|
||||
<div
|
||||
className="relative flex items-center gap-3 px-3 py-3 cursor-pointer"
|
||||
onClick={() => setPlayerMode("overlay")}
|
||||
>
|
||||
{/* Album Art - slightly larger */}
|
||||
<div className="relative w-12 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-black/30 shadow-md">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls - Vibe & Play/Pause */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Vibe Button */}
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={!canSkip || isVibeLoading}
|
||||
className={cn(
|
||||
"w-10 h-10 flex items-center justify-center rounded-full transition-colors",
|
||||
!canSkip
|
||||
? "text-gray-600"
|
||||
: vibeMode
|
||||
? "text-[#f5c518]"
|
||||
: "text-white/80 hover:text-[#f5c518]"
|
||||
)}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate leading-tight">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-gray-300/70 text-xs truncate leading-tight mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isBuffering) {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition shadow-md",
|
||||
isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-white text-black hover:scale-105"
|
||||
)}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Controls - Vibe button (for music only) & Play/Pause */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="group"
|
||||
aria-label="Playback controls"
|
||||
>
|
||||
{/* Vibe button - only for music tracks */}
|
||||
{canSkip && (
|
||||
<button
|
||||
onClick={handleVibeToggle}
|
||||
disabled={isVibeLoading}
|
||||
className={cn(
|
||||
"w-10 h-10 flex items-center justify-center rounded-full transition-colors",
|
||||
vibeMode
|
||||
? "text-[#f5c518]"
|
||||
: "text-white/80 hover:text-[#f5c518]"
|
||||
)}
|
||||
aria-label={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
aria-pressed={vibeMode}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<AudioWaveform className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isBuffering) {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition shadow-md",
|
||||
isBuffering
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-white text-black hover:scale-105"
|
||||
)}
|
||||
aria-label={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
: isPlaying
|
||||
? "Pause"
|
||||
: "Play"
|
||||
}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,11 +518,13 @@ export function MiniPlayer() {
|
||||
)}
|
||||
>
|
||||
<div className="bg-[#121212]">
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="inline"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
<Suspense fallback={<div className="p-4 text-center text-white/50">Loading vibe analysis...</div>}>
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="inline"
|
||||
onClose={() => setIsVibePanelExpanded(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,6 +542,8 @@ export function MiniPlayer() {
|
||||
? "text-brand"
|
||||
: "text-white/70 hover:text-brand"
|
||||
)}
|
||||
aria-label={isVibePanelExpanded ? "Hide vibe analysis" : "Show vibe analysis"}
|
||||
aria-expanded={isVibePanelExpanded}
|
||||
>
|
||||
<AudioWaveform className="w-3.5 h-3.5" />
|
||||
<span>Vibe Analysis</span>
|
||||
@@ -494,40 +560,17 @@ export function MiniPlayer() {
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 h-1 bg-white/[0.15] transition-all",
|
||||
seekEnabled
|
||||
? "cursor-pointer group hover:h-2"
|
||||
: "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleProgressClick : undefined}
|
||||
title={
|
||||
!hasMedia
|
||||
? undefined
|
||||
: !canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached"
|
||||
: "Click to seek"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
seekEnabled
|
||||
? "bg-white"
|
||||
: hasMedia
|
||||
? "bg-white/50"
|
||||
: "bg-gray-600"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
{seekEnabled && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg shadow-white/50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SeekSlider
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
currentTime={clampedCurrentTime}
|
||||
onSeek={handleSeek}
|
||||
canSeek={canSeek}
|
||||
hasMedia={hasMedia}
|
||||
downloadProgress={downloadProgress}
|
||||
variant="minimal"
|
||||
className="absolute top-0 left-0 right-0"
|
||||
/>
|
||||
|
||||
{/* Player Content */}
|
||||
<div className="px-3 py-2.5 pt-3">
|
||||
@@ -591,6 +634,7 @@ export function MiniPlayer() {
|
||||
<button
|
||||
onClick={() => setPlayerMode("full")}
|
||||
className="text-gray-400 hover:text-white transition p-1"
|
||||
aria-label="Show bottom player"
|
||||
title="Show bottom player"
|
||||
>
|
||||
<MonitorUp className="w-3.5 h-3.5" />
|
||||
@@ -604,6 +648,7 @@ export function MiniPlayer() {
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!hasMedia}
|
||||
aria-label="Expand player"
|
||||
title="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
@@ -625,6 +670,8 @@ export function MiniPlayer() {
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Shuffle"
|
||||
aria-pressed={isShuffle}
|
||||
title={canSkip ? "Shuffle" : "Shuffle (music only)"}
|
||||
>
|
||||
<Shuffle className="w-3.5 h-3.5" />
|
||||
@@ -640,6 +687,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Skip backward 30 seconds"
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
@@ -658,6 +706,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Previous track"
|
||||
title={
|
||||
canSkip ? "Previous" : "Previous (music only)"
|
||||
}
|
||||
@@ -683,6 +732,7 @@ export function MiniPlayer() {
|
||||
? "bg-white/80 text-black"
|
||||
: "bg-gray-700 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
title={
|
||||
isBuffering
|
||||
? "Buffering..."
|
||||
@@ -710,6 +760,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Next track"
|
||||
title={canSkip ? "Next" : "Next (music only)"}
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
@@ -725,6 +776,7 @@ export function MiniPlayer() {
|
||||
? "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
@@ -745,6 +797,8 @@ export function MiniPlayer() {
|
||||
: "text-gray-400 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
)}
|
||||
aria-label={repeatMode === 'one' ? "Repeat one" : repeatMode === 'all' ? "Repeat all" : "Repeat off"}
|
||||
aria-pressed={repeatMode !== 'off'}
|
||||
title={
|
||||
canSkip
|
||||
? repeatMode === "off"
|
||||
@@ -774,6 +828,8 @@ export function MiniPlayer() {
|
||||
? "text-brand hover:text-brand-hover"
|
||||
: "text-gray-400 hover:text-brand"
|
||||
)}
|
||||
aria-label="Toggle vibe visualization"
|
||||
aria-pressed={vibeMode}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
|
||||
@@ -17,13 +17,16 @@ import {
|
||||
Repeat1,
|
||||
AudioWaveform,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { toast } from "sonner";
|
||||
import { VibeComparisonArt } from "./VibeOverlay";
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { SeekSlider } from "./SeekSlider";
|
||||
|
||||
export function OverlayPlayer() {
|
||||
const {
|
||||
@@ -46,6 +49,8 @@ export function OverlayPlayer() {
|
||||
previous,
|
||||
returnToPreviousMode,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setUpcoming,
|
||||
@@ -53,14 +58,14 @@ export function OverlayPlayer() {
|
||||
stopVibeMode,
|
||||
duration: playbackDuration,
|
||||
} = useAudio();
|
||||
|
||||
|
||||
// Get current track's audio features for vibe comparison
|
||||
const currentTrackFeatures = queue[currentIndex]?.audioFeatures || null;
|
||||
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
|
||||
// Swipe state for track skipping
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
@@ -85,27 +90,35 @@ export function OverlayPlayer() {
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) return null;
|
||||
|
||||
const displayTime = (() => {
|
||||
if (currentTime > 0) return currentTime;
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
return currentAudiobook.progress.currentTime;
|
||||
let time = currentTime;
|
||||
|
||||
if (time <= 0) {
|
||||
if (
|
||||
playbackType === "audiobook" &&
|
||||
currentAudiobook?.progress?.currentTime
|
||||
) {
|
||||
time = currentAudiobook.progress.currentTime;
|
||||
} else if (
|
||||
playbackType === "podcast" &&
|
||||
currentPodcast?.progress?.currentTime
|
||||
) {
|
||||
time = currentPodcast.progress.currentTime;
|
||||
}
|
||||
}
|
||||
if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
return currentPodcast.progress.currentTime;
|
||||
}
|
||||
return currentTime;
|
||||
|
||||
// CRITICAL: Clamp to duration to prevent display of invalid times
|
||||
return clampTime(time, duration);
|
||||
})();
|
||||
|
||||
const progress = duration > 0 ? Math.min(100, Math.max(0, (displayTime / duration) * 100)) : 0;
|
||||
const progress =
|
||||
duration > 0
|
||||
? Math.min(100, Math.max(0, (displayTime / duration) * 100))
|
||||
: 0;
|
||||
const seekEnabled = canSeek;
|
||||
const canSkip = playbackType === "track";
|
||||
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const x = clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const time = percentage * duration;
|
||||
const handleSeek = (time: number) => {
|
||||
seek(time);
|
||||
};
|
||||
|
||||
@@ -122,7 +135,7 @@ export function OverlayPlayer() {
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchStartX.current === null) return;
|
||||
|
||||
|
||||
if (canSkip) {
|
||||
if (swipeOffset > 60) {
|
||||
previous();
|
||||
@@ -130,7 +143,7 @@ export function OverlayPlayer() {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setSwipeOffset(0);
|
||||
touchStartX.current = null;
|
||||
};
|
||||
@@ -138,17 +151,21 @@ export function OverlayPlayer() {
|
||||
// Handle Vibe toggle
|
||||
const handleVibeToggle = async () => {
|
||||
if (!currentTrack?.id) return;
|
||||
|
||||
|
||||
if (vibeMode) {
|
||||
stopVibeMode();
|
||||
toast.success("Vibe mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsVibeLoading(true);
|
||||
try {
|
||||
const response = await api.getRadioTracks("vibe", currentTrack.id, 50);
|
||||
|
||||
const response = await api.getRadioTracks(
|
||||
"vibe",
|
||||
currentTrack.id,
|
||||
50
|
||||
);
|
||||
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
const sf = (response as any).sourceFeatures;
|
||||
const sourceFeatures = {
|
||||
@@ -170,10 +187,13 @@ export function OverlayPlayer() {
|
||||
moodElectronic: sf?.moodElectronic,
|
||||
};
|
||||
|
||||
const queueIds = [currentTrack.id, ...response.tracks.map((t: any) => t.id)];
|
||||
const queueIds = [
|
||||
currentTrack.id,
|
||||
...response.tracks.map((t: any) => t.id),
|
||||
];
|
||||
startVibeMode(sourceFeatures, queueIds);
|
||||
setUpcoming(response.tracks, true); // preserveOrder=true for vibe mode
|
||||
|
||||
|
||||
toast.success(`Vibe mode on`, {
|
||||
description: `${response.tracks.length} matching tracks queued`,
|
||||
icon: <AudioWaveform className="w-4 h-4 text-[#f5c518]" />,
|
||||
@@ -203,8 +223,12 @@ export function OverlayPlayer() {
|
||||
coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 500)
|
||||
: null;
|
||||
albumLink = currentTrack.album?.id ? `/album/${currentTrack.album.id}` : null;
|
||||
artistLink = currentTrack.artist?.id ? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}` : null;
|
||||
albumLink = currentTrack.album?.id
|
||||
? `/album/${currentTrack.album.id}`
|
||||
: null;
|
||||
artistLink = currentTrack.artist?.id
|
||||
? `/artist/${currentTrack.artist.mbid || currentTrack.artist.id}`
|
||||
: null;
|
||||
mediaLink = albumLink;
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
title = currentAudiobook.title;
|
||||
@@ -224,16 +248,16 @@ export function OverlayPlayer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-b from-[#1a1a2e] via-[#121218] to-[#000000] z-[9999] flex flex-col overflow-hidden"
|
||||
onTouchStart={isMobileOrTablet ? handleTouchStart : undefined}
|
||||
onTouchMove={isMobileOrTablet ? handleTouchMove : undefined}
|
||||
onTouchEnd={isMobileOrTablet ? handleTouchEnd : undefined}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ paddingTop: 'calc(12px + env(safe-area-inset-top))' }}
|
||||
style={{ paddingTop: "calc(12px + env(safe-area-inset-top))" }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -246,38 +270,39 @@ export function OverlayPlayer() {
|
||||
>
|
||||
<ChevronDown className="w-7 h-7" />
|
||||
</button>
|
||||
|
||||
{/* Now Playing indicator */}
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest font-medium">
|
||||
Now Playing
|
||||
</span>
|
||||
|
||||
<div className="w-11" /> {/* Spacer for centering */}
|
||||
</div>
|
||||
|
||||
{/* Main Content - Portrait vs Landscape */}
|
||||
<div className="flex-1 flex flex-col landscape:flex-row items-center justify-center px-6 pb-6 landscape:px-8 landscape:gap-8 overflow-hidden">
|
||||
|
||||
{/* Artwork Section */}
|
||||
<div
|
||||
className="w-full max-w-[280px] landscape:max-w-[240px] landscape:w-[240px] aspect-square flex-shrink-0 mb-6 landscape:mb-0 relative"
|
||||
style={{
|
||||
<div
|
||||
className="w-full max-w-[320px] landscape:max-w-[240px] landscape:w-[240px] aspect-square flex-shrink-0 mb-6 landscape:mb-0 relative"
|
||||
style={{
|
||||
transform: `translateX(${swipeOffset * 0.5}px)`,
|
||||
opacity: 1 - Math.abs(swipeOffset) / 200
|
||||
opacity: 1 - Math.abs(swipeOffset) / 200,
|
||||
}}
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 rounded-2xl blur-2xl opacity-50",
|
||||
vibeMode
|
||||
? "bg-gradient-to-br from-brand/30 via-transparent to-purple-500/30"
|
||||
: "bg-gradient-to-br from-[#f5c518]/20 via-transparent to-[#a855f7]/20"
|
||||
)} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 rounded-2xl blur-2xl opacity-50",
|
||||
vibeMode
|
||||
? "bg-gradient-to-br from-brand/30 via-transparent to-purple-500/30"
|
||||
: "bg-gradient-to-br from-[#f5c518]/20 via-transparent to-[#a855f7]/20"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Album art OR Vibe Comparison when in vibe mode */}
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-[#2a2a2a] to-[#1a1a1a] rounded-2xl overflow-hidden shadow-2xl">
|
||||
{vibeMode && currentTrackFeatures ? (
|
||||
<VibeComparisonArt currentTrackFeatures={currentTrackFeatures} />
|
||||
<VibeComparisonArt
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
/>
|
||||
) : coverUrl ? (
|
||||
<Image
|
||||
key={coverUrl}
|
||||
@@ -295,20 +320,24 @@ export function OverlayPlayer() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Swipe hint indicators */}
|
||||
{canSkip && isMobileOrTablet && Math.abs(swipeOffset) > 20 && (
|
||||
<div className={cn(
|
||||
"absolute top-1/2 -translate-y-1/2 text-white/60",
|
||||
swipeOffset > 0 ? "-left-8" : "-right-8"
|
||||
)}>
|
||||
{swipeOffset > 0 ? (
|
||||
<SkipBack className="w-6 h-6" />
|
||||
) : (
|
||||
<SkipForward className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canSkip &&
|
||||
isMobileOrTablet &&
|
||||
Math.abs(swipeOffset) > 20 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 -translate-y-1/2 text-white/60",
|
||||
swipeOffset > 0 ? "-left-8" : "-right-8"
|
||||
)}
|
||||
>
|
||||
{swipeOffset > 0 ? (
|
||||
<SkipBack className="w-6 h-6" />
|
||||
) : (
|
||||
<SkipForward className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info & Controls Section */}
|
||||
@@ -316,7 +345,11 @@ export function OverlayPlayer() {
|
||||
{/* Track Info */}
|
||||
<div className="text-center landscape:text-left mb-6">
|
||||
{mediaLink ? (
|
||||
<Link href={mediaLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<Link
|
||||
href={mediaLink}
|
||||
onClick={returnToPreviousMode}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<h1 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
@@ -327,7 +360,11 @@ export function OverlayPlayer() {
|
||||
</h1>
|
||||
)}
|
||||
{artistLink ? (
|
||||
<Link href={artistLink} onClick={returnToPreviousMode} className="block hover:underline">
|
||||
<Link
|
||||
href={artistLink}
|
||||
onClick={returnToPreviousMode}
|
||||
className="block hover:underline"
|
||||
>
|
||||
<p className="text-base text-gray-400 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
@@ -341,41 +378,50 @@ export function OverlayPlayer() {
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-1 bg-white/20 rounded-full mb-2",
|
||||
seekEnabled ? "cursor-pointer" : "cursor-not-allowed"
|
||||
)}
|
||||
onClick={seekEnabled ? handleSeek : undefined}
|
||||
title={!canSeek
|
||||
? downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%...`
|
||||
: "Downloading..."
|
||||
: "Tap to seek"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-150",
|
||||
seekEnabled
|
||||
? "bg-gradient-to-r from-[#f5c518] to-[#a855f7]"
|
||||
: "bg-white/40"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<SeekSlider
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
currentTime={displayTime}
|
||||
onSeek={handleSeek}
|
||||
canSeek={canSeek}
|
||||
hasMedia={hasMedia}
|
||||
downloadProgress={downloadProgress}
|
||||
variant="overlay"
|
||||
showHandle={false}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 font-medium tabular-nums">
|
||||
<span>{formatTime(displayTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
<span>
|
||||
{playbackType === "podcast" || playbackType === "audiobook"
|
||||
? formatTimeRemaining(Math.max(0, duration - displayTime))
|
||||
: formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-6 mb-6">
|
||||
{/* Skip -30s (for audiobooks/podcasts) */}
|
||||
{!canSkip && (
|
||||
<button
|
||||
onClick={() => skipBackward(30)}
|
||||
className="text-white/80 hover:text-white transition-all hover:scale-110 relative"
|
||||
title="Rewind 30 seconds"
|
||||
>
|
||||
<RotateCcw className="w-7 h-7" />
|
||||
<span className="absolute text-[9px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={previous}
|
||||
className={cn(
|
||||
"text-white/80 hover:text-white transition-all hover:scale-110",
|
||||
!canSkip && "opacity-30 cursor-not-allowed hover:scale-100"
|
||||
!canSkip &&
|
||||
"opacity-30 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
disabled={!canSkip}
|
||||
title={canSkip ? "Previous" : "Skip only for music"}
|
||||
@@ -399,13 +445,28 @@ export function OverlayPlayer() {
|
||||
onClick={next}
|
||||
className={cn(
|
||||
"text-white/80 hover:text-white transition-all hover:scale-110",
|
||||
!canSkip && "opacity-30 cursor-not-allowed hover:scale-100"
|
||||
!canSkip &&
|
||||
"opacity-30 cursor-not-allowed hover:scale-100"
|
||||
)}
|
||||
disabled={!canSkip}
|
||||
title={canSkip ? "Next" : "Skip only for music"}
|
||||
>
|
||||
<SkipForward className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
{/* Skip +30s (for audiobooks/podcasts) */}
|
||||
{!canSkip && (
|
||||
<button
|
||||
onClick={() => skipForward(30)}
|
||||
className="text-white/80 hover:text-white transition-all hover:scale-110 relative"
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<RotateCw className="w-7 h-7" />
|
||||
<span className="absolute text-[9px] font-bold top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
30
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Secondary Controls */}
|
||||
@@ -437,7 +498,13 @@ export function OverlayPlayer() {
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-white"
|
||||
)}
|
||||
title={repeatMode === "one" ? "Repeat One" : repeatMode === "all" ? "Repeat All" : "Repeat Off"}
|
||||
title={
|
||||
repeatMode === "one"
|
||||
? "Repeat One"
|
||||
: repeatMode === "all"
|
||||
? "Repeat All"
|
||||
: "Repeat Off"
|
||||
}
|
||||
>
|
||||
{repeatMode === "one" ? (
|
||||
<Repeat1 className="w-5 h-5" />
|
||||
@@ -457,7 +524,11 @@ export function OverlayPlayer() {
|
||||
? "text-[#f5c518]"
|
||||
: "text-gray-500 hover:text-[#f5c518]"
|
||||
)}
|
||||
title={vibeMode ? "Turn off vibe mode" : "Match this vibe"}
|
||||
title={
|
||||
vibeMode
|
||||
? "Turn off vibe mode"
|
||||
: "Match this vibe"
|
||||
}
|
||||
>
|
||||
{isVibeLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
@@ -468,9 +539,9 @@ export function OverlayPlayer() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Safe area padding at bottom */}
|
||||
<div style={{ height: 'env(safe-area-inset-bottom)' }} />
|
||||
<div style={{ height: "env(safe-area-inset-bottom)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
275
frontend/components/player/SeekSlider.tsx
Normal file
275
frontend/components/player/SeekSlider.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
interface SeekSliderProps {
|
||||
/** Current progress percentage (0-100) */
|
||||
progress: number;
|
||||
/** Duration in seconds */
|
||||
duration: number;
|
||||
/** Current time in seconds */
|
||||
currentTime: number;
|
||||
/** Callback when seeking to a new position */
|
||||
onSeek: (time: number) => void;
|
||||
/** Whether seeking is enabled */
|
||||
canSeek: boolean;
|
||||
/** Whether the slider has media loaded */
|
||||
hasMedia: boolean;
|
||||
/** Download progress (0-100) if downloading */
|
||||
downloadProgress?: number | null;
|
||||
/** Custom class name for the container */
|
||||
className?: string;
|
||||
/** Whether to show the drag handle on hover/drag */
|
||||
showHandle?: boolean;
|
||||
/** Variant styling */
|
||||
variant?: "default" | "minimal" | "overlay";
|
||||
}
|
||||
|
||||
export function SeekSlider({
|
||||
progress,
|
||||
duration,
|
||||
currentTime,
|
||||
onSeek,
|
||||
canSeek,
|
||||
hasMedia,
|
||||
downloadProgress,
|
||||
className,
|
||||
showHandle = true,
|
||||
variant = "default",
|
||||
}: SeekSliderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const touchIdentifierRef = useRef<number | null>(null);
|
||||
|
||||
const calculateProgress = useCallback((clientX: number): number => {
|
||||
if (!sliderRef.current) return 0;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
return percentage;
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
|
||||
// Store the touch identifier to track this specific touch
|
||||
const touch = e.touches[0];
|
||||
touchIdentifierRef.current = touch.identifier;
|
||||
|
||||
setIsDragging(true);
|
||||
const newProgress = calculateProgress(touch.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
|
||||
// Prevent default to avoid scrolling and stop propagation to prevent parent swipe handlers
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
// Find the touch that we're tracking
|
||||
const touch = Array.from(e.touches).find(
|
||||
(t) => t.identifier === touchIdentifierRef.current
|
||||
);
|
||||
|
||||
if (!touch) return;
|
||||
|
||||
const newProgress = calculateProgress(touch.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
|
||||
// Prevent default to avoid scrolling and stop propagation to prevent parent swipe handlers
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[isDragging, canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
// Check if the touch we're tracking ended
|
||||
const touchEnded = !Array.from(e.touches).some(
|
||||
(t) => t.identifier === touchIdentifierRef.current
|
||||
);
|
||||
|
||||
if (!touchEnded) return;
|
||||
|
||||
if (previewProgress !== null) {
|
||||
const newTime = (previewProgress / 100) * duration;
|
||||
onSeek(newTime);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
setPreviewProgress(null);
|
||||
touchIdentifierRef.current = null;
|
||||
|
||||
// Stop propagation to prevent parent swipe handlers
|
||||
e.stopPropagation();
|
||||
},
|
||||
[isDragging, canSeek, previewProgress, duration, onSeek]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!canSeek) return;
|
||||
|
||||
setIsDragging(true);
|
||||
const newProgress = calculateProgress(e.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
},
|
||||
[canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
const newProgress = calculateProgress(e.clientX);
|
||||
setPreviewProgress(newProgress);
|
||||
},
|
||||
[isDragging, canSeek, calculateProgress]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDragging || !canSeek) return;
|
||||
|
||||
if (previewProgress !== null) {
|
||||
const newTime = (previewProgress / 100) * duration;
|
||||
onSeek(newTime);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
setPreviewProgress(null);
|
||||
}, [isDragging, canSeek, previewProgress, duration, onSeek]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Don't handle click if we just finished dragging
|
||||
if (isDragging) return;
|
||||
if (!canSeek) return;
|
||||
|
||||
const newProgress = calculateProgress(e.clientX);
|
||||
const newTime = (newProgress / 100) * duration;
|
||||
onSeek(newTime);
|
||||
},
|
||||
[isDragging, canSeek, calculateProgress, duration, onSeek]
|
||||
);
|
||||
|
||||
// Add global mouse event listeners when dragging
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const displayProgress =
|
||||
previewProgress !== null ? previewProgress : progress;
|
||||
const isActive = canSeek && hasMedia;
|
||||
|
||||
// Determine tooltip text
|
||||
const getTooltipText = () => {
|
||||
if (!hasMedia) return undefined;
|
||||
if (!canSeek) {
|
||||
return downloadProgress !== null
|
||||
? `Downloading ${downloadProgress}%... Seek will be available when cached`
|
||||
: "Downloading... Seeking will be available when cached";
|
||||
}
|
||||
return isDragging ? "Release to seek" : "Click or drag to seek";
|
||||
};
|
||||
|
||||
// Variant-specific styles
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case "minimal":
|
||||
return {
|
||||
container: "h-1",
|
||||
track: "bg-white/[0.15]",
|
||||
progress: isActive
|
||||
? "bg-white"
|
||||
: hasMedia
|
||||
? "bg-white/50"
|
||||
: "bg-gray-600",
|
||||
};
|
||||
case "overlay":
|
||||
return {
|
||||
container: "h-1",
|
||||
track: "bg-white/20",
|
||||
progress: isActive
|
||||
? "bg-gradient-to-r from-[#f5c518] to-[#a855f7]"
|
||||
: "bg-white/40",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
container: "h-1",
|
||||
track: "bg-white/[0.15]",
|
||||
progress: isActive
|
||||
? "bg-white group-hover:bg-white"
|
||||
: hasMedia
|
||||
? "bg-white/50"
|
||||
: "bg-gray-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getVariantStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={cn(
|
||||
"relative rounded-full transition-all",
|
||||
styles.container,
|
||||
styles.track,
|
||||
isActive ? "cursor-pointer group" : "cursor-not-allowed",
|
||||
isDragging && "h-2", // Expand when dragging
|
||||
className
|
||||
)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
title={getTooltipText()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative transition-all duration-150",
|
||||
styles.progress
|
||||
)}
|
||||
style={{ width: `${displayProgress}%` }}
|
||||
>
|
||||
{showHandle && isActive && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full transition-opacity shadow-lg shadow-white/50",
|
||||
isDragging
|
||||
? "opacity-100 scale-125"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual feedback when dragging */}
|
||||
{isDragging && (
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-black/80 text-white text-xs px-2 py-1 rounded pointer-events-none whitespace-nowrap">
|
||||
{Math.floor((displayProgress / 100) * duration)}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -541,7 +541,7 @@ export function VibeComparisonArt({
|
||||
if (!vibeMode || !comparisons) return null;
|
||||
|
||||
// Radar chart dimensions
|
||||
const size = 280;
|
||||
const size = 320;
|
||||
const center = size / 2;
|
||||
const maxRadius = 110;
|
||||
|
||||
@@ -564,7 +564,8 @@ export function VibeComparisonArt({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full h-full bg-gradient-to-br from-[#1a1a2e] via-[#0f0f1a] to-[#000000] flex items-center justify-center overflow-hidden",
|
||||
"relative w-full h-full flex items-center justify-center",
|
||||
"md:bg-gradient-to-br md:from-[#1a1a2e] md:via-[#0f0f1a] md:to-[#000000] md:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "@/lib/audio-state-context";
|
||||
import { EnhancedVibeOverlay } from "./VibeOverlayEnhanced";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
|
||||
// Lazy load VibeOverlayEnhanced - only loads when vibe mode is active
|
||||
const EnhancedVibeOverlay = lazy(() => import("./VibeOverlayEnhanced").then(mod => ({ default: mod.EnhancedVibeOverlay })));
|
||||
|
||||
/**
|
||||
* Container component that manages the floating EnhancedVibeOverlay.
|
||||
@@ -31,10 +33,12 @@ export function VibeOverlayContainer() {
|
||||
if (!vibeMode || isDismissed || !isVisible) return null;
|
||||
|
||||
return (
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsDismissed(true)}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<EnhancedVibeOverlay
|
||||
currentTrackFeatures={currentTrackFeatures}
|
||||
variant="floating"
|
||||
onClose={() => setIsDismissed(true)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
74
frontend/components/providers/GlobalErrorBoundary.tsx
Normal file
74
frontend/components/providers/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global error boundary for catching application-level errors.
|
||||
* Provides a fallback UI when critical errors occur in production.
|
||||
*/
|
||||
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("[GlobalErrorBoundary] Application error:", error);
|
||||
console.error(
|
||||
"[GlobalErrorBoundary] Component stack:",
|
||||
errorInfo.componentStack
|
||||
);
|
||||
}
|
||||
|
||||
private handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950 text-white p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-gray-400">
|
||||
An unexpected error occurred. Please try
|
||||
reloading the page.
|
||||
</p>
|
||||
</div>
|
||||
{this.state.error && (
|
||||
<div className="bg-gray-900 p-4 rounded-lg text-left">
|
||||
<p className="text-sm font-mono text-red-400 break-words">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
|
||||
/**
|
||||
* GalaxyBackground Component
|
||||
@@ -21,6 +22,11 @@ interface GalaxyBackgroundProps {
|
||||
}
|
||||
|
||||
export function GalaxyBackground({ primaryColor, secondaryColor }: GalaxyBackgroundProps = {}) {
|
||||
// Performance optimization: disable animations on mobile and for reduced-motion preference
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||
const shouldDisableAnimations = isMobile || prefersReducedMotion;
|
||||
|
||||
// Convert hex color to RGB values for opacity control
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
@@ -89,6 +95,9 @@ export function GalaxyBackground({ primaryColor, secondaryColor }: GalaxyBackgro
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floating Star Particles - only render on desktop with motion enabled */}
|
||||
{!shouldDisableAnimations && (
|
||||
<>
|
||||
{/* Floating Star Particles - more concentrated at bottom */}
|
||||
{/* Bottom layer - most prominent */}
|
||||
{particles.bottom.map((p, i) => (
|
||||
@@ -171,6 +180,8 @@ export function GalaxyBackground({ primaryColor, secondaryColor }: GalaxyBackgro
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
133
frontend/components/ui/PullToRefresh.tsx
Normal file
133
frontend/components/ui/PullToRefresh.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, ReactNode, TouchEvent } from "react";
|
||||
import { GradientSpinner } from "./GradientSpinner";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface PullToRefreshProps {
|
||||
children: ReactNode;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function PullToRefresh({
|
||||
children,
|
||||
threshold = 80,
|
||||
}: PullToRefreshProps) {
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const startY = useRef(0);
|
||||
const isPulling = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTouchStart = useCallback((e: TouchEvent) => {
|
||||
// Only allow pull-to-refresh when scrolled to the top
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Check if the main element inside is scrolled to top
|
||||
const mainElement = container.querySelector("main");
|
||||
if (mainElement && mainElement.scrollTop === 0) {
|
||||
startY.current = e.touches[0].clientY;
|
||||
isPulling.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
if (!isPulling.current || isRefreshing) return;
|
||||
|
||||
const currentY = e.touches[0].clientY;
|
||||
const distance = currentY - startY.current;
|
||||
|
||||
// Only track downward pulls
|
||||
if (distance > 0) {
|
||||
// Apply resistance factor for smoother feel
|
||||
const resistance = 0.5;
|
||||
const adjustedDistance = distance * resistance;
|
||||
|
||||
setPullDistance(adjustedDistance);
|
||||
|
||||
// Prevent default scroll behavior when pulling
|
||||
if (adjustedDistance > 10) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[isRefreshing]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (!isPulling.current) return;
|
||||
|
||||
isPulling.current = false;
|
||||
|
||||
// Check if we've pulled past the threshold
|
||||
if (pullDistance >= threshold) {
|
||||
setIsRefreshing(true);
|
||||
setPullDistance(threshold); // Lock at threshold during refresh
|
||||
|
||||
// Trigger full page reload after a brief delay for visual feedback
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
} else {
|
||||
// Reset if not past threshold
|
||||
setPullDistance(0);
|
||||
}
|
||||
}, [pullDistance, threshold]);
|
||||
|
||||
// Calculate visual properties based on pull progress
|
||||
const pullProgress = Math.min(pullDistance / threshold, 1);
|
||||
const showIndicator = pullDistance > 0;
|
||||
const shouldRelease = pullDistance >= threshold;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className="relative h-full"
|
||||
style={{ touchAction: "pan-y" }}
|
||||
>
|
||||
{/* Pull-to-refresh indicator */}
|
||||
{showIndicator && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-50 flex flex-col items-center justify-center pointer-events-none"
|
||||
style={{
|
||||
transform: `translateY(${Math.min(pullDistance, threshold + 20)}px)`,
|
||||
opacity: pullProgress,
|
||||
transition: isPulling.current
|
||||
? "none"
|
||||
: "transform 0.3s ease-out, opacity 0.3s ease-out",
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
<div className="bg-black/80 backdrop-blur-sm rounded-full p-3 shadow-lg border border-white/10">
|
||||
{isRefreshing ? (
|
||||
<GradientSpinner size="sm" />
|
||||
) : (
|
||||
<RefreshCw
|
||||
className={`w-5 h-5 text-white transition-transform ${
|
||||
shouldRelease ? "rotate-180" : ""
|
||||
}`}
|
||||
style={{
|
||||
transform: `rotate(${pullDistance * 2}deg)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white/80 text-xs mt-2 font-medium">
|
||||
{isRefreshing
|
||||
? "Refreshing..."
|
||||
: shouldRelease
|
||||
? "Release to refresh"
|
||||
: "Pull to refresh"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user