|
| 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