2026-09-22 19:06:32 -04:00
|
|
|
<script lang="ts">
|
|
|
|
|
import { session } from '$lib/stores/videoSession.svelte';
|
|
|
|
|
import { drawWaveform, type WaveformData } from '$lib/timeline/waveformRenderer';
|
|
|
|
|
import { type TimelineState, timeToX, drawPlayhead } from '$lib/timeline/renderer';
|
|
|
|
|
import { seekTo } from '$lib/transport/playback';
|
|
|
|
|
|
|
|
|
|
let { visible }: { visible: boolean } = $props();
|
|
|
|
|
|
|
|
|
|
let canvas = $state<HTMLCanvasElement | null>(null);
|
|
|
|
|
let containerEl = $state<HTMLDivElement | null>(null);
|
|
|
|
|
let pendingDraw = 0;
|
|
|
|
|
|
|
|
|
|
const TIMELINE_HEIGHT = 50;
|
|
|
|
|
|
|
|
|
|
let timelineState = $state<TimelineState>({
|
|
|
|
|
visibleStart: 0,
|
|
|
|
|
visibleEnd: 60,
|
|
|
|
|
zoom: 1,
|
|
|
|
|
width: 600,
|
|
|
|
|
height: TIMELINE_HEIGHT,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let waveformData = $derived<WaveformData>({
|
|
|
|
|
tiers: session.waveformTiers,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function requestDraw() {
|
|
|
|
|
if (!pendingDraw) {
|
|
|
|
|
pendingDraw = requestAnimationFrame(() => {
|
|
|
|
|
drawMainCanvas();
|
|
|
|
|
pendingDraw = 0;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawMainCanvas() {
|
|
|
|
|
if (!canvas) return;
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
ctx.save();
|
|
|
|
|
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
|
|
|
|
|
|
|
|
|
const { width } = timelineState;
|
|
|
|
|
ctx.clearRect(0, 0, width, TIMELINE_HEIGHT);
|
|
|
|
|
|
|
|
|
|
// Background
|
|
|
|
|
ctx.fillStyle = 'transparent';
|
|
|
|
|
ctx.fillRect(0, 0, width, TIMELINE_HEIGHT);
|
|
|
|
|
|
|
|
|
|
// Draw major time ticks
|
|
|
|
|
drawSimpleTimeTicks(ctx, timelineState, session.duration);
|
|
|
|
|
|
|
|
|
|
// Draw waveform
|
|
|
|
|
if (waveformData.tiers) {
|
|
|
|
|
drawWaveform(ctx, timelineState, waveformData, session.duration, 0, TIMELINE_HEIGHT - 16);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw playhead
|
2026-09-22 20:16:10 -04:00
|
|
|
if (session.duration > 0) {
|
|
|
|
|
drawPlayhead(ctx, timelineState, session.currentTime);
|
|
|
|
|
}
|
2026-09-22 19:06:32 -04:00
|
|
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawSimpleTimeTicks(
|
|
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
|
state: TimelineState,
|
|
|
|
|
duration: number
|
|
|
|
|
) {
|
|
|
|
|
if (duration <= 0) return;
|
|
|
|
|
const range = state.visibleEnd - state.visibleStart;
|
|
|
|
|
const tickInterval = getTickInterval(range, state.width);
|
|
|
|
|
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
|
|
|
|
|
const baselineY = TIMELINE_HEIGHT - 2;
|
|
|
|
|
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
ctx.font = '10px -apple-system, sans-serif';
|
|
|
|
|
ctx.fillStyle = '#6c7086';
|
|
|
|
|
|
|
|
|
|
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, TIMELINE_HEIGHT - 14);
|
|
|
|
|
ctx.lineTo(x, TIMELINE_HEIGHT - 8);
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
const m = Math.floor(t / 60);
|
|
|
|
|
const s = Math.floor(t % 60);
|
|
|
|
|
ctx.fillText(`${m}:${String(s).padStart(2, '0')}`, x, baselineY);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sync timeline state with session duration
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (session.duration > 0) {
|
|
|
|
|
timelineState.visibleStart = 0;
|
|
|
|
|
timelineState.visibleEnd = session.duration;
|
|
|
|
|
timelineState.zoom = 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Resize observer
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (!containerEl) return;
|
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
timelineState.width = entry.contentRect.width;
|
|
|
|
|
if (canvas) {
|
|
|
|
|
canvas.width = entry.contentRect.width * window.devicePixelRatio;
|
|
|
|
|
canvas.height = TIMELINE_HEIGHT * window.devicePixelRatio;
|
|
|
|
|
canvas.style.width = `${entry.contentRect.width}px`;
|
|
|
|
|
canvas.style.height = `${TIMELINE_HEIGHT}px`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
observer.observe(containerEl);
|
|
|
|
|
return () => observer.disconnect();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Redraw on state changes
|
|
|
|
|
$effect(() => {
|
|
|
|
|
void session.currentTime;
|
|
|
|
|
void session.duration;
|
|
|
|
|
void session.waveformTiers;
|
|
|
|
|
void timelineState.visibleStart;
|
|
|
|
|
void timelineState.visibleEnd;
|
|
|
|
|
void timelineState.width;
|
|
|
|
|
void canvas;
|
|
|
|
|
requestDraw();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function handleClick(e: MouseEvent) {
|
|
|
|
|
if (!canvas) return;
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
const x = e.clientX - rect.left;
|
|
|
|
|
const range = timelineState.visibleEnd - timelineState.visibleStart;
|
|
|
|
|
const time = timelineState.visibleStart + (x / timelineState.width) * range;
|
|
|
|
|
seekTo(Math.max(0, Math.min(time, session.duration)));
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
class="player-timeline"
|
|
|
|
|
class:visible
|
|
|
|
|
bind:this={containerEl}
|
|
|
|
|
>
|
|
|
|
|
<canvas
|
|
|
|
|
bind:this={canvas}
|
|
|
|
|
onclick={handleClick}
|
|
|
|
|
></canvas>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.player-timeline {
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
height: 50px;
|
|
|
|
|
background: rgba(0, 0, 0, 0.6);
|
|
|
|
|
backdrop-filter: blur(4px);
|
|
|
|
|
z-index: 15;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transform: translateY(100%);
|
|
|
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
|
|
|
cursor: crosshair;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.player-timeline.visible {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
transform: translateY(0);
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
canvas {
|
|
|
|
|
display: block;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
</style>
|