Spaces:
Running
on
A100
Running
on
A100
| import { useCallback, useEffect } from 'react'; | |
| import { AlignedSegment } from '../services/transcriptionApi'; | |
| import { SegmentWithTrack } from '../utils/trackUtils'; | |
| import { formatTime } from '../utils/subtitleUtils'; | |
| interface UseTimelineRendererOptions { | |
| canvasRef: React.RefObject<HTMLCanvasElement>; | |
| canvasSize: { width: number; height: number }; | |
| segmentsWithTracks: SegmentWithTrack[]; | |
| displaySegments: AlignedSegment[]; | |
| currentTime: number; | |
| activeSegmentIndex: number | null; | |
| selectedSegmentIndex: number | null; | |
| hoveredSegment: number | null; | |
| isDragging: boolean; | |
| dragSegmentIndex: number | null; | |
| mediaDuration: number; | |
| geometryUtils: { | |
| timeToX: (time: number) => number; | |
| trackToY: (track: number) => number; | |
| timelineWidth: number; | |
| }; | |
| constants: { | |
| TRACK_HEIGHT: number; | |
| TIMELINE_PADDING: number; | |
| PIXELS_PER_SECOND: number; | |
| }; | |
| } | |
| export const useTimelineRenderer = ({ | |
| canvasRef, | |
| canvasSize, | |
| segmentsWithTracks, | |
| displaySegments, | |
| currentTime, | |
| activeSegmentIndex, | |
| selectedSegmentIndex, | |
| hoveredSegment, | |
| isDragging, | |
| dragSegmentIndex, | |
| mediaDuration, | |
| geometryUtils, | |
| constants, | |
| }: UseTimelineRendererOptions) => { | |
| const { timeToX, trackToY, timelineWidth } = geometryUtils; | |
| const { TRACK_HEIGHT, TIMELINE_PADDING, PIXELS_PER_SECOND } = constants; | |
| // Draw the timeline | |
| const draw = useCallback(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| // Set canvas size | |
| canvas.width = canvasSize.width; | |
| canvas.height = canvasSize.height; | |
| // Clear canvas | |
| ctx.fillStyle = '#0f172a'; // bg-slate-900 | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Draw timeline base line | |
| ctx.strokeStyle = '#4B5563'; // gray-600 | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| const baseY = canvas.height / 2; | |
| ctx.moveTo(TIMELINE_PADDING, baseY); | |
| ctx.lineTo(timelineWidth - TIMELINE_PADDING, baseY); | |
| ctx.stroke(); | |
| // Draw time markers with dynamic intervals | |
| const getOptimalTimeInterval = () => { | |
| const pixelsPerSecond = PIXELS_PER_SECOND; | |
| const minSpacing = 120; // Increased from 80 to give more space between markers | |
| // Calculate what time interval gives us reasonable spacing | |
| const minTimeInterval = minSpacing / pixelsPerSecond; | |
| // Choose appropriate intervals based on duration and zoom | |
| if (minTimeInterval <= 1) return { major: 5, minor: 1 }; | |
| if (minTimeInterval <= 5) return { major: 10, minor: 2 }; | |
| if (minTimeInterval <= 10) return { major: 30, minor: 5 }; | |
| if (minTimeInterval <= 30) return { major: 60, minor: 10 }; | |
| if (minTimeInterval <= 60) return { major: 300, minor: 60 }; // 5min major, 1min minor | |
| if (minTimeInterval <= 300) return { major: 600, minor: 120 }; // 10min major, 2min minor | |
| return { major: 1800, minor: 300 }; // 30min major, 5min minor | |
| }; | |
| const { major: majorInterval, minor: minorInterval } = getOptimalTimeInterval(); | |
| // Draw background grid lines for better visual organization | |
| ctx.strokeStyle = '#1E293B'; // slate-800 (very subtle) | |
| ctx.lineWidth = 1; | |
| for (let time = 0; time <= mediaDuration; time += minorInterval) { | |
| const x = timeToX(time); | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, canvas.height - 40); // Don't overlap with time labels | |
| ctx.stroke(); | |
| } | |
| // Draw minor markers (shorter, more visible than before) | |
| ctx.strokeStyle = '#64748B'; // slate-500 (more visible than gray-700) | |
| ctx.lineWidth = 1; | |
| for (let time = 0; time <= mediaDuration; time += minorInterval) { | |
| const x = timeToX(time); | |
| ctx.beginPath(); | |
| ctx.moveTo(x, canvas.height - 15); | |
| ctx.lineTo(x, canvas.height - 5); | |
| ctx.stroke(); | |
| } | |
| // Draw major markers (taller, much more prominent) | |
| ctx.strokeStyle = '#94A3B8'; // slate-400 (much more visible) | |
| ctx.fillStyle = '#F1F5F9'; // slate-100 (bright white-ish for text) | |
| ctx.font = 'bold 13px system-ui'; // Slightly larger and bold | |
| ctx.lineWidth = 2; // Thicker lines for major markers | |
| for (let time = 0; time <= mediaDuration; time += majorInterval) { | |
| const x = timeToX(time); | |
| // Draw time label with special handling for 0:00 to avoid clipping | |
| const timeText = formatTime(time); | |
| ctx.fillStyle = '#F1F5F9'; // slate-100 (bright white-ish) | |
| if (time === 0) { | |
| // For 0:00, align left and shift right to avoid clipping | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(timeText, x + 4, canvas.height - 20); | |
| } else { | |
| // For all other times, center align as normal | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(timeText, x, canvas.height - 20); | |
| } | |
| } | |
| // Draw segments | |
| segmentsWithTracks.forEach((segment) => { | |
| // Find the original segment index in displaySegments | |
| const originalIndex = displaySegments.findIndex(s => | |
| s.start === segment.start && s.end === segment.end && s.text === segment.text | |
| ); | |
| const x = timeToX(segment.start); | |
| // Calculate actual width based on time duration, don't enforce minimum width for rendering | |
| const actualWidth = timeToX(segment.end) - timeToX(segment.start); | |
| const width = Math.max(actualWidth, 2); // Minimum 2px so segments are always visible | |
| const y = trackToY(segment.track); | |
| const height = TRACK_HEIGHT; | |
| // Draw all segments (scrolling is handled by container) | |
| { | |
| // Determine segment color based on original segment index, not track index | |
| let fillColor = '#374151'; // gray-700 (default) | |
| let strokeColor = '#4B5563'; // gray-600 | |
| let textColor = '#D1D5DB'; // gray-300 | |
| // Priority order: dragging > selected > active > hovered | |
| // Use originalIndex for all comparisons to maintain consistency during drag operations | |
| if (isDragging && dragSegmentIndex === originalIndex) { | |
| // Special styling for segment being dragged | |
| fillColor = '#DC2626'; // red-600 (dragging indicator) | |
| strokeColor = '#EF4444'; // red-500 | |
| textColor = '#FFFFFF'; | |
| } else if (selectedSegmentIndex === originalIndex) { | |
| fillColor = '#D97706'; // yellow-600 | |
| strokeColor = '#FBBF24'; // yellow-400 | |
| textColor = '#FFFFFF'; | |
| } else if (activeSegmentIndex === originalIndex && selectedSegmentIndex === null) { | |
| // Don't highlight active segment in blue when there's a selected segment | |
| fillColor = '#2563EB'; // blue-600 | |
| strokeColor = '#3B82F6'; // blue-500 | |
| textColor = '#FFFFFF'; | |
| } else if (hoveredSegment === originalIndex && !isDragging) { | |
| // Only show hover state when not dragging | |
| fillColor = '#4B5563'; // gray-600 | |
| strokeColor = '#6B7280'; // gray-500 | |
| } | |
| // Draw segment rectangle | |
| ctx.fillStyle = fillColor; | |
| ctx.fillRect(x, y, width, height); | |
| // Draw segment border | |
| ctx.strokeStyle = strokeColor; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x, y, width, height); | |
| // Draw segment text | |
| ctx.fillStyle = textColor; | |
| ctx.font = '12px system-ui'; | |
| ctx.textAlign = 'left'; | |
| // Clip text to segment width | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.rect(x + 4, y, width - 8, height); | |
| ctx.clip(); | |
| const textY = y + height / 2 + 4; // Center vertically | |
| ctx.fillText(segment.text, x + 4, textY); | |
| ctx.restore(); | |
| // Draw resize handles for selected segment | |
| if (selectedSegmentIndex === originalIndex) { | |
| const handleWidth = 8; | |
| ctx.fillStyle = '#3B82F6'; // blue-500 | |
| // Left handle | |
| ctx.fillRect(x, y, handleWidth, height); | |
| // Right handle | |
| ctx.fillRect(x + width - handleWidth, y, handleWidth, height); | |
| } | |
| } | |
| }); | |
| // Draw progress indicator | |
| const progressX = timeToX(currentTime); | |
| ctx.strokeStyle = '#EF4444'; // red-500 | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(progressX, 0); | |
| ctx.lineTo(progressX, canvas.height); | |
| ctx.stroke(); | |
| }, [ | |
| canvasRef, | |
| canvasSize, | |
| segmentsWithTracks, | |
| displaySegments, | |
| currentTime, | |
| activeSegmentIndex, | |
| selectedSegmentIndex, | |
| hoveredSegment, | |
| isDragging, | |
| dragSegmentIndex, | |
| mediaDuration, | |
| timeToX, | |
| trackToY, | |
| timelineWidth, | |
| TRACK_HEIGHT, | |
| TIMELINE_PADDING, | |
| PIXELS_PER_SECOND, | |
| ]); | |
| // Redraw when dependencies change | |
| useEffect(() => { | |
| draw(); | |
| }, [draw]); | |
| return { draw }; | |
| }; | |