From 36701feb9ecb14ab79c6b5ceff0941d50925078d Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 21 Sep 2026 11:03:24 -0400 Subject: [PATCH] feat: add canvas-based timeline with playhead, scrubbing, and zoom Co-authored-by: Cursor --- src/lib/components/Timeline.svelte | 134 ++++++++++++++++++++++-- src/lib/timeline/interactions.ts | 48 +++++++++ src/lib/timeline/renderer.ts | 128 ++++++++++++++++++++++ tests/lib/timeline/interactions.test.ts | 40 +++++++ tests/lib/timeline/renderer.test.ts | 57 ++++++++++ 5 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 src/lib/timeline/interactions.ts create mode 100644 src/lib/timeline/renderer.ts create mode 100644 tests/lib/timeline/interactions.test.ts create mode 100644 tests/lib/timeline/renderer.test.ts diff --git a/src/lib/components/Timeline.svelte b/src/lib/components/Timeline.svelte index d1437b9..05c6f65 100644 --- a/src/lib/components/Timeline.svelte +++ b/src/lib/components/Timeline.svelte @@ -1,17 +1,133 @@ -
-
Timeline
+ + +
+
diff --git a/src/lib/timeline/interactions.ts b/src/lib/timeline/interactions.ts new file mode 100644 index 0000000..75b7439 --- /dev/null +++ b/src/lib/timeline/interactions.ts @@ -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)); +} diff --git a/src/lib/timeline/renderer.ts b/src/lib/timeline/renderer.ts new file mode 100644 index 0000000..d4b8f41 --- /dev/null +++ b/src/lib/timeline/renderer.ts @@ -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(); +} diff --git a/tests/lib/timeline/interactions.test.ts b/tests/lib/timeline/interactions.test.ts new file mode 100644 index 0000000..e624454 --- /dev/null +++ b/tests/lib/timeline/interactions.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { clampZoom, computeZoom, type TimelineState } from '$lib/timeline/interactions'; + +describe('clampZoom', () => { + it('clamps zoom above minimum', () => { + expect(clampZoom(0.5, 60)).toBeGreaterThanOrEqual(1); + }); + + it('clamps zoom below maximum', () => { + expect(clampZoom(10000, 60)).toBeLessThanOrEqual(600); + }); +}); + +describe('computeZoom', () => { + it('zooming in narrows the visible range', () => { + const state: TimelineState = { + visibleStart: 0, + visibleEnd: 60, + zoom: 1, + width: 600, + height: 160, + }; + const result = computeZoom(state, 30, 1.1, 60); + const newRange = result.visibleEnd - result.visibleStart; + expect(newRange).toBeLessThan(60); + }); + + it('zooming out widens the visible range', () => { + const state: TimelineState = { + visibleStart: 10, + visibleEnd: 20, + zoom: 6, + width: 600, + height: 160, + }; + const result = computeZoom(state, 15, 0.9, 60); + const newRange = result.visibleEnd - result.visibleStart; + expect(newRange).toBeGreaterThan(10); + }); +}); diff --git a/tests/lib/timeline/renderer.test.ts b/tests/lib/timeline/renderer.test.ts new file mode 100644 index 0000000..988ac07 --- /dev/null +++ b/tests/lib/timeline/renderer.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { timeToX, xToTime, type TimelineState } from '$lib/timeline/renderer'; + +function makeState(overrides: Partial = {}): TimelineState { + return { + visibleStart: 0, + visibleEnd: 60, + zoom: 1, + width: 600, + height: 160, + ...overrides, + }; +} + +describe('timeToX', () => { + it('maps start of visible range to 0', () => { + const state = makeState(); + expect(timeToX(0, state)).toBe(0); + }); + + it('maps end of visible range to width', () => { + const state = makeState(); + expect(timeToX(60, state)).toBe(600); + }); + + it('maps midpoint correctly', () => { + const state = makeState(); + expect(timeToX(30, state)).toBe(300); + }); + + it('handles zoomed-in view', () => { + const state = makeState({ visibleStart: 10, visibleEnd: 20 }); + expect(timeToX(15, state)).toBe(300); + }); +}); + +describe('xToTime', () => { + it('maps 0 to visible start', () => { + const state = makeState(); + expect(xToTime(0, state)).toBe(0); + }); + + it('maps width to visible end', () => { + const state = makeState(); + expect(xToTime(600, state)).toBe(60); + }); + + it('maps midpoint correctly', () => { + const state = makeState(); + expect(xToTime(300, state)).toBe(30); + }); + + it('inverse of timeToX', () => { + const state = makeState({ visibleStart: 10, visibleEnd: 20 }); + expect(xToTime(timeToX(15, state), state)).toBeCloseTo(15); + }); +});