277 lines
9.1 KiB
TypeScript
277 lines
9.1 KiB
TypeScript
"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",
|
|
!isDragging && "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>
|
|
);
|
|
}
|