193 lines
5.4 KiB
TypeScript
193 lines
5.4 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
|
|
export interface WatchSegment {
|
|
startSec: number;
|
|
endSec: number;
|
|
watchedAt: string;
|
|
}
|
|
|
|
interface SegmentedProgressBarProps {
|
|
segments: WatchSegment[];
|
|
duration: number;
|
|
percent: number;
|
|
className?: string;
|
|
height?: 'sm' | 'md' | 'lg';
|
|
showTooltip?: boolean;
|
|
interactive?: boolean;
|
|
onSegmentClick?: (segment: WatchSegment, position: number) => void;
|
|
}
|
|
|
|
const heightPixels = {
|
|
sm: '4px',
|
|
md: '8px',
|
|
lg: '12px',
|
|
};
|
|
|
|
export function SegmentedProgressBar({
|
|
segments,
|
|
duration,
|
|
percent,
|
|
className = '',
|
|
height = 'md',
|
|
showTooltip = true,
|
|
interactive = false,
|
|
onSegmentClick,
|
|
}: SegmentedProgressBarProps) {
|
|
const [tooltipPos, setTooltipPos] = React.useState<{ x: number; time: string } | null>(null);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// Normalize segments: merge overlapping ranges
|
|
const normalizedSegments = React.useMemo(() => {
|
|
if (segments.length === 0) return [];
|
|
|
|
const sorted = [...segments].sort((a, b) => a.startSec - b.startSec);
|
|
const merged: WatchSegment[] = [];
|
|
|
|
for (const seg of sorted) {
|
|
if (merged.length === 0) {
|
|
merged.push({ ...seg });
|
|
} else {
|
|
const last = merged[merged.length - 1];
|
|
// Check for overlap or adjacency (within 0.5s)
|
|
if (seg.startSec <= last.endSec + 0.5) {
|
|
// Merge
|
|
last.endSec = Math.max(last.endSec, seg.endSec);
|
|
} else {
|
|
// Gap, add new segment
|
|
merged.push({ ...seg });
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}, [segments]);
|
|
|
|
const handleMouseMove = React.useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!showTooltip || !containerRef.current) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
|
const time = Math.round(percentage * duration);
|
|
|
|
setTooltipPos({
|
|
x,
|
|
time: formatTime(time),
|
|
});
|
|
},
|
|
[duration, showTooltip]
|
|
);
|
|
|
|
const handleMouseLeave = () => {
|
|
setTooltipPos(null);
|
|
};
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!interactive || !onSegmentClick || !containerRef.current) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
|
const position = Math.round(percentage * duration);
|
|
|
|
// Find which segment was clicked
|
|
for (const segment of normalizedSegments) {
|
|
if (position >= segment.startSec && position <= segment.endSec) {
|
|
onSegmentClick(segment, position);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
const barHeight = heightPixels[height];
|
|
|
|
return (
|
|
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '4px' }} className={className}>
|
|
{/* Main progress bar container */}
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
height: barHeight,
|
|
backgroundColor: '#d1d5db',
|
|
borderRadius: '9999px',
|
|
overflow: 'hidden',
|
|
cursor: interactive ? 'pointer' : 'default',
|
|
}}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={handleClick}
|
|
>
|
|
{/* Watched segments */}
|
|
{normalizedSegments.map((segment, idx) => {
|
|
const startPercent = (segment.startSec / duration) * 100;
|
|
const endPercent = (segment.endSec / duration) * 100;
|
|
const width = endPercent - startPercent;
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
height: '100%',
|
|
backgroundColor: '#ff6900',
|
|
left: `${Math.max(0, startPercent)}%`,
|
|
width: `${Math.max(0, Math.min(100, width))}%`,
|
|
transition: 'background-color 200ms',
|
|
}}
|
|
title={`${formatTime(segment.startSec)} - ${formatTime(segment.endSec)}`}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = '#2563eb';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = '#3b82f6';
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Current progress line */}
|
|
{percent > 0 && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
width: '2px',
|
|
backgroundColor: '#ef4444',
|
|
opacity: 0.8,
|
|
pointerEvents: 'none',
|
|
zIndex: 10,
|
|
left: `${Math.max(0, Math.min(100, percent))}%`,
|
|
transform: 'translateX(-50%)',
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tooltip text - only show if showTooltip is true */}
|
|
{tooltipPos && showTooltip && (
|
|
<div style={{ fontSize: '12px', color: '#6b7280', whiteSpace: 'nowrap' }}>
|
|
{tooltipPos.time} / {formatTime(duration)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
|
|
if (h > 0) {
|
|
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
}
|