Files
spotizerr-dev/spotizerr-ui/src/components/Queue.tsx
2025-08-23 23:00:11 -06:00

973 lines
39 KiB
TypeScript

import { useContext, useState, useRef, useEffect } from "react";
import { FaTimes, FaSync, FaCheckCircle, FaExclamationCircle, FaHourglassHalf, FaMusic, FaCompactDisc, FaStepForward } from "react-icons/fa";
import { QueueContext, type QueueItem, getStatus, getProgress, getCurrentTrackInfo, isActiveStatus, isTerminalStatus } from "@/contexts/queue-context";
import { useAuth } from "@/contexts/auth-context";
// Circular Progress Component
const CircularProgress = ({
progress,
isCompleted = false,
size = 60,
strokeWidth = 6
}: {
progress: number;
isCompleted?: boolean;
size?: number;
strokeWidth?: number;
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<div className="relative inline-flex">
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-border dark:text-border-dark opacity-60"
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={isCompleted ? 0 : strokeDashoffset}
className={`transition-all duration-500 ease-out ${
isCompleted ? "text-success" : "text-info"
}`}
/>
</svg>
{/* Center content */}
<div className="absolute inset-0 flex items-center justify-center">
{isCompleted ? (
<FaCheckCircle className="text-success text-lg" />
) : (
<span className="text-xs font-semibold text-content-primary dark:text-content-primary-dark">
{Math.round(progress)}%
</span>
)}
</div>
</div>
);
};
// Status styling configuration
const statusStyles = {
initializing: {
icon: <FaSync className="animate-spin icon-accent" />,
color: "text-info",
bgColor: "bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/30",
borderColor: "border-info/30 dark:border-info/40",
name: "Initializing",
},
processing: {
icon: <FaSync className="animate-spin icon-warning" />,
color: "text-processing",
bgColor: "bg-gradient-to-r from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/30",
borderColor: "border-processing/30 dark:border-processing/40",
name: "Processing",
},
downloading: {
icon: <FaSync className="animate-spin icon-accent" />,
color: "text-info",
bgColor: "bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/30",
borderColor: "border-info/30 dark:border-info/40",
name: "Downloading",
},
"real-time": {
icon: <FaSync className="animate-spin icon-accent" />,
color: "text-info",
bgColor: "bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/30",
borderColor: "border-info/30 dark:border-info/40",
name: "Downloading",
},
done: {
icon: <FaCheckCircle className="icon-success" />,
color: "text-success",
bgColor: "bg-gradient-to-r from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/30",
borderColor: "border-success/30 dark:border-success/40",
name: "Done",
},
completed: {
icon: <FaCheckCircle className="icon-success" />,
color: "text-success",
bgColor: "bg-gradient-to-r from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/30",
borderColor: "border-success/30 dark:border-success/40",
name: "Completed",
},
error: {
icon: <FaExclamationCircle className="icon-error" />,
color: "text-error",
bgColor: "bg-gradient-to-r from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/30",
borderColor: "border-error/30 dark:border-error/40",
name: "Error",
},
cancelled: {
icon: <FaTimes className="icon-warning" />,
color: "text-warning",
bgColor: "bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/30",
borderColor: "border-warning/30 dark:border-warning/40",
name: "Cancelled",
},
skipped: {
icon: <FaStepForward className="icon-warning" />,
color: "text-warning",
bgColor: "bg-gradient-to-r from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/30",
borderColor: "border-warning/30 dark:border-warning/40",
name: "Skipped",
},
queued: {
icon: <FaHourglassHalf className="icon-muted" />,
color: "text-content-muted dark:text-content-muted-dark",
bgColor: "bg-gradient-to-r from-surface-muted to-surface-accent dark:from-surface-muted-dark dark:to-surface-accent-dark",
borderColor: "border-border dark:border-border-dark",
name: "Queued",
},
retrying: {
icon: <FaSync className="animate-spin icon-warning" />,
color: "text-warning",
bgColor: "bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/30",
borderColor: "border-warning/30 dark:border-warning/40",
name: "Retrying",
},
} as const;
// Skipped Task Component
const SkippedTaskCard = ({ item }: { item: QueueItem }) => {
const { removeItem } = useContext(QueueContext) || {};
const trackInfo = getCurrentTrackInfo(item);
const TypeIcon = item.downloadType === "album" ? FaCompactDisc : FaMusic;
return (
<div className="p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl md:hover:scale-[1.02] bg-gradient-to-r from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/30 border-warning/30 dark:border-warning/40">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
{/* Main content */}
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<div className="text-2xl text-warning bg-white/80 dark:bg-surface-dark/80 p-2 rounded-full shadow-sm flex-shrink-0">
<FaStepForward className="icon-warning" />
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2">
<TypeIcon className="icon-muted text-sm flex-shrink-0" />
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
{item.name}
</p>
</div>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
{item.artist}
</p>
{/* Show current track info for parent downloads */}
{(item.downloadType === "album" || item.downloadType === "playlist") && trackInfo.title && (
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={trackInfo.title}>
{trackInfo.current}/{trackInfo.total}: {trackInfo.title}
</p>
)}
</div>
</div>
{/* Status and actions */}
<div className="flex items-center justify-between md:justify-end gap-3 md:gap-3 md:ml-4">
<div className="flex-1 md:flex-none md:text-right">
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm md:text-xs font-semibold text-warning bg-white/60 dark:bg-surface-dark/60 shadow-sm">
Skipped
</div>
</div>
{/* Remove button */}
<div className="flex gap-2 md:gap-1 flex-shrink-0">
<button
onClick={() => removeItem?.(item.id)}
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-error hover:bg-error/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Remove"
>
<FaTimes className="text-base md:text-sm" />
</button>
</div>
</div>
</div>
{/* Skip reason */}
{item.error && (
<div className="mt-3 p-3 md:p-2 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm md:text-xs text-warning font-medium break-words">
Skipped: {item.error}
</p>
</div>
)}
</div>
);
};
// Cancelled Task Component
const CancelledTaskCard = ({ item }: { item: QueueItem }) => {
const { removeItem } = useContext(QueueContext) || {};
const trackInfo = getCurrentTrackInfo(item);
const TypeIcon = item.downloadType === "album" ? FaCompactDisc : FaMusic;
return (
<div className="p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl md:hover:scale-[1.02] bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/30 border-warning/30 dark:border-warning/40">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
{/* Main content */}
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<div className="text-2xl text-warning bg-white/80 dark:bg-surface-dark/80 p-2 rounded-full shadow-sm flex-shrink-0">
<FaTimes className="icon-warning" />
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2">
<TypeIcon className="icon-muted text-sm flex-shrink-0" />
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
{item.name}
</p>
</div>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
{item.artist}
</p>
{/* Show current track info for parent downloads */}
{(item.downloadType === "album" || item.downloadType === "playlist") && trackInfo.title && (
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={trackInfo.title}>
{trackInfo.current}/{trackInfo.total}: {trackInfo.title}
</p>
)}
</div>
</div>
{/* Status and actions */}
<div className="flex items-center justify-between md:justify-end gap-3 md:gap-3 md:ml-4">
<div className="flex-1 md:flex-none md:text-right">
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm md:text-xs font-semibold text-warning bg-white/60 dark:bg-surface-dark/60 shadow-sm">
Cancelled
</div>
</div>
{/* Remove button */}
<div className="flex gap-2 md:gap-1 flex-shrink-0">
<button
onClick={() => removeItem?.(item.id)}
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-error hover:bg-error/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Remove"
>
<FaTimes className="text-base md:text-sm" />
</button>
</div>
</div>
</div>
{/* Cancellation reason */}
{item.error && (
<div className="mt-3 p-3 md:p-2 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm md:text-xs text-warning font-medium break-words">
Cancelled: {item.error}
</p>
</div>
)}
</div>
);
};
const QueueItemCard = ({ item, cachedStatus }: { item: QueueItem, cachedStatus: string }) => {
const { removeItem, cancelItem } = useContext(QueueContext) || {};
const status = cachedStatus;
const progress = getProgress(item);
const trackInfo = getCurrentTrackInfo(item);
const styleInfo = statusStyles[status as keyof typeof statusStyles] || statusStyles.queued;
const isTerminal = isTerminalStatus(status);
const isActive = isActiveStatus(status);
// Get type icon
const TypeIcon = item.downloadType === "album" ? FaCompactDisc : FaMusic;
return (
<div className={`p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl md:hover:scale-[1.02] ${styleInfo.bgColor} ${styleInfo.borderColor}`}>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
{/* Main content */}
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<div className={`text-2xl ${styleInfo.color} bg-white/80 dark:bg-surface-dark/80 p-2 rounded-full shadow-sm flex-shrink-0`}>
{styleInfo.icon}
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2">
<TypeIcon className="icon-muted text-sm flex-shrink-0" />
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
{item.name}
</p>
</div>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
{item.artist}
</p>
{/* Show current track info for parent downloads */}
{(item.downloadType === "album" || item.downloadType === "playlist") && trackInfo.title && (
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={trackInfo.title}>
{trackInfo.current}/{trackInfo.total}: {trackInfo.title}
</p>
)}
</div>
</div>
{/* Status and progress */}
<div className="flex items-center justify-between md:justify-end gap-3 md:gap-3 md:ml-4">
<div className="flex-1 md:flex-none md:text-right">
<div className={`inline-flex items-center px-3 py-1 rounded-full text-sm md:text-xs font-semibold ${styleInfo.color} bg-white/60 dark:bg-surface-dark/60 shadow-sm`}>
{styleInfo.name}
</div>
{/* Summary info for completed downloads */}
{isTerminal && item.summary && item.downloadType !== "track" && (
<p className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark mt-1">
{item.summary.total_successful}/{trackInfo.total || item.summary.total_successful + item.summary.total_failed + item.summary.total_skipped} tracks
</p>
)}
</div>
{/* Circular progress for active downloads */}
{isActive && progress !== undefined && (
<div className="flex-shrink-0">
<CircularProgress
progress={progress}
isCompleted={false}
size={44}
strokeWidth={4}
/>
</div>
)}
{/* Completed progress for finished downloads */}
{isTerminal && status === "done" && item.downloadType === "track" && (
<div className="flex-shrink-0">
<CircularProgress
progress={100}
isCompleted={true}
size={44}
strokeWidth={4}
/>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2 md:gap-1 flex-shrink-0">
{isTerminal ? (
<button
onClick={() => removeItem?.(item.id)}
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-error hover:bg-error/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Remove"
>
<FaTimes className="text-base md:text-sm" />
</button>
) : (
<button
onClick={() => cancelItem?.(item.id)}
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-warning hover:bg-warning/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Cancel"
>
<FaTimes className="text-base md:text-sm" />
</button>
)}
</div>
</div>
</div>
{/* Error message */}
{item.error && (
<div className={`mt-3 p-3 md:p-2 rounded-lg ${
status === "cancelled"
? "bg-warning/10 border border-warning/20"
: status === "skipped"
? "bg-warning/10 border border-warning/20"
: "bg-error/10 border border-error/20"
}`}>
<p className={`text-sm md:text-xs font-medium break-words ${
status === "cancelled"
? "text-warning"
: status === "skipped"
? "text-warning"
: "text-error"
}`}>
{status === "cancelled" ? "Cancelled: " : status === "skipped" ? "Skipped: " : "Error: "}
{item.error}
</p>
</div>
)}
{/* Summary for completed downloads with multiple tracks */}
{isTerminal && item.summary && item.downloadType !== "track" && (
<div className="mt-3 p-3 md:p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg border border-border/20 dark:border-border-dark/20">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm md:text-xs font-semibold text-content-primary dark:text-content-primary-dark">
Download Summary
</h4>
<span className="text-xs text-content-muted dark:text-content-muted-dark">
{item.summary.total_successful + item.summary.total_failed + item.summary.total_skipped} tracks total
</span>
</div>
<div className="flex flex-wrap gap-3 md:gap-4 text-sm md:text-xs">
{item.summary.total_successful > 0 && (
<span className="flex items-center gap-2 md:gap-1">
<div className="w-3 h-3 md:w-2 md:h-2 bg-success rounded-full flex-shrink-0"></div>
<span className="text-success font-medium">{item.summary.total_successful} successful</span>
</span>
)}
{item.summary.total_failed > 0 && (
<span className="flex items-center gap-2 md:gap-1">
<div className="w-3 h-3 md:w-2 md:h-2 bg-error rounded-full flex-shrink-0"></div>
<span className="text-error font-medium">{item.summary.total_failed} failed</span>
</span>
)}
{item.summary.total_skipped > 0 && (
<span className="flex items-center gap-2 md:gap-1">
<div className="w-3 h-3 md:w-2 md:h-2 bg-warning rounded-full flex-shrink-0"></div>
<span className="text-warning font-medium">{item.summary.total_skipped} skipped</span>
</span>
)}
</div>
</div>
)}
</div>
);
};
export const Queue = () => {
const context = useContext(QueueContext);
const { authEnabled, isAuthenticated } = useAuth();
// Check if user is authenticated (only relevant when auth is enabled)
const isUserAuthenticated = !authEnabled || isAuthenticated;
const [startY, setStartY] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragDistance, setDragDistance] = useState(0);
const queueRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [canDrag, setCanDrag] = useState(false);
// Virtual scrolling state
const [visibleItemCount, setVisibleItemCount] = useState(7);
const [isLoadingMoreItems, setIsLoadingMoreItems] = useState(false);
// Track items that recently transitioned to terminal states
const [recentlyTerminated, setRecentlyTerminated] = useState<Map<string, number>>(new Map());
const previousStatusRef = useRef<Map<string, string>>(new Map());
const INITIAL_ITEM_COUNT = 7;
const LOAD_MORE_THRESHOLD = 0.8; // Load more when 80% scrolled through visible items
const TERMINAL_STATE_DISPLAY_DURATION = 3000; // 3 seconds
const {
items = [],
isVisible = false,
toggleVisibility = () => {},
cancelAll = () => {},
clearCompleted = () => {},
hasMore = false,
isLoadingMore = false,
loadMoreTasks = () => {},
totalTasks = 0
} = context || {};
// Track status changes and identify transitions to terminal states
useEffect(() => {
if (!items || items.length === 0) return;
const currentStatuses = new Map<string, string>();
const newlyTerminated = new Map<string, number>();
const currentTime = Date.now();
// Check each item for status changes
items.forEach(item => {
const currentStatus = getStatus(item);
const previousStatus = previousStatusRef.current.get(item.id);
currentStatuses.set(item.id, currentStatus);
// If item transitioned from non-terminal to terminal state
if (previousStatus &&
!isTerminalStatus(previousStatus) &&
isTerminalStatus(currentStatus)) {
newlyTerminated.set(item.id, currentTime);
}
});
// Update previous statuses
previousStatusRef.current = currentStatuses;
// Add newly terminated items to tracking
if (newlyTerminated.size > 0) {
setRecentlyTerminated(prev => {
const updated = new Map(prev);
newlyTerminated.forEach((timestamp, itemId) => {
updated.set(itemId, timestamp);
});
return updated;
});
// Set up cleanup timers for newly terminated items
newlyTerminated.forEach((timestamp, itemId) => {
setTimeout(() => {
setRecentlyTerminated(prev => {
const updated = new Map(prev);
// Only remove if the timestamp matches (prevents removing newer entries)
if (updated.get(itemId) === timestamp) {
updated.delete(itemId);
}
return updated;
});
}, TERMINAL_STATE_DISPLAY_DURATION);
});
}
}, [items]);
// Cleanup recently terminated items when items are removed from the queue
useEffect(() => {
if (!items || items.length === 0) {
setRecentlyTerminated(new Map());
previousStatusRef.current = new Map();
return;
}
// Remove tracking for items that are no longer in the queue
const currentItemIds = new Set(items.map(item => item.id));
setRecentlyTerminated(prev => {
const updated = new Map();
prev.forEach((timestamp, itemId) => {
if (currentItemIds.has(itemId)) {
updated.set(itemId, timestamp);
}
});
return updated;
});
// Clean up previous status tracking for removed items
const newPreviousStatuses = new Map();
previousStatusRef.current.forEach((status, itemId) => {
if (currentItemIds.has(itemId)) {
newPreviousStatuses.set(itemId, status);
}
});
previousStatusRef.current = newPreviousStatuses;
}, [items?.length]); // Trigger when items array length changes
// Infinite scroll and virtual scrolling
useEffect(() => {
if (!isVisible) return;
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Virtual scrolling - load more visible items if user has scrolled through most visible items
if (scrollPercentage > LOAD_MORE_THRESHOLD && !isLoadingMoreItems) {
const totalAvailableItems = items.length;
if (visibleItemCount < totalAvailableItems) {
setIsLoadingMoreItems(true);
// Gradually increase visible items (add 5 more each time)
setTimeout(() => {
setVisibleItemCount(prev => Math.min(prev + 5, totalAvailableItems));
setIsLoadingMoreItems(false);
}, 100); // Small delay for smooth UX
}
}
// Server-side pagination - only trigger when we've shown most of our items
if (scrollPercentage > 0.9 && hasMore && !isLoadingMore && visibleItemCount >= items.length * 0.8) {
loadMoreTasks();
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [isVisible, hasMore, isLoadingMore, loadMoreTasks, visibleItemCount, items.length, isLoadingMoreItems]);
// Reset visible item count when items change significantly (new downloads, etc.)
useEffect(() => {
// If we have fewer items than currently visible, adjust down
if (items.length < visibleItemCount) {
setVisibleItemCount(Math.max(INITIAL_ITEM_COUNT, items.length));
}
}, [items.length, visibleItemCount]);
// Reset visible item count when queue visibility changes
useEffect(() => {
if (isVisible) {
// Reset to initial count when queue opens for optimal performance
setVisibleItemCount(INITIAL_ITEM_COUNT);
}
}, [isVisible]);
// Mobile drag-to-dismiss
const handleTouchStart = (e: React.TouchEvent) => {
const touch = e.touches[0];
const scrollContainer = scrollContainerRef.current;
const headerElement = headerRef.current;
const touchedHeader = headerElement?.contains(e.target as Node);
const scrollAtTop = scrollContainer ? scrollContainer.scrollTop <= 10 : true;
// Allow dragging from header or anywhere when scrolled to top
if (touchedHeader || scrollAtTop) {
setCanDrag(true);
setStartY(touch.clientY);
setIsDragging(false);
setDragDistance(0);
// Prevent event from bubbling to backdrop
e.stopPropagation();
} else {
setCanDrag(false);
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!canDrag || startY === null) return;
const touch = e.touches[0];
const deltaY = touch.clientY - startY;
if (deltaY > 0) {
// Start dragging with a smaller threshold for better responsiveness
if (!isDragging && deltaY > 5) {
setIsDragging(true);
e.preventDefault();
e.stopPropagation();
}
if (isDragging) {
e.preventDefault();
e.stopPropagation();
const clampedDelta = Math.min(deltaY, 250);
setDragDistance(clampedDelta);
if (queueRef.current) {
const resistance = Math.pow(clampedDelta / 250, 0.6);
const transformY = clampedDelta * resistance;
const opacity = Math.max(0.2, 1 - (clampedDelta / 400));
queueRef.current.style.transform = `translateY(${transformY}px)`;
queueRef.current.style.opacity = `${opacity}`;
queueRef.current.style.transition = 'none';
}
}
}
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (!canDrag || startY === null) {
resetDragState();
return;
}
// Prevent event from bubbling to backdrop
e.stopPropagation();
if (queueRef.current) {
queueRef.current.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
// Lower threshold and ensure both conditions are met
if (isDragging && dragDistance > 60) {
queueRef.current.style.transform = 'translateY(100%)';
queueRef.current.style.opacity = '0';
setTimeout(() => {
toggleVisibility();
if (queueRef.current) {
queueRef.current.style.transform = '';
queueRef.current.style.opacity = '';
queueRef.current.style.transition = '';
}
resetDragState();
}, 300);
} else {
queueRef.current.style.transform = '';
queueRef.current.style.opacity = '';
setTimeout(() => {
if (queueRef.current) {
queueRef.current.style.transition = '';
}
resetDragState();
}, 300);
}
} else {
resetDragState();
}
};
const resetDragState = () => {
setStartY(null);
setIsDragging(false);
setDragDistance(0);
setCanDrag(false);
// Ensure queue element is reset
if (queueRef.current) {
queueRef.current.style.transform = '';
queueRef.current.style.opacity = '';
queueRef.current.style.transition = '';
}
};
// Prevent body scroll on mobile
useEffect(() => {
if (!isVisible) return;
const isMobile = window.innerWidth < 768;
if (!isMobile) return;
const originalOverflow = document.body.style.overflow;
const originalTouchAction = document.body.style.touchAction;
document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.touchAction = originalTouchAction;
};
}, [isVisible]);
if (!context || !isVisible || !isUserAuthenticated) return null;
// Optimize: Calculate status once per item and reuse throughout render
const itemsWithStatus = items.map(item => ({
...item,
_cachedStatus: getStatus(item)
}));
const hasActive = itemsWithStatus.some(item => isActiveStatus(item._cachedStatus));
const hasFinished = itemsWithStatus.some(item => isTerminalStatus(item._cachedStatus));
// Sort items by priority using cached status
const sortedItems = [...itemsWithStatus].sort((a, b) => {
const statusA = a._cachedStatus;
const statusB = b._cachedStatus;
const getPriority = (status: string) => {
const priorities = {
"real-time": 1, downloading: 2, processing: 3, initializing: 4,
retrying: 5, queued: 6, done: 7, completed: 7, error: 8, cancelled: 9, skipped: 10
} as Record<string, number>;
return priorities[status as keyof typeof priorities] || 10;
};
return getPriority(statusA) - getPriority(statusB);
});
// Helper function to determine if an item should be visible
const shouldShowItem = (item: QueueItem) => {
const status = getStatus(item);
// Always show non-terminal items
if (!isTerminalStatus(status)) {
return true;
}
// Show items that recently transitioned to terminal states (within 3 seconds)
// This includes done, error, cancelled, and skipped states
if (recentlyTerminated.has(item.id)) {
return true;
}
// Show items with recent callbacks (items that were already terminal when first seen)
return (item.lastCallback && 'timestamp' in item.lastCallback);
};
return (
<>
{/* Mobile backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={!isDragging ? toggleVisibility : undefined}
onTouchStart={(e) => {
// Only prevent default if not touching the queue
if (!queueRef.current?.contains(e.target as Node)) {
e.preventDefault();
}
}}
onTouchMove={(e) => {
// Only prevent default if not dragging the queue
if (!isDragging) {
e.preventDefault();
}
}}
onTouchEnd={(e) => {
// Only prevent default and close if not dragging the queue
if (!isDragging && !queueRef.current?.contains(e.target as Node)) {
e.preventDefault();
toggleVisibility();
}
}}
style={{ touchAction: isDragging ? 'none' : 'auto' }}
/>
<div
ref={queueRef}
className="fixed inset-x-0 bottom-0 md:bottom-4 md:right-4 md:inset-x-auto w-full md:max-w-md bg-surface dark:bg-surface-dark md:rounded-xl shadow-2xl border-t md:border border-border dark:border-border-dark z-50 backdrop-blur-sm"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
touchAction: isDragging ? 'none' : 'auto',
willChange: isDragging ? 'transform, opacity' : 'auto',
isolation: 'isolate',
}}
>
<header
ref={headerRef}
className="flex items-center justify-between p-4 border-b border-border dark:border-border-dark bg-gradient-to-r from-surface to-surface-secondary dark:from-surface-dark dark:to-surface-secondary-dark md:rounded-t-xl"
>
{/* Enhanced drag indicator for mobile */}
<div className={`md:hidden absolute top-2 left-1/2 transform -translate-x-1/2 rounded-full transition-all duration-200 ${
isDragging
? dragDistance > 60
? "w-16 h-1.5 bg-success animate-pulse"
: "w-14 h-1 bg-warning"
: "w-12 h-1 bg-content-muted dark:bg-content-muted-dark opacity-60 animate-pulse"
}`} />
<h2 className="text-lg font-bold text-content-primary dark:text-content-primary-dark">
Download Queue ({totalTasks})
{items.length > INITIAL_ITEM_COUNT && (
<span className="text-sm font-normal text-content-muted dark:text-content-muted-dark ml-2">
Showing {Math.min(visibleItemCount, items.filter(shouldShowItem).length)} of {items.filter(shouldShowItem).length}
</span>
)}
</h2>
<div className="flex gap-1 md:gap-2">
<button
onClick={cancelAll}
className="text-xs md:text-sm text-content-muted dark:text-content-muted-dark hover:text-warning transition-colors px-3 py-2 md:px-2 md:py-1 rounded-md hover:bg-warning/10 min-h-[44px] md:min-h-auto"
disabled={!hasActive}
>
Cancel All
</button>
<button
onClick={clearCompleted}
className="text-xs md:text-sm text-content-muted dark:text-content-muted-dark hover:text-success transition-colors px-3 py-2 md:px-2 md:py-1 rounded-md hover:bg-success/10 min-h-[44px] md:min-h-auto"
disabled={!hasFinished}
>
Clear Finished
</button>
<button
onClick={toggleVisibility}
className="text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark p-3 md:p-1 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors min-h-[44px] md:min-h-auto flex items-center justify-center"
>
<FaTimes className="text-base md:text-sm" />
</button>
</div>
</header>
<div
ref={scrollContainerRef}
className="p-4 overflow-y-auto max-h-[60vh] md:max-h-96 bg-gradient-to-b from-surface-secondary/30 to-surface/30 dark:from-surface-secondary-dark/30 dark:to-surface-dark/30"
style={{ touchAction: isDragging ? 'none' : 'pan-y' }}
>
{(() => {
const visibleItems = sortedItems.filter(shouldShowItem);
// Apply virtual scrolling - only show limited number of items
const itemsToRender = visibleItems.slice(0, visibleItemCount);
const hasMoreVisibleItems = visibleItems.length > visibleItemCount;
return visibleItems.length === 0 ? (
<div className="text-center py-8">
<div className="w-20 h-20 md:w-16 md:h-16 mx-auto mb-4 rounded-full bg-surface-muted dark:bg-surface-muted-dark flex items-center justify-center">
<FaMusic className="text-3xl md:text-2xl icon-muted" />
</div>
<p className="text-base md:text-sm text-content-muted dark:text-content-muted-dark">The queue is empty.</p>
<p className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark mt-1">Downloads will appear here</p>
</div>
) : (
<>
{/* Render visible items */}
{itemsToRender.map(item => {
if (item._cachedStatus === "cancelled") {
return <CancelledTaskCard key={item.id} item={item} />;
}
if (item._cachedStatus === "skipped") {
return <SkippedTaskCard key={item.id} item={item} />;
}
return <QueueItemCard key={item.id} item={item} cachedStatus={item._cachedStatus} />;
})}
{/* Virtual scrolling loading indicator */}
{(isLoadingMoreItems || hasMoreVisibleItems) && (
<div className="flex justify-center mt-4 py-2">
<div className="flex items-center gap-2 text-content-muted dark:text-content-muted-dark text-sm">
{isLoadingMoreItems ? (
<>
<div className="w-4 h-4 border-2 border-content-muted dark:border-content-muted-dark border-t-transparent rounded-full animate-spin" />
Loading more items...
</>
) : hasMoreVisibleItems ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Scroll to see {visibleItems.length - visibleItemCount} more items
</>
) : null}
</div>
</div>
)}
{/* Server-side loading indicator */}
{isLoadingMore && (
<div className="flex justify-center mt-4 py-4">
<div className="flex items-center gap-2 text-content-muted dark:text-content-muted-dark">
<div className="w-4 h-4 border-2 border-content-muted dark:border-content-muted-dark border-t-transparent rounded-full animate-spin" />
Loading more tasks...
</div>
</div>
)}
{/* Server-side load more button */}
{hasMore && !isLoadingMore && visibleItemCount >= items.length * 0.8 && (
<div className="flex justify-center mt-4">
<button
onClick={loadMoreTasks}
className="px-3 py-1 text-xs bg-surface-muted dark:bg-surface-muted-dark text-content-secondary dark:text-content-secondary-dark rounded border border-border dark:border-border-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Load More
</button>
</div>
)}
</>
);
})()}
</div>
</div>
</>
);
};