Skip to content

Commit 43d3c08

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: Add InShot-style visual timeline with color-coded event blocks
1 parent 5026ba4 commit 43d3c08

2 files changed

Lines changed: 191 additions & 49 deletions

File tree

src/components/RecorderControls.tsx

Lines changed: 10 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Upload, Save, Film, Pencil, Scissors, ChevronDown, Trash2
55
} from 'lucide-react';
66
import type { RecordedSession } from '../types';
7+
import { VideoTimeline } from './VideoTimeline';
78

89
interface RecorderControlsProps {
910
isRecording: boolean;
@@ -64,48 +65,6 @@ export const RecorderControls = ({
6465
text: `text-[10px] font-bold uppercase tracking-wider ${isLightMode ? 'text-neutral-500' : 'text-neutral-400'}`
6566
};
6667

67-
// Render Timeline Scrubber
68-
const renderScrubber = () => {
69-
if (!session) return null;
70-
71-
const progress = (playbackTime / session.metadata.duration) * 100;
72-
73-
// Filter out high-frequency camera events, keep state/annotation/chat
74-
const markers = session.events.filter(e => e.type !== 'camera');
75-
76-
return (
77-
<div className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-full cursor-pointer relative group"
78-
onClick={(e) => {
79-
const rect = e.currentTarget.getBoundingClientRect();
80-
const p = (e.clientX - rect.left) / rect.width;
81-
seek(p * session.metadata.duration);
82-
}}>
83-
84-
{/* Visual Markers for Significant Events */}
85-
{markers.map((event, idx) => {
86-
const left = (event.timestamp / session.metadata.duration) * 100;
87-
return (
88-
<div
89-
key={idx}
90-
className="absolute top-1/2 -translate-y-1/2 w-1 h-1 bg-yellow-500 rounded-full pointer-events-none opacity-50 z-10"
91-
style={{ left: `${left}%` }}
92-
title={`${event.type} change at ${formatTime(event.timestamp)}`}
93-
/>
94-
);
95-
})}
96-
97-
<div
98-
className="h-full bg-blue-500 rounded-full absolute top-0 left-0 pointer-events-none transition-all duration-100 z-20"
99-
style={{ width: `${progress}%` }}
100-
/>
101-
<div
102-
className="w-3 h-3 bg-white shadow rounded-full absolute top-1/2 -translate-y-1/2 -translate-x-1/2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-30"
103-
style={{ left: `${progress}%` }}
104-
/>
105-
</div>
106-
);
107-
};
108-
10968
return (
11069
<div className={`p-3 rounded-xl border space-y-3 ${cardBg} ${isLightMode ? 'border-neutral-200' : 'border-white/10'}`}>
11170

@@ -212,14 +171,16 @@ export const RecorderControls = ({
212171
)}
213172
</div>
214173

215-
{/* Timeline (Only if session exists) */}
174+
{/* Visual Timeline (Only if session exists) */}
216175
{session && !isRecording && (
217-
<div className="space-y-1 pt-1">
218-
{renderScrubber()}
219-
<div className="flex justify-between text-[9px] font-mono opacity-60">
220-
<span>{formatTime(playbackTime)}</span>
221-
<span>{formatTime(session.metadata.duration)}</span>
222-
</div>
176+
<div className="pt-2">
177+
<VideoTimeline
178+
session={session}
179+
playbackTime={playbackTime}
180+
isPlaying={isPlaying}
181+
onSeek={seek}
182+
isLightMode={isLightMode}
183+
/>
223184
</div>
224185
)}
225186

src/components/VideoTimeline.tsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { useState, useRef } from 'react';
2+
import type { RecordedSession } from '../types';
3+
4+
interface VideoTimelineProps {
5+
session: RecordedSession | null;
6+
playbackTime: number;
7+
isPlaying: boolean;
8+
onSeek: (time: number) => void;
9+
isLightMode?: boolean;
10+
}
11+
12+
export const VideoTimeline = ({
13+
session,
14+
playbackTime,
15+
onSeek
16+
}: VideoTimelineProps) => {
17+
const timelineRef = useRef<HTMLDivElement>(null);
18+
const [hoveredTime, setHoveredTime] = useState<number | null>(null);
19+
20+
const duration = session?.metadata.duration || 1000;
21+
22+
// Event type colors (InShot-inspired)
23+
const eventColors = {
24+
camera: '#00b4d8',
25+
state: '#00c853',
26+
annotation: '#ffd60a',
27+
chat: '#9d4edd'
28+
};
29+
30+
// Convert pixel position to time
31+
const xToTime = (x: number) => {
32+
if (!timelineRef.current) return 0;
33+
const rect = timelineRef.current.getBoundingClientRect();
34+
const relativeX = Math.max(0, Math.min(x - rect.left, rect.width));
35+
return (relativeX / rect.width) * duration;
36+
};
37+
38+
const handleTimelineClick = (e: React.MouseEvent) => {
39+
const time = xToTime(e.clientX);
40+
onSeek(time);
41+
};
42+
43+
const handleMouseMove = (e: React.MouseEvent) => {
44+
const time = xToTime(e.clientX);
45+
setHoveredTime(time);
46+
};
47+
48+
const handleMouseLeave = () => {
49+
setHoveredTime(null);
50+
};
51+
52+
// Format time as MM:SS
53+
const formatTime = (ms: number) => {
54+
const seconds = Math.floor(ms / 1000);
55+
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
56+
const s = (seconds % 60).toString().padStart(2, '0');
57+
return `${m}:${s}`;
58+
};
59+
60+
// Group events into visual blocks
61+
const getEventBlocks = () => {
62+
if (!session) return [];
63+
64+
const blocks: Array<{
65+
type: string;
66+
start: number;
67+
end: number;
68+
count: number;
69+
}> = [];
70+
71+
// Group consecutive events of same type
72+
session.events.forEach((event, idx) => {
73+
const lastBlock = blocks[blocks.length - 1];
74+
const nextEvent = session.events[idx + 1];
75+
const eventEnd = nextEvent ? nextEvent.timestamp : duration;
76+
77+
if (lastBlock && lastBlock.type === event.type && event.timestamp - lastBlock.end < 1000) {
78+
// Extend existing block
79+
lastBlock.end = eventEnd;
80+
lastBlock.count++;
81+
} else {
82+
// Create new block
83+
blocks.push({
84+
type: event.type,
85+
start: event.timestamp,
86+
end: Math.min(event.timestamp + 500, eventEnd), // Minimum 500ms block
87+
count: 1
88+
});
89+
}
90+
});
91+
92+
return blocks;
93+
};
94+
95+
const eventBlocks = getEventBlocks();
96+
97+
return (
98+
<div className="space-y-2">
99+
{/* Timeline Track */}
100+
<div
101+
ref={timelineRef}
102+
className="relative h-24 bg-black/40 rounded-lg border border-white/10 overflow-hidden cursor-pointer"
103+
onClick={handleTimelineClick}
104+
onMouseMove={handleMouseMove}
105+
onMouseLeave={handleMouseLeave}
106+
>
107+
{/* Grid Lines */}
108+
{[...Array(10)].map((_, i) => (
109+
<div
110+
key={i}
111+
className="absolute top-0 bottom-0 w-px bg-white/5"
112+
style={{ left: `${(i + 1) * 10}%` }}
113+
/>
114+
))}
115+
116+
{/* Event Blocks */}
117+
<div className="absolute top-0 left-0 right-0 h-12">
118+
{eventBlocks.map((block, idx) => {
119+
const left = (block.start / duration) * 100;
120+
const width = ((block.end - block.start) / duration) * 100;
121+
const color = eventColors[block.type as keyof typeof eventColors] || '#666';
122+
123+
return (
124+
<div
125+
key={idx}
126+
className="absolute top-2 h-8 rounded opacity-70 hover:opacity-100 transition-opacity"
127+
style={{
128+
left: `${left}%`,
129+
width: `${width}%`,
130+
backgroundColor: color,
131+
minWidth: '2px'
132+
}}
133+
title={`${block.type} (${block.count} events)`}
134+
/>
135+
);
136+
})}
137+
</div>
138+
139+
{/* Playhead (Current Time Indicator) */}
140+
<div
141+
className="absolute top-0 bottom-0 w-0.5 bg-red-500"
142+
style={{ left: `${(playbackTime / duration) * 100}%` }}
143+
>
144+
<div className="absolute -top-1 -left-1.5 w-3 h-3 bg-red-500 rounded-full" />
145+
</div>
146+
147+
{/* Hover Time Indicator */}
148+
{hoveredTime !== null && (
149+
<div
150+
className="absolute top-0 bottom-0 w-px bg-white/30"
151+
style={{ left: `${(hoveredTime / duration) * 100}%` }}
152+
>
153+
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-black/80 text-white text-xs rounded whitespace-nowrap">
154+
{formatTime(hoveredTime)}
155+
</div>
156+
</div>
157+
)}
158+
</div>
159+
160+
{/* Time Markers */}
161+
<div className="flex justify-between text-xs opacity-60 font-mono px-1">
162+
<span>00:00</span>
163+
<span>{formatTime(duration / 2)}</span>
164+
<span>{formatTime(duration)}</span>
165+
</div>
166+
167+
{/* Event Legend */}
168+
<div className="flex gap-3 text-xs flex-wrap">
169+
{Object.entries(eventColors).map(([type, color]) => (
170+
<div key={type} className="flex items-center gap-1.5">
171+
<div
172+
className="w-3 h-3 rounded"
173+
style={{ backgroundColor: color }}
174+
/>
175+
<span className="capitalize opacity-70">{type}</span>
176+
</div>
177+
))}
178+
</div>
179+
</div>
180+
);
181+
};

0 commit comments

Comments
 (0)