feat: add canvas-based timeline with playhead, scrubbing, and zoom
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
48
src/lib/timeline/interactions.ts
Normal file
48
src/lib/timeline/interactions.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { type TimelineState, xToTime } from './renderer';
|
||||
|
||||
export type { TimelineState };
|
||||
|
||||
const MIN_VISIBLE_RANGE = 0.1;
|
||||
const MIN_ZOOM = 1;
|
||||
|
||||
export function clampZoom(zoom: number, duration: number): number {
|
||||
const maxZoom = duration / MIN_VISIBLE_RANGE;
|
||||
return Math.max(MIN_ZOOM, Math.min(zoom, maxZoom));
|
||||
}
|
||||
|
||||
export function computeZoom(
|
||||
state: TimelineState,
|
||||
anchorTime: number,
|
||||
factor: number,
|
||||
duration: number
|
||||
): { visibleStart: number; visibleEnd: number; zoom: number } {
|
||||
const currentRange = state.visibleEnd - state.visibleStart;
|
||||
const newRange = currentRange / factor;
|
||||
const clampedRange = Math.max(MIN_VISIBLE_RANGE, Math.min(newRange, duration));
|
||||
|
||||
const anchorRatio = (anchorTime - state.visibleStart) / currentRange;
|
||||
let newStart = anchorTime - anchorRatio * clampedRange;
|
||||
let newEnd = newStart + clampedRange;
|
||||
|
||||
if (newStart < 0) {
|
||||
newStart = 0;
|
||||
newEnd = clampedRange;
|
||||
}
|
||||
if (newEnd > duration) {
|
||||
newEnd = duration;
|
||||
newStart = Math.max(0, newEnd - clampedRange);
|
||||
}
|
||||
|
||||
const newZoom = clampZoom(duration / clampedRange, duration);
|
||||
|
||||
return { visibleStart: newStart, visibleEnd: newEnd, zoom: newZoom };
|
||||
}
|
||||
|
||||
export function handleClick(
|
||||
x: number,
|
||||
state: TimelineState,
|
||||
duration: number
|
||||
): number {
|
||||
const time = xToTime(x, state);
|
||||
return Math.max(0, Math.min(time, duration));
|
||||
}
|
||||
128
src/lib/timeline/renderer.ts
Normal file
128
src/lib/timeline/renderer.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export interface TimelineState {
|
||||
visibleStart: number;
|
||||
visibleEnd: number;
|
||||
zoom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function timeToX(time: number, state: TimelineState): number {
|
||||
const range = state.visibleEnd - state.visibleStart;
|
||||
if (range <= 0) return 0;
|
||||
return ((time - state.visibleStart) / range) * state.width;
|
||||
}
|
||||
|
||||
export function xToTime(x: number, state: TimelineState): number {
|
||||
const range = state.visibleEnd - state.visibleStart;
|
||||
return state.visibleStart + (x / state.width) * range;
|
||||
}
|
||||
|
||||
const THUMB_LANE_HEIGHT = 50;
|
||||
const WAVEFORM_LANE_HEIGHT = 40;
|
||||
const CONTROLS_LANE_HEIGHT = 30;
|
||||
const TICK_HEIGHT = 10;
|
||||
|
||||
export function drawTimeline(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
currentTime: number,
|
||||
duration: number
|
||||
): void {
|
||||
const { width, height } = state;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.fillStyle = '#181825';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
drawTimeTicks(ctx, state, duration);
|
||||
|
||||
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
|
||||
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
|
||||
|
||||
drawPlayhead(ctx, state, currentTime);
|
||||
}
|
||||
|
||||
function drawTimeTicks(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
duration: number
|
||||
): void {
|
||||
const range = state.visibleEnd - state.visibleStart;
|
||||
const tickInterval = getTickInterval(range, state.width);
|
||||
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
|
||||
|
||||
ctx.fillStyle = '#6c7086';
|
||||
ctx.font = '10px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
|
||||
if (t < 0 || t > duration) continue;
|
||||
const x = timeToX(t, state);
|
||||
ctx.strokeStyle = '#45475a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT);
|
||||
ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT);
|
||||
ctx.stroke();
|
||||
|
||||
const label = formatTickLabel(t);
|
||||
ctx.fillText(label, x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 12);
|
||||
}
|
||||
}
|
||||
|
||||
function getTickInterval(visibleRange: number, width: number): number {
|
||||
const minPixelsPerTick = 80;
|
||||
const maxTicks = width / minPixelsPerTick;
|
||||
const rawInterval = visibleRange / maxTicks;
|
||||
|
||||
const intervals = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||
for (const interval of intervals) {
|
||||
if (interval >= rawInterval) return interval;
|
||||
}
|
||||
return 600;
|
||||
}
|
||||
|
||||
function formatTickLabel(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function drawPlaceholderLane(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
y: number,
|
||||
height: number,
|
||||
label: string
|
||||
): void {
|
||||
ctx.fillStyle = '#2a2a3e';
|
||||
ctx.fillRect(0, y, state.width, height);
|
||||
ctx.strokeStyle = '#353550';
|
||||
ctx.strokeRect(0, y, state.width, height);
|
||||
ctx.fillStyle = '#6c7086';
|
||||
ctx.font = '11px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
|
||||
}
|
||||
|
||||
export function drawPlayhead(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
currentTime: number
|
||||
): void {
|
||||
const x = timeToX(currentTime, state);
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, state.height);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 5, 0);
|
||||
ctx.lineTo(x + 5, 0);
|
||||
ctx.lineTo(x, 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
Reference in New Issue
Block a user