Initial commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
'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')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user