feat: add canvas-based timeline with playhead, scrubbing, and zoom

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 11:03:24 -04:00
parent ac7fa550e1
commit 36701feb9e
5 changed files with 398 additions and 9 deletions

View File

@@ -1,17 +1,133 @@
<div class="timeline">
<div class="placeholder-text">Timeline</div>
<script lang="ts">
import { session } from '$lib/stores/videoSession.svelte';
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
import {
computeZoom,
handleClick as computeClickTime,
} from '$lib/timeline/interactions';
let canvas = $state<HTMLCanvasElement | null>(null);
let containerEl = $state<HTMLDivElement | null>(null);
let animFrameId = $state(0);
let timelineState = $state<TimelineState>({
visibleStart: 0,
visibleEnd: 60,
zoom: 1,
width: 600,
height: 160,
});
let isDragging = $state(false);
$effect(() => {
if (session.duration > 0) {
timelineState.visibleEnd = session.duration;
}
});
$effect(() => {
if (!containerEl) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
timelineState.width = entry.contentRect.width;
timelineState.height = entry.contentRect.height;
if (canvas) {
canvas.width = entry.contentRect.width * window.devicePixelRatio;
canvas.height = entry.contentRect.height * window.devicePixelRatio;
canvas.style.width = `${entry.contentRect.width}px`;
canvas.style.height = `${entry.contentRect.height}px`;
}
}
});
observer.observe(containerEl);
return () => observer.disconnect();
});
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
function draw() {
if (!ctx || !canvas) return;
ctx.save();
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
drawTimeline(ctx, timelineState, session.currentTime, session.duration);
ctx.restore();
animFrameId = requestAnimationFrame(draw);
}
draw();
return () => cancelAnimationFrame(animFrameId);
});
function getCanvasX(e: MouseEvent): number {
if (!canvas) return 0;
const rect = canvas.getBoundingClientRect();
return e.clientX - rect.left;
}
function seekTo(time: number) {
session.currentTime = time;
const videoEl = document.querySelector('video');
if (videoEl) {
videoEl.currentTime = time;
}
}
function handleMouseDown(e: MouseEvent) {
isDragging = true;
const x = getCanvasX(e);
const targetTime = computeClickTime(x, timelineState, session.duration);
seekTo(targetTime);
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
const x = getCanvasX(e);
const targetTime = computeClickTime(x, timelineState, session.duration);
seekTo(targetTime);
}
function handleMouseUp() {
isDragging = false;
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const x = getCanvasX(e);
const anchorTime = computeClickTime(x, timelineState, session.duration);
const factor = e.deltaY < 0 ? 1.15 : 0.87;
const result = computeZoom(timelineState, anchorTime, factor, session.duration);
timelineState.visibleStart = result.visibleStart;
timelineState.visibleEnd = result.visibleEnd;
timelineState.zoom = result.zoom;
}
</script>
<div class="timeline-container" bind:this={containerEl}>
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
onwheel={handleWheel}
></canvas>
</div>
<style>
.timeline {
.timeline-container {
height: 160px;
background: var(--timeline-bg);
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: crosshair;
}
.placeholder-text {
color: var(--text-muted);
font-size: 13px;
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>