omniasr-transcriptions / frontend /src /hooks /useTimelineRenderer.ts
jeanma's picture
Omnilingual ASR transcription demo
ae238b3 verified
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 };
};