Skip to content

Commit cf7b15d

Browse files
feat: add thumbnail strip for video frame navigation
Squash merged after review. Build/lint/typecheck all pass. Adds a useful thumbnail strip for video scrubbing.
1 parent 3a09798 commit cf7b15d

4 files changed

Lines changed: 448 additions & 23 deletions

File tree

src/components/ThumbnailStrip.tsx

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState, useCallback } from "react";
4+
5+
interface Thumbnail {
6+
time: number;
7+
dataUrl: string;
8+
}
9+
10+
interface ThumbnailStripProps {
11+
videoSrc: string | null;
12+
duration: number;
13+
currentTime: number;
14+
trimStart?: number;
15+
trimEnd?: number;
16+
onSeek: (time: number) => void;
17+
intervalSeconds?: number;
18+
}
19+
20+
export default function ThumbnailStrip({
21+
videoSrc,
22+
duration,
23+
currentTime,
24+
trimStart = 0,
25+
trimEnd,
26+
onSeek,
27+
intervalSeconds = 5,
28+
}: ThumbnailStripProps) {
29+
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
30+
const [isGenerating, setIsGenerating] = useState(false);
31+
const [progress, setProgress] = useState(0);
32+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
33+
const stripRef = useRef<HTMLDivElement>(null);
34+
const offscreenVideoRef = useRef<HTMLVideoElement | null>(null);
35+
const abortRef = useRef(false);
36+
37+
const effectiveTrimEnd = trimEnd ?? duration;
38+
39+
const generateThumbnails = useCallback(async () => {
40+
if (!videoSrc || duration <= 0) return;
41+
42+
abortRef.current = false;
43+
setIsGenerating(true);
44+
setThumbnails([]);
45+
setProgress(0);
46+
47+
const video = document.createElement("video");
48+
video.src = videoSrc;
49+
video.crossOrigin = "anonymous";
50+
video.muted = true;
51+
video.preload = "auto";
52+
offscreenVideoRef.current = video;
53+
54+
await new Promise<void>((resolve, reject) => {
55+
video.onloadedmetadata = () => resolve();
56+
video.onerror = () => reject(new Error("Video load failed"));
57+
video.load();
58+
});
59+
60+
const canvas = document.createElement("canvas");
61+
const ctx = canvas.getContext("2d");
62+
if (!ctx) return;
63+
64+
const thumbW = 160;
65+
const thumbH = 90;
66+
canvas.width = thumbW;
67+
canvas.height = thumbH;
68+
69+
const times: number[] = [];
70+
for (let t = 0; t <= duration; t += intervalSeconds) {
71+
times.push(Math.min(t, duration - 0.1));
72+
}
73+
if (times[times.length - 1] < duration - 0.5) {
74+
times.push(duration - 0.1);
75+
}
76+
77+
const captured: Thumbnail[] = [];
78+
79+
for (let i = 0; i < times.length; i++) {
80+
if (abortRef.current) break;
81+
82+
const time = times[i];
83+
await new Promise<void>((resolve) => {
84+
const onSeeked = () => {
85+
video.removeEventListener("seeked", onSeeked);
86+
ctx.drawImage(video, 0, 0, thumbW, thumbH);
87+
captured.push({ time, dataUrl: canvas.toDataURL("image/jpeg", 0.7) });
88+
setThumbnails([...captured]);
89+
setProgress(Math.round(((i + 1) / times.length) * 100));
90+
resolve();
91+
};
92+
video.addEventListener("seeked", onSeeked);
93+
video.currentTime = time;
94+
});
95+
}
96+
97+
video.src = "";
98+
offscreenVideoRef.current = null;
99+
setIsGenerating(false);
100+
}, [videoSrc, duration, intervalSeconds]);
101+
102+
useEffect(() => {
103+
if (videoSrc && duration > 0) {
104+
generateThumbnails();
105+
}
106+
return () => {
107+
abortRef.current = true;
108+
};
109+
}, [generateThumbnails]);
110+
111+
const formatTime = (seconds: number) => {
112+
const m = Math.floor(seconds / 60);
113+
const s = Math.floor(seconds % 60);
114+
return `${m}:${s.toString().padStart(2, "0")}`;
115+
};
116+
117+
const activeIndex = thumbnails.findIndex(
118+
(t, i) =>
119+
currentTime >= t.time &&
120+
(i === thumbnails.length - 1 || currentTime < thumbnails[i + 1].time)
121+
);
122+
123+
if (!videoSrc) return null;
124+
125+
return (
126+
<div className="thumbnail-strip-wrapper">
127+
<div className="strip-header">
128+
<span className="strip-label">
129+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
130+
<rect x="0.5" y="0.5" width="11" height="11" rx="1.5" stroke="currentColor" />
131+
<rect x="3" y="2.5" width="1.5" height="7" rx="0.5" fill="currentColor" />
132+
<rect x="7.5" y="2.5" width="1.5" height="7" rx="0.5" fill="currentColor" />
133+
</svg>
134+
Frames
135+
</span>
136+
{isGenerating && (
137+
<span className="strip-progress">
138+
<span
139+
className="progress-bar"
140+
style={{ width: `${progress}%` }}
141+
/>
142+
<span className="progress-text">{progress}%</span>
143+
</span>
144+
)}
145+
{!isGenerating && thumbnails.length > 0 && (
146+
<span className="strip-meta">
147+
{thumbnails.length} frames · every {intervalSeconds}s
148+
</span>
149+
)}
150+
</div>
151+
152+
<div className="strip-scroll-area" ref={stripRef}>
153+
{thumbnails.length === 0 && isGenerating && (
154+
<div className="strip-skeleton">
155+
{Array.from({ length: 8 }).map((_, i) => (
156+
<div key={i} className="skeleton-thumb" style={{ animationDelay: `${i * 80}ms` }} />
157+
))}
158+
</div>
159+
)}
160+
161+
{thumbnails.length > 0 && (
162+
<div className="strip-inner">
163+
{thumbnails.map((thumb, i) => {
164+
const isActive = i === activeIndex;
165+
const inTrimRange =
166+
thumb.time >= trimStart && thumb.time <= effectiveTrimEnd;
167+
const isHovered = hoveredIndex === i;
168+
169+
return (
170+
<button
171+
key={thumb.time}
172+
className={`thumb-btn ${isActive ? "active" : ""} ${
173+
!inTrimRange ? "out-of-range" : ""
174+
} ${isHovered ? "hovered" : ""}`}
175+
onClick={() => onSeek(thumb.time)}
176+
onMouseEnter={() => setHoveredIndex(i)}
177+
onMouseLeave={() => setHoveredIndex(null)}
178+
title={`Seek to ${formatTime(thumb.time)}`}
179+
>
180+
<img
181+
src={thumb.dataUrl}
182+
alt={`Frame at ${formatTime(thumb.time)}`}
183+
draggable={false}
184+
/>
185+
<span className="thumb-time">{formatTime(thumb.time)}</span>
186+
{isActive && <span className="active-indicator" />}
187+
</button>
188+
);
189+
})}
190+
</div>
191+
)}
192+
</div>
193+
194+
<style>{`
195+
.thumbnail-strip-wrapper {
196+
width: 100%;
197+
background: #0d0d0f;
198+
border: 1px solid #1e1e24;
199+
border-radius: 10px;
200+
overflow: hidden;
201+
font-family: 'SF Mono', 'Fira Code', monospace;
202+
}
203+
204+
.strip-header {
205+
display: flex;
206+
align-items: center;
207+
gap: 12px;
208+
padding: 8px 14px;
209+
background: #111115;
210+
border-bottom: 1px solid #1e1e24;
211+
}
212+
213+
.strip-label {
214+
display: flex;
215+
align-items: center;
216+
gap: 6px;
217+
font-size: 10px;
218+
font-weight: 600;
219+
letter-spacing: 0.08em;
220+
text-transform: uppercase;
221+
color: #5a5a72;
222+
}
223+
224+
.strip-progress {
225+
position: relative;
226+
flex: 1;
227+
height: 3px;
228+
background: #1e1e24;
229+
border-radius: 2px;
230+
overflow: hidden;
231+
display: flex;
232+
align-items: center;
233+
}
234+
235+
.progress-bar {
236+
position: absolute;
237+
left: 0;
238+
top: 0;
239+
height: 100%;
240+
background: linear-gradient(90deg, #4f6ef7, #a78bfa);
241+
border-radius: 2px;
242+
transition: width 0.2s ease;
243+
}
244+
245+
.progress-text {
246+
position: absolute;
247+
right: -28px;
248+
font-size: 9px;
249+
color: #5a5a72;
250+
white-space: nowrap;
251+
}
252+
253+
.strip-meta {
254+
margin-left: auto;
255+
font-size: 10px;
256+
color: #3a3a50;
257+
}
258+
259+
.strip-scroll-area {
260+
overflow-x: auto;
261+
overflow-y: hidden;
262+
padding: 10px 10px 6px;
263+
scrollbar-width: thin;
264+
scrollbar-color: #2a2a35 transparent;
265+
}
266+
267+
.strip-scroll-area::-webkit-scrollbar {
268+
height: 4px;
269+
}
270+
271+
.strip-scroll-area::-webkit-scrollbar-track {
272+
background: transparent;
273+
}
274+
275+
.strip-scroll-area::-webkit-scrollbar-thumb {
276+
background: #2a2a35;
277+
border-radius: 2px;
278+
}
279+
280+
.strip-skeleton {
281+
display: flex;
282+
gap: 6px;
283+
}
284+
285+
.skeleton-thumb {
286+
width: 106px;
287+
height: 60px;
288+
border-radius: 6px;
289+
background: linear-gradient(90deg, #111115 25%, #1a1a22 50%, #111115 75%);
290+
background-size: 200% 100%;
291+
animation: shimmer 1.4s infinite;
292+
flex-shrink: 0;
293+
}
294+
295+
@keyframes shimmer {
296+
0% { background-position: 200% 0; }
297+
100% { background-position: -200% 0; }
298+
}
299+
300+
.strip-inner {
301+
display: flex;
302+
gap: 6px;
303+
align-items: flex-end;
304+
}
305+
306+
.thumb-btn {
307+
position: relative;
308+
padding: 0;
309+
border: none;
310+
background: none;
311+
cursor: pointer;
312+
border-radius: 6px;
313+
overflow: hidden;
314+
flex-shrink: 0;
315+
width: 106px;
316+
height: 60px;
317+
transition: transform 0.15s ease, box-shadow 0.15s ease;
318+
outline: 2px solid transparent;
319+
outline-offset: 1px;
320+
}
321+
322+
.thumb-btn img {
323+
width: 100%;
324+
height: 100%;
325+
object-fit: cover;
326+
display: block;
327+
border-radius: 6px;
328+
filter: brightness(0.85);
329+
transition: filter 0.15s ease;
330+
}
331+
332+
.thumb-btn:hover img,
333+
.thumb-btn.hovered img {
334+
filter: brightness(1.05);
335+
}
336+
337+
.thumb-btn:hover,
338+
.thumb-btn.hovered {
339+
transform: translateY(-3px) scale(1.04);
340+
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
341+
outline-color: rgba(79, 110, 247, 0.5);
342+
z-index: 2;
343+
}
344+
345+
.thumb-btn.active {
346+
outline-color: #4f6ef7;
347+
box-shadow: 0 0 0 2px #4f6ef7, 0 8px 20px rgba(79,110,247,0.3);
348+
z-index: 3;
349+
}
350+
351+
.thumb-btn.active img {
352+
filter: brightness(1.1);
353+
}
354+
355+
.thumb-btn.out-of-range img {
356+
filter: brightness(0.35) saturate(0.2);
357+
}
358+
359+
.thumb-time {
360+
position: absolute;
361+
bottom: 0;
362+
left: 0;
363+
right: 0;
364+
padding: 3px 4px 3px;
365+
background: linear-gradient(transparent, rgba(0,0,0,0.85));
366+
font-size: 9px;
367+
color: rgba(255,255,255,0.75);
368+
text-align: center;
369+
letter-spacing: 0.04em;
370+
pointer-events: none;
371+
border-radius: 0 0 6px 6px;
372+
}
373+
374+
.thumb-btn.active .thumb-time {
375+
color: #a5b4fc;
376+
}
377+
378+
.active-indicator {
379+
position: absolute;
380+
top: 4px;
381+
right: 4px;
382+
width: 6px;
383+
height: 6px;
384+
border-radius: 50%;
385+
background: #4f6ef7;
386+
box-shadow: 0 0 6px #4f6ef7;
387+
animation: pulse-dot 1.5s ease-in-out infinite;
388+
}
389+
390+
@keyframes pulse-dot {
391+
0%, 100% { opacity: 1; transform: scale(1); }
392+
50% { opacity: 0.5; transform: scale(0.7); }
393+
}
394+
`}</style>
395+
</div>
396+
);
397+
}

0 commit comments

Comments
 (0)