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