11 Commits

Author SHA1 Message Date
c90b6765b7 fix: timeline resize regression + dynamic lane alignment
- Remove splitAreaEl dependency from handleResizeMove; use fixed 40-500 clamp
- Add computeLanes() + LaneLayout for dynamic lane height computation
- Update clipRenderer to accept LaneLayout instead of hardcoded constants
- Tighten CONTROLS_HEIGHT (30→22) and CLIP_HEIGHT (30→20) to reduce gaps
- Tighten label baseline offset (14→10) for better waveform-to-tick alignment

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:15:19 -04:00
23b30be357 fix: CSS-grid layout to preserve video on toggle + magnification enhancements (brighter, larger, minor ticks)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 17:30:47 -04:00
124542b4c4 fix: post-smoke-test polish — preserve playhead, clip list redesign, play-clip action, 1100x750 default
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 17:15:28 -04:00
279e27a9b5 docs: user edits to v0.1.3 spec (version bump section)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:54:53 -04:00
8c271773bd feat: timeline right-click context menu with mark/seek/delete actions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:53:43 -04:00
27c199e090 feat: larger timeline timestamps with mouse-proximity magnification
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:51:56 -04:00
923c7b0e23 fix: clip list width edge cases (falsy check, narrow-window clamp)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:50:57 -04:00
b371ec6c3e feat: adjustable clip list layout (bottom/left sidebar)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:49:35 -04:00
0b8bec2496 chore: bump version to 0.1.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:48:14 -04:00
91550dacd6 docs: add v0.1.3 implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:42:59 -04:00
9752a6d614 docs: add v0.1.3 polish/tweaks design spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:32:17 -04:00
18 changed files with 2200 additions and 108 deletions

View File

@@ -1 +1 @@
0.1.2
0.1.3

View File

@@ -0,0 +1,56 @@
# v0.1.3 Polish/Tweaks Implementation Summary
**Date:** 2026-09-22 16:55 ET
**Version:** 0.1.2 → 0.1.3
## Task Description
Implemented three UI polish features for v0.1.3 of the GUI Video Clipper (Tauri + Svelte 5):
1. **Adjustable clip list layout** — clip list can appear below the timeline (default) or as a left sidebar next to the video preview, with a toolbar toggle button and persisted preference
2. **Timeline timestamp magnification** — increased base font from 10px to 12px, with a mouse-proximity magnification effect using cosine interpolation (~18px peak, 80px radius)
3. **Timeline right-click context menu** — custom context menu replacing the default web menu, with Mark In/Out, Go to Clip Start/End, and Delete Clip actions
## Changes Made
### Commits (oldest → newest)
- `0b8bec2` — chore: bump version to 0.1.3
- `b371ec6` — feat: adjustable clip list layout (bottom/left sidebar)
- `923c7b0` — fix: clip list width edge cases (falsy check, narrow-window clamp)
- `27c199e` — feat: larger timeline timestamps with mouse-proximity magnification
- `8c27177` — feat: timeline right-click context menu with mark/seek/delete actions
- `279e27a` — docs: user edits to v0.1.3 spec (version bump section)
### Files Modified
- `VERSION` — new file, version 0.1.3
- `package.json` — version bump
- `package-lock.json` — lockfile sync
- `src-tauri/tauri.conf.json` — version bump
- `src-tauri/Cargo.toml` — version bump
- `src-tauri/Cargo.lock` — lockfile sync
- `src/lib/stores/preferences.svelte.ts` — added `clipListPosition` and `clipListWidth` preferences
- `src/App.svelte` — dual layout modes (bottom/left), toggle button, sidebar resize
- `src/lib/components/ClipList.svelte` — added `position` prop for sidebar styling
- `src/lib/timeline/renderer.ts` — larger base font, magnification math in `drawTimeTicks()`
- `src/lib/components/Timeline.svelte` — mouseX tracking, context menu wiring
- `src/lib/components/TimelineContextMenu.svelte` — new context menu component
### Design & Plan Docs
- `docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md` — design spec
- `docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md` — implementation plan
## Process
Used subagent-driven development: 5 tasks dispatched as subagents with task reviews between each. Two reviewer findings on Task 2 (falsy width check, narrow-window clamp) were fixed before proceeding.
## Lessons Learned
- The layout restructure required careful conditional rendering in App.svelte — the `{#if}/{:else}` approach creates two separate DOM trees. This means TransportControls gets remounted on layout toggle, which should be smoke-tested.
- Context menu `$effect` for viewport clamping can flash position (0,0) for one frame before adjustment — initializing adjusted positions from the raw x/y props would eliminate this.
- The bump-version.sh script doesn't sync package-lock.json — needed manual `npm install --package-lock-only`.
## Follow-Up Items
- [ ] Manual smoke test with `npm run tauri dev` — layout toggle, sidebar resize, timestamp magnification feel, context menu flow
- [ ] Consider fixing first-frame context menu position flash
- [ ] Consider adding `onresize` clamp for sidebar width when window shrinks below saved width

View File

@@ -0,0 +1,44 @@
# Fix Resize + Waveform Alignment
**Date:** 2026-09-22 17:42
**Task:** Implement the "Fix Resize + Waveform Alignment" plan to fix horizontal resize regression and waveform/lane alignment issues.
## Changes Made
### 1. Resize Regression Fix — `src/App.svelte`
- **Root cause:** After the CSS-grid refactor, `handleResizeMove` used `splitAreaEl.clientHeight` as the upper bound. Since `.lower-section` only contained the resize handle (5px) + timeline pane, `totalHeight ≈ timelineHeight + 5`, so the clamp `Math.min(totalHeight - 20, ...)` immediately locked the height.
- **Fix:**
- Removed `splitAreaEl` state variable.
- Removed `bind:this={splitAreaEl}` from the `.lower-section` div.
- Simplified `handleResizeMove` to use a fixed max of 500 (matching the existing keyboard resize handler): `Math.max(40, Math.min(500, resizeStartHeight + delta))`.
### 2. Dynamic Lane Layout — `src/lib/timeline/renderer.ts`
- Removed fixed lane height constants (`THUMB_LANE_HEIGHT`, `WAVEFORM_LANE_HEIGHT`, `CONTROLS_LANE_HEIGHT`).
- Added `LaneLayout` interface and `computeLanes(totalHeight)` function that dynamically computes lane positions proportionally based on total canvas height.
- Controls lane: fixed 30px at bottom.
- Clip lane: fixed 30px above controls.
- Remaining space split 55/45 between thumbnails and waveform (with minimums of 20px and 15px).
- Updated `drawTimeline` to call `computeLanes(height)` and pass layout to all sub-draw functions.
- Updated `drawTimeTicks` to accept `LaneLayout` and use `lanes.controlsY` instead of `state.height - CONTROLS_LANE_HEIGHT`.
### 3. Clip Renderer Update — `src/lib/timeline/clipRenderer.ts`
- Removed hardcoded `CLIP_LANE_Y` and `CLIP_LANE_HEIGHT` constants.
- Updated `drawClips` and `hitTestClip` signatures to accept `LaneLayout` parameter.
- All clip positioning now uses `lanes.clipY` and `lanes.clipHeight`.
### 4. Timeline Component Update — `src/lib/components/Timeline.svelte`
- Imported `computeLanes` from renderer.
- Updated `hitTestClip` call to pass `computeLanes(timelineState.height)`.
## Verification
- `npm run check` (svelte-check): 0 errors, 0 warnings.
## Files Modified
- `src/App.svelte`
- `src/lib/timeline/renderer.ts`
- `src/lib/timeline/clipRenderer.ts`
- `src/lib/components/Timeline.svelte`
## Lessons Learned
- When using CSS grid layouts, elements that previously represented the "total space" may now only contain their own children. Relying on `clientHeight` of such elements for resize calculations breaks — use fixed bounds instead.
- Dynamic lane computation is preferable to fixed pixel constants because it adapts to any canvas height, preventing gaps or overlaps when the user resizes the timeline.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
# v0.1.3 Polish & Tweaks — Design Spec
**Date:** 2026-09-22
**Version:** 0.1.2 → 0.1.3
## Overview
Three UI polish features for v0.1.3: adjustable clip-list layout, improved timeline timestamp labels with a mouse-proximity magnification effect, and a custom right-click context menu on the timeline.
---
## Feature 1: Adjustable Layout (Clip List Position)
### Current State
The main content area is a vertical stack:
```
┌──────────────────────────────┐
│ Toolbar │
├──────────────────────────────┤
│ Video Preview │
├──────────────────────────────┤
│ Transport Controls │
├──────────────────────────────┤
│ Timeline / Thumbs / Waveform │
├──────────────────────────────┤
│ Clip List │
├──────────────────────────────┤
│ Status Bar │
└──────────────────────────────┘
```
### Proposed: Two Layout Modes
A new `clipListPosition` preference (`'bottom' | 'left'`) controls where the clip list appears. Default: `'bottom'` (current behavior, unchanged).
When set to `'left'`, the layout becomes:
```
┌──────────────────────────────────────────┐
│ Toolbar │
├──────────┬───────────────────────────────┤
│ Clip │ Video Preview │
│ List │ │
│ │ │
│ ├───────────────────────────────┤
│ │ Transport Controls │
├──────────┴───────────────────────────────┤
│ Timeline / Thumbs / Waveform (full) │
├──────────────────────────────────────────┤
│ Status Bar │
└──────────────────────────────────────────┘
```
Key details:
- The clip list shares horizontal space only with the video preview and transport controls. The timeline remains full-width underneath — no horizontal space is sacrificed for timeline real estate.
- A vertical drag-to-resize handle sits between the clip list and the video area, allowing the user to adjust the sidebar width. Default width: ~220px. Min: 150px. Max: 40% of window width.
- The existing vertical resize handle between timeline and clip list only appears in `'bottom'` mode. In `'left'` mode, the timeline height is controlled by the existing `timelineHeight` state (still resizable via a horizontal drag handle between the upper section and the timeline).
### Toggle Button
A button in the toolbar (alongside ⚙ and ℹ) toggles between `'bottom'` and `'left'` modes. The icon visually hints at the current layout — e.g. a stacked-rows icon for bottom mode, a sidebar icon for left mode. Clicking it switches immediately and persists the preference.
### Persistence
- Add `clipListPosition: 'bottom' | 'left'` to the `Preferences` interface in `preferences.svelte.ts`.
- Add `clipListWidth: number` (default 220) for the sidebar width in left mode.
- Both are saved/loaded via the existing Tauri store mechanism.
### Implementation Notes
- `App.svelte` is the only component that needs structural changes. `ClipList.svelte`, `VideoPlayer.svelte`, and `Timeline.svelte` remain unchanged — they just get placed in different containers.
- In `'left'` mode, the `.content` area becomes:
- A flex column with two children:
1. **Upper section** (flex row): `[ClipList sidebar | flex column of [VideoPlayer, TransportControls]]`
2. **Lower section** (same as before): timeline pane with resize handle
- The vertical resize handle between the upper and lower sections works the same as the existing one.
- In `'bottom'` mode: the current layout is preserved exactly.
- The `ClipList` component needs no changes; it already fills its container. The only adjustment is removing the `border-top` when in left mode (the sidebar should have a `border-right` instead), which can be handled via a CSS class or prop.
---
## Feature 2: Timeline Timestamp Labels
### Current State
Timestamp labels are rendered at `10px` font size in `drawTimeTicks()` in `renderer.ts`. They are small and hard to read.
### Proposed Changes
1. **Increase base font size** from `10px` to `12px`.
2. **Mouse-proximity magnification effect:** As the cursor moves along the timeline, labels near the cursor smoothly scale up. This creates an invisible "magnifying lens" effect for timestamps.
### Magnification Behavior
- **Radius:** ~80px from cursor (in canvas pixel space).
- **Peak size:** ~18px directly under the cursor.
- **Falloff:** Smooth cosine interpolation from peak to base size. A label 80px away renders at base size; a label directly under the cursor renders at peak size.
- **Vertical alignment:** Labels stay bottom-aligned as they grow — the text baseline remains constant, so larger labels grow upward. This prevents layout jitter.
- **Cursor leaves timeline:** When the mouse leaves the canvas (`onmouseleave`), `mouseX` is set to `null` and all labels render at the base size. No magnification in this state.
### Implementation Notes
- `Timeline.svelte` already handles `onmousemove`. Add tracking of the mouse X coordinate (a simple `let mouseX = $state<number | null>(null)`), updated on `mousemove` and cleared on `mouseleave`.
- Pass `mouseX` into `drawTimeline()` → `drawTimeTicks()`.
- `drawTimeTicks()` computes the per-label font size based on distance from `mouseX`:
```
distance = abs(labelX - mouseX)
if distance > MAGNIFY_RADIUS: scale = 0
else: scale = (1 + cos(PI * distance / MAGNIFY_RADIUS)) / 2
fontSize = BASE_FONT_SIZE + BONUS_FONT_SIZE * scale
```
- Trigger a redraw whenever `mouseX` changes. Since `requestDraw()` already coalesces via `requestAnimationFrame`, this adds no extra frame overhead — it simply piggybacks on the next scheduled frame.
---
## Feature 3: Timeline Right-Click Context Menu
### Current State
Right-clicking the timeline shows the default web/Tauri context menu (Reload, Inspect Element), which is not useful.
### Proposed: Custom Context Menu
A styled context menu appears on right-click anywhere on the timeline canvas. Right-clicking also moves the playhead to the clicked position.
### Menu Items
| # | Label | Behavior | Availability |
|---|-------|----------|-------------|
| 1 | **Mark In** | Sets in-point at the right-clicked time (calls `markInPoint`) | Always |
| 2 | **Mark Out** | Sets out-point at the right-clicked time (calls `markOutPoint`) | Always |
| — | *(separator)* | | |
| 3 | **Go to Clip Start** | Seeks playhead to `selectedClip.startTime` | Disabled if no clip selected |
| 4 | **Go to Clip End** | Seeks playhead to `selectedClip.endTime` | Disabled if no clip selected |
| — | *(separator)* | | |
| 5 | **Delete Clip** | Removes the selected clip | Hidden if no clip selected |
### Behavior
- **Right-click on canvas:** `contextmenu` event is intercepted, default is prevented. The playhead moves to the clicked time position. The context menu appears at the cursor's screen coordinates.
- **Selecting an item:** Executes the action and closes the menu.
- **Closing without action:** Click anywhere outside the menu, press Escape, or scroll — the menu closes.
- **Positioning:** The menu is rendered as a positioned `<div>` appended at the `App.svelte` level (or uses a portal pattern) so it isn't clipped by any `overflow: hidden` containers. If the menu would extend beyond the window edge, it flips direction (e.g. opens leftward or upward).
### Component: `TimelineContextMenu.svelte`
Props:
- `x: number` — screen X position
- `y: number` — screen Y position
- `time: number` — the timeline time at the right-click position
- `selectedClipId: string | null` — to determine which items are enabled/hidden
- `onAction: (action: ContextMenuAction) => void` — callback for menu item selection
- `onClose: () => void` — callback to close the menu
Actions (discriminated union):
```typescript
type ContextMenuAction =
| { type: 'mark-in' }
| { type: 'mark-out' }
| { type: 'go-to-clip-start' }
| { type: 'go-to-clip-end' }
| { type: 'delete-clip' };
```
### Styling
- Dark theme matching the app's existing style (dark background, light text, hover highlights).
- Subtle border/shadow to float above the timeline.
- Disabled items: dimmed text, no hover effect, not clickable.
- Separators: thin horizontal lines using `var(--border)`.
### Implementation Notes
- `Timeline.svelte` adds an `oncontextmenu` handler on the canvas element.
- The handler computes the time from the click X coordinate (using existing `computeClickTime`), seeks the playhead, and emits an event / sets state to show the context menu.
- The context menu component is rendered conditionally in `Timeline.svelte` (or `App.svelte` if clipping is an issue).
- Keyboard shortcut hints can optionally be shown in the menu items (e.g. "Mark In I") for discoverability.
---
## Version Bump
Execute `scripts/bump-version.sh` after incrementing `VERSION`.
Confirm all locations updated from `0.1.2` → `0.1.3`:
- `package.json` — `version` field
- `src-tauri/tauri.conf.json` — `version` field
- `src-tauri/Cargo.toml` — `version` field
- `grep`/`find` for remaining `0.1.2` references, fix any, and add to `scripts/bump-version.sh`
---
## Scope Boundaries
- No changes to the backend (Rust). All three features are purely frontend.
- No changes to clip store logic — existing `markInPoint`, `markOutPoint`, `removeClip`, `selectClip` functions are reused as-is.
- No new dependencies.
- The context menu does NOT appear on the minimap — only on the main timeline canvas.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "gui-video-clipper",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gui-video-clipper",
"version": "0.1.2",
"version": "0.1.3",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",

View File

@@ -1,6 +1,6 @@
{
"name": "gui-video-clipper",
"version": "0.1.2",
"version": "0.1.3",
"description": "macOS GUI video clipper (Tauri + Svelte)",
"type": "module",
"scripts": {

2
src-tauri/Cargo.lock generated
View File

@@ -1281,7 +1281,7 @@ dependencies = [
[[package]]
name = "gui-video-clipper"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"axum",
"dirs",

View File

@@ -1,6 +1,6 @@
[package]
name = "gui-video-clipper"
version = "0.1.2"
version = "0.1.3"
description = "A macOS GUI app for clipping online videos"
authors = ["cottongin"]
edition = "2021"

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GUI Video Clipper",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "xyz.cottongin.gui-video-clipper",
"build": {
"beforeDevCommand": "npm run dev",
@@ -13,8 +13,8 @@
"windows": [
{
"title": "GUI Video Clipper",
"width": 800,
"height": 600
"width": 1100,
"height": 750
}
],
"security": {

View File

@@ -10,7 +10,7 @@
import ExportDialog from '$lib/components/ExportDialog.svelte';
import AboutDialog from '$lib/components/AboutDialog.svelte';
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
import { loadPreferences } from '$lib/stores/preferences.svelte';
import { loadPreferences, preferences, setClipListPosition, setClipListWidth } from '$lib/stores/preferences.svelte';
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
import { session } from '$lib/stores/videoSession.svelte';
@@ -30,7 +30,13 @@
let isResizing = $state(false);
let resizeStartY = $state(0);
let resizeStartHeight = $state(0);
let splitAreaEl = $state<HTMLDivElement | null>(null);
let isSidebarResizing = $state(false);
let sidebarResizeStartX = $state(0);
let sidebarResizeStartWidth = $state(0);
let windowWidth = $state(window.innerWidth);
let isLeft = $derived(preferences.clipListPosition === 'left');
$effect(() => {
loadPreferences();
@@ -63,10 +69,9 @@
}
function handleResizeMove(e: MouseEvent) {
if (!isResizing || !splitAreaEl) return;
if (!isResizing) return;
const delta = e.clientY - resizeStartY;
const totalHeight = splitAreaEl.clientHeight;
const newHeight = Math.max(40, Math.min(totalHeight - 20, resizeStartHeight + delta));
const newHeight = Math.max(40, Math.min(500, resizeStartHeight + delta));
timelineHeight = newHeight;
}
@@ -74,6 +79,34 @@
isResizing = false;
}
function toggleClipListPosition() {
const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom';
setClipListPosition(next);
}
function handleSidebarResizeStart(e: MouseEvent) {
e.preventDefault();
isSidebarResizing = true;
sidebarResizeStartX = e.clientX;
sidebarResizeStartWidth = preferences.clipListWidth;
}
function handleSidebarResizeMove(e: MouseEvent) {
if (!isSidebarResizing) return;
const delta = e.clientX - sidebarResizeStartX;
const maxWidth = Math.round(windowWidth * 0.4);
const minWidth = Math.min(150, maxWidth);
const newWidth = Math.max(minWidth, Math.min(maxWidth, sidebarResizeStartWidth + delta));
preferences.clipListWidth = newWidth;
}
function handleSidebarResizeEnd() {
if (isSidebarResizing) {
isSidebarResizing = false;
setClipListWidth(preferences.clipListWidth);
}
}
function handleGlobalKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
@@ -156,8 +189,17 @@
<svelte:window
onkeydown={handleGlobalKeydown}
onmousemove={handleResizeMove}
onmouseup={handleResizeEnd}
onmousemove={(e) => {
handleResizeMove(e);
handleSidebarResizeMove(e);
}}
onmouseup={() => {
handleResizeEnd();
handleSidebarResizeEnd();
}}
onresize={() => {
windowWidth = window.innerWidth;
}}
/>
{#if showSetupWizard}
@@ -183,23 +225,67 @@
<div class="app-shell">
<header class="toolbar">
<UrlInput />
<button
class="layout-btn"
onclick={toggleClipListPosition}
title={preferences.clipListPosition === 'bottom'
? 'Move clip list to sidebar'
: 'Move clip list to bottom'}
>
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
</button>
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
</header>
<main class="content">
<VideoPlayer />
<TransportControls bind:this={transportControls} />
<div class="timeline-clip-area" bind:this={splitAreaEl}>
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
<main class="content" class:layout-left={isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
<!-- Sidebar clip list: visible only in left mode -->
<div class="sidebar-area" class:hidden={!isLeft}>
<ClipList onExport={() => (showExportDialog = true)} position="left" />
</div>
<div
class="sidebar-handle"
class:hidden={!isLeft}
class:active={isSidebarResizing}
role="slider"
aria-orientation="vertical"
aria-label="Resize clip list sidebar"
aria-valuenow={preferences.clipListWidth}
aria-valuemin={150}
aria-valuemax={Math.round(windowWidth * 0.4)}
tabindex="0"
onmousedown={handleSidebarResizeStart}
onkeydown={(e) => {
const maxWidth = Math.round(windowWidth * 0.4);
const minWidth = Math.min(150, maxWidth);
if (e.key === 'ArrowRight') {
e.preventDefault();
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
);
}
}}
></div>
<!-- Video + Transport: always rendered, never destroyed -->
<div class="video-area">
<VideoPlayer />
<TransportControls bind:this={transportControls} />
</div>
<!-- Timeline section: always full width -->
<div class="lower-section">
<div
class="resize-handle"
class:active={isResizing}
role="slider"
aria-orientation="horizontal"
aria-label="Resize timeline and clip list"
aria-label="Resize timeline"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
@@ -215,10 +301,15 @@
}
}}
></div>
<div class="cliplist-pane">
<ClipList onExport={() => (showExportDialog = true)} />
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
</div>
<!-- Bottom clip list: visible only in bottom mode -->
<div class="bottom-cliplist" class:hidden={isLeft}>
<ClipList onExport={() => (showExportDialog = true)} />
</div>
</main>
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
@@ -240,20 +331,78 @@
gap: 8px;
}
/* --- Grid layout: bottom mode (default) --- */
.content {
flex: 1;
display: flex;
flex-direction: column;
display: grid;
grid-template-areas:
"video"
"lower"
"bottom-clips";
grid-template-columns: 1fr;
grid-template-rows: 1fr auto auto;
overflow: hidden;
}
.timeline-clip-area {
.video-area {
grid-area: video;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.lower-section {
grid-area: lower;
display: flex;
flex-direction: column;
flex-shrink: 0;
min-height: 80px;
}
.bottom-cliplist {
grid-area: bottom-clips;
min-height: 20px;
overflow: hidden;
}
/* Sidebar elements hidden in bottom mode */
.sidebar-area {
grid-area: sidebar;
overflow-y: auto;
overflow-x: hidden;
border-right: 1px solid var(--border);
background: var(--bg-secondary);
}
.sidebar-handle {
grid-area: handle;
width: 5px;
background: var(--border);
cursor: ew-resize;
flex-shrink: 0;
transition: background 0.1s;
}
.sidebar-handle:hover,
.sidebar-handle.active {
background: var(--accent);
}
.hidden {
display: none !important;
}
/* --- Grid layout: left mode --- */
.content.layout-left {
grid-template-areas:
"sidebar handle video"
"lower lower lower"
"bottom-clips bottom-clips bottom-clips";
grid-template-columns: var(--sidebar-width, 220px) 5px 1fr;
grid-template-rows: 1fr auto 0;
}
.timeline-pane {
flex-shrink: 0;
min-height: 40px;
@@ -273,10 +422,9 @@
background: var(--accent, #89b4fa);
}
.cliplist-pane {
flex: 1;
min-height: 20px;
overflow: hidden;
.layout-btn {
font-size: 16px;
padding: 4px 8px;
}
.about-btn {

View File

@@ -4,22 +4,26 @@
selectClip,
removeClip,
updateClip,
type Clip,
} from '$lib/stores/clips.svelte';
import { formatTime, parseTime } from '$lib/utils/time';
import { session } from '$lib/stores/videoSession.svelte';
import { seekTo } from '$lib/transport/playback';
let { onExport }: { onExport?: () => void } = $props();
let {
onExport,
position = 'bottom',
}: { onExport?: () => void; position?: 'bottom' | 'left' } = $props();
let clips = $derived(clipStore.clips);
let selectedId = $derived(clipStore.selectedClipId);
let isSidebar = $derived(position === 'left');
function handleSelect(id: string) {
selectClip(id);
const clip = clips.find((c) => c.id === id);
if (clip) {
session.currentTime = clip.startTime;
const videoEl = document.querySelector('video');
if (videoEl) videoEl.currentTime = clip.startTime;
seekTo(clip.startTime);
}
}
@@ -36,15 +40,29 @@
function handleContainerClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Only deselect if the click landed on the container itself, not a child
if (target.classList.contains('clip-list')) {
selectClip(null);
}
}
function handlePlayClip(clip: Clip) {
seekTo(clip.startTime);
const videoEl = document.querySelector('video');
if (videoEl?.paused) videoEl.play();
}
function handleGoToStart(clip: Clip) {
seekTo(clip.startTime);
}
function handleGoToEnd(clip: Clip) {
seekTo(clip.endTime);
}
</script>
<div
class="clip-list"
class:sidebar={isSidebar}
role="listbox"
tabindex="0"
onclick={handleContainerClick}
@@ -64,6 +82,7 @@
<div
class="clip-row"
class:selected={clip.id === selectedId}
class:sidebar={isSidebar}
role="option"
aria-selected={clip.id === selectedId}
tabindex="0"
@@ -75,42 +94,124 @@
}
}}
>
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<button
class="delete-btn"
onclick={(e) => {
e.stopPropagation();
removeClip(clip.id);
}}
title="Delete clip"
>✕</button>
{#if isSidebar}
<!-- Sidebar (vertical card) layout -->
<div class="clip-card">
<div class="card-row">
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input sidebar-label"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
</div>
<div class="card-row times">
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
</div>
{#if clip.id === selectedId}
<div class="clip-actions">
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
title="Play clip"
>▶</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
title="Go to start"
>|◀</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
title="Go to end"
>▶|</button>
<button
class="action-btn danger"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
</div>
{/if}
</div>
{:else}
<!-- Bottom (wide row) layout -->
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
{#if clip.id === selectedId}
<div class="clip-actions inline">
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
title="Play clip"
>▶</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
title="Go to start"
>|◀</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
title="Go to end"
>▶|</button>
<button
class="action-btn danger"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
</div>
{:else}
<button
class="delete-btn"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
{/if}
{/if}
</div>
{/each}
<div class="action-buttons">
<div class="list-actions">
{#if selectedId}
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
{/if}
@@ -132,6 +233,12 @@
box-sizing: border-box;
}
.clip-list.sidebar {
border-top: none;
height: 100%;
padding: 8px 6px;
}
.empty {
color: var(--text-muted);
font-size: 13px;
@@ -146,7 +253,7 @@
margin-bottom: 4px;
}
.action-buttons {
.list-actions {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
@@ -154,7 +261,7 @@
gap: 8px;
}
.action-buttons button {
.list-actions button {
font-size: 13px;
padding: 6px 12px;
}
@@ -164,6 +271,7 @@
background: var(--bg-tertiary);
}
/* --- Bottom (wide) row layout --- */
.clip-row {
display: flex;
align-items: center;
@@ -186,6 +294,32 @@
background: var(--bg-tertiary);
}
/* --- Sidebar (vertical card) layout --- */
.clip-row.sidebar {
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 6px;
}
.clip-card {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.card-row {
display: flex;
align-items: center;
gap: 6px;
}
.card-row.times {
justify-content: center;
}
/* --- Shared elements --- */
.color-swatch {
width: 12px;
height: 12px;
@@ -202,6 +336,12 @@
padding: 2px 4px;
}
.label-input.sidebar-label {
flex: 1;
width: auto;
min-width: 0;
}
.label-input:focus {
background: var(--bg-primary);
outline: 1px solid var(--accent);
@@ -230,6 +370,44 @@
font-size: 12px;
}
/* --- Clip action buttons --- */
.clip-actions {
display: flex;
gap: 4px;
justify-content: center;
padding-top: 4px;
}
.clip-actions.inline {
padding-top: 0;
margin-left: auto;
flex-shrink: 0;
}
.action-btn {
font-size: 12px;
padding: 2px 6px;
color: var(--text-secondary);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
line-height: 1.2;
}
.action-btn:hover {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
.action-btn.danger:hover {
background: var(--danger);
color: var(--bg-primary);
border-color: var(--danger);
}
/* Legacy delete button for unselected clips in bottom mode */
.delete-btn {
font-size: 14px;
padding: 2px 6px;

View File

@@ -1,11 +1,16 @@
<script lang="ts">
import TimelineContextMenu from '$lib/components/TimelineContextMenu.svelte';
import type { ContextMenuAction } from '$lib/components/TimelineContextMenu.svelte';
import { session } from '$lib/stores/videoSession.svelte';
import {
clipStore,
selectClip,
updateClip,
markInPoint,
markOutPoint,
removeClip,
} from '$lib/stores/clips.svelte';
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
import { drawTimeline, type TimelineState, computeLanes } from '$lib/timeline/renderer';
import {
computeZoom,
panBy,
@@ -36,6 +41,12 @@
let isMinimapDragging = $state(false);
let minimapDragStartX = $state(0);
let minimapDragStartVisibleStart = $state(0);
let mouseX = $state<number | null>(null);
let contextMenu = $state<{
x: number;
y: number;
time: number;
} | null>(null);
let isZoomed = $derived(timelineState.zoom > 1.01);
@@ -84,7 +95,8 @@
clipStore.pendingInPoint,
waveformData,
session.thumbnailSpritesheets,
session.status === 'ready'
session.status === 'ready',
mouseX
);
ctx.restore();
}
@@ -154,6 +166,7 @@
void timelineState.width;
void timelineState.height;
void canvas;
void mouseX;
requestDraw();
});
@@ -232,11 +245,62 @@
}
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
const x = getCanvasX(e);
const time = computeClickTime(x, timelineState, session.duration);
seekTo(time);
contextMenu = {
x: e.clientX,
y: e.clientY,
time,
};
}
function handleContextMenuAction(action: ContextMenuAction) {
if (!contextMenu) return;
switch (action.type) {
case 'mark-in':
markInPoint(contextMenu.time);
break;
case 'mark-out':
markOutPoint(contextMenu.time);
break;
case 'go-to-clip-start': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.startTime);
break;
}
case 'go-to-clip-end': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.endTime);
break;
}
case 'delete-clip':
if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId);
break;
case 'play-clip': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) {
seekTo(clip.startTime);
const videoEl = document.querySelector('video');
if (videoEl?.paused) videoEl.play();
}
break;
}
default: {
const _exhaustive: never = action;
void _exhaustive;
}
}
}
function handleMouseDown(e: MouseEvent) {
contextMenu = null;
const x = getCanvasX(e);
const y = getCanvasY(e);
const hit = hitTestClip(x, y, timelineState, clipStore.clips);
const hit = hitTestClip(x, y, timelineState, clipStore.clips, computeLanes(timelineState.height));
if (hit) {
if (hit.edge === 'body') {
selectClip(hit.clipId);
@@ -284,8 +348,18 @@
dragTarget = null;
}
function handleMouseTrack(e: MouseEvent) {
mouseX = getCanvasX(e);
}
function handleMouseLeave() {
handleMouseUp();
mouseX = null;
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
contextMenu = null;
if (e.deltaX !== 0) {
const result = panBy(timelineState, e.deltaX, session.duration);
@@ -376,14 +450,31 @@
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmousemove={(e) => {
handleMouseMove(e);
handleMouseTrack(e);
}}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
onmouseleave={handleMouseLeave}
onwheel={handleWheel}
oncontextmenu={handleContextMenu}
></canvas>
</div>
</div>
{#if contextMenu}
<TimelineContextMenu
x={contextMenu.x}
y={contextMenu.y}
time={contextMenu.time}
selectedClipId={clipStore.selectedClipId}
onAction={handleContextMenuAction}
onClose={() => {
contextMenu = null;
}}
/>
{/if}
<svelte:window
onmousemove={handleMinimapMouseMove}
onmouseup={handleMinimapMouseUp}

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { formatTime } from '$lib/utils/time';
export type ContextMenuAction =
| { type: 'mark-in' }
| { type: 'mark-out' }
| { type: 'go-to-clip-start' }
| { type: 'go-to-clip-end' }
| { type: 'play-clip' }
| { type: 'delete-clip' };
let {
x,
y,
time,
selectedClipId,
onAction,
onClose,
}: {
x: number;
y: number;
time: number;
selectedClipId: string | null;
onAction: (action: ContextMenuAction) => void;
onClose: () => void;
} = $props();
let menuEl = $state<HTMLDivElement | null>(null);
function handleAction(action: ContextMenuAction) {
onAction(action);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
function handleClickOutside(e: MouseEvent) {
if (menuEl && !menuEl.contains(e.target as Node)) {
onClose();
}
}
let adjustedX = $state(0);
let adjustedY = $state(0);
$effect(() => {
const posX = x;
const posY = y;
if (!menuEl) {
adjustedX = posX;
adjustedY = posY;
return;
}
const rect = menuEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let newX = posX;
let newY = posY;
if (posX + rect.width > vw) {
newX = posX - rect.width;
}
if (posY + rect.height > vh) {
newY = posY - rect.height;
}
adjustedX = Math.max(0, newX);
adjustedY = Math.max(0, newY);
});
</script>
<svelte:window
onmousedown={handleClickOutside}
onkeydown={handleKeydown}
/>
<div
class="context-menu"
bind:this={menuEl}
style="left: {adjustedX}px; top: {adjustedY}px;"
role="menu"
>
<div class="menu-header">{formatTime(time)}</div>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-in' })}
>
<span class="label">Mark In</span>
<span class="shortcut">I</span>
</button>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-out' })}
>
<span class="label">Mark Out</span>
<span class="shortcut">O</span>
</button>
<div class="separator"></div>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-start' })}
>
<span class="label">Go to Clip Start</span>
</button>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-end' })}
>
<span class="label">Go to Clip End</span>
</button>
{#if selectedClipId}
<div class="separator"></div>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'play-clip' })}
>
<span class="label">Play Clip</span>
<span class="shortcut">▶</span>
</button>
<button
class="menu-item danger"
role="menuitem"
onclick={() => handleAction({ type: 'delete-clip' })}
>
<span class="label">Delete Clip</span>
<span class="shortcut">⌫</span>
</button>
{/if}
</div>
<style>
.context-menu {
position: fixed;
z-index: 1000;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 0;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
font-size: 13px;
}
.menu-header {
padding: 4px 12px 2px;
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
border-radius: 0;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.menu-item:hover:not(:disabled) {
background: var(--accent);
color: var(--bg-primary);
}
.menu-item:disabled {
color: var(--text-muted);
cursor: default;
opacity: 0.5;
}
.menu-item.danger:hover {
background: var(--danger);
color: var(--bg-primary);
}
.shortcut {
font-size: 11px;
color: var(--text-muted);
margin-left: 16px;
}
.menu-item:hover:not(:disabled) .shortcut {
color: inherit;
}
.separator {
height: 1px;
background: var(--border);
margin: 4px 0;
}
</style>

View File

@@ -135,6 +135,10 @@
function handleLoadedData() {
videoError = null;
// Restore playhead position (e.g. after layout toggle remounts the video)
if (videoElement && session.currentTime > 0) {
videoElement.currentTime = session.currentTime;
}
if (videoElement) {
console.log('[VideoPlayer] loadeddata:', {
src: videoElement.src,

View File

@@ -47,12 +47,16 @@ export interface Preferences {
outputDirectory: string;
cookieSource: CookieSource;
captionSettings: CaptionSettings;
clipListPosition: 'bottom' | 'left';
clipListWidth: number;
}
const DEFAULT_PREFERENCES: Preferences = {
outputDirectory: '~/',
cookieSource: { type: 'browser', browser: 'firefox' },
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
clipListPosition: 'bottom',
clipListWidth: 220,
};
let store: Store | null = null;
@@ -73,6 +77,10 @@ export async function loadPreferences(): Promise<void> {
if (savedDir) preferences.outputDirectory = savedDir;
if (savedCookie) preferences.cookieSource = savedCookie;
if (savedCaptions) preferences.captionSettings = { ...DEFAULT_CAPTION_SETTINGS, ...savedCaptions };
const savedClipListPosition = await store.get<'bottom' | 'left'>('clipListPosition');
const savedClipListWidth = await store.get<number>('clipListWidth');
if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition;
if (savedClipListWidth != null) preferences.clipListWidth = savedClipListWidth;
})();
await loadPromise;
}
@@ -85,6 +93,8 @@ export async function savePreferences(): Promise<void> {
await store.set('outputDirectory', preferences.outputDirectory);
await store.set('cookieSource', preferences.cookieSource);
await store.set('captionSettings', preferences.captionSettings);
await store.set('clipListPosition', preferences.clipListPosition);
await store.set('clipListWidth', preferences.clipListWidth);
await store.save();
}
@@ -102,3 +112,13 @@ export async function setCaptionSettings(settings: Partial<CaptionSettings>): Pr
Object.assign(preferences.captionSettings, settings);
await savePreferences();
}
export async function setClipListPosition(position: 'bottom' | 'left'): Promise<void> {
preferences.clipListPosition = position;
await savePreferences();
}
export async function setClipListWidth(width: number): Promise<void> {
preferences.clipListWidth = width;
await savePreferences();
}

View File

@@ -1,8 +1,6 @@
import type { Clip } from '$lib/stores/clips.svelte';
import { timeToX, type TimelineState } from './renderer';
import { timeToX, type TimelineState, type LaneLayout } from './renderer';
const CLIP_LANE_Y = 90;
const CLIP_LANE_HEIGHT = 30;
const HANDLE_WIDTH = 6;
export function drawClips(
@@ -10,24 +8,28 @@ export function drawClips(
state: TimelineState,
clips: Clip[],
selectedId: string | null,
pendingInPoint: number | null
pendingInPoint: number | null,
lanes: LaneLayout
): void {
const clipY = lanes.clipY;
const clipH = lanes.clipHeight;
for (const clip of clips) {
const x1 = timeToX(clip.startTime, state);
const x2 = timeToX(clip.endTime, state);
const isSelected = clip.id === selectedId;
ctx.fillStyle = clip.color + '66';
ctx.fillRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
ctx.fillRect(x1, clipY, x2 - x1, clipH);
ctx.strokeStyle = isSelected ? '#ffffff' : clip.color;
ctx.lineWidth = isSelected ? 2 : 1;
ctx.strokeRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
ctx.strokeRect(x1, clipY, x2 - x1, clipH);
ctx.lineWidth = 1;
ctx.fillStyle = isSelected ? '#ffffff' : clip.color;
ctx.fillRect(x1 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
ctx.fillRect(x2 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
ctx.fillRect(x1 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
ctx.fillRect(x2 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
const labelWidth = x2 - x1;
if (labelWidth > 40) {
@@ -37,7 +39,7 @@ export function drawClips(
ctx.fillText(
clip.label,
(x1 + x2) / 2,
CLIP_LANE_Y + CLIP_LANE_HEIGHT / 2 + 4,
clipY + clipH / 2 + 4,
labelWidth - 10
);
}
@@ -49,8 +51,8 @@ export function drawClips(
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(x, CLIP_LANE_Y);
ctx.lineTo(x, CLIP_LANE_Y + CLIP_LANE_HEIGHT);
ctx.moveTo(x, clipY);
ctx.lineTo(x, clipY + clipH);
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1;
@@ -66,9 +68,10 @@ export function hitTestClip(
x: number,
y: number,
state: TimelineState,
clips: Clip[]
clips: Clip[],
lanes: LaneLayout
): ClipHitResult | null {
if (y < CLIP_LANE_Y || y > CLIP_LANE_Y + CLIP_LANE_HEIGHT) {
if (y < lanes.clipY || y > lanes.clipY + lanes.clipHeight) {
return null;
}

View File

@@ -23,10 +23,40 @@ export function xToTime(x: number, state: TimelineState): number {
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;
const BASE_FONT_SIZE = 12;
const MAGNIFY_BONUS = 10;
const MAGNIFY_RADIUS = 100;
const CONTROLS_HEIGHT = 22;
const CLIP_HEIGHT = 20;
export interface LaneLayout {
thumbY: number;
thumbHeight: number;
waveformY: number;
waveformHeight: number;
clipY: number;
clipHeight: number;
controlsY: number;
controlsHeight: number;
}
export function computeLanes(totalHeight: number): LaneLayout {
const available = Math.max(0, totalHeight - CONTROLS_HEIGHT - CLIP_HEIGHT);
const thumbHeight = Math.max(20, Math.round(available * 0.55));
const waveformHeight = Math.max(15, available - thumbHeight);
return {
thumbY: 0,
thumbHeight,
waveformY: thumbHeight,
waveformHeight,
clipY: thumbHeight + waveformHeight,
clipHeight: CLIP_HEIGHT,
controlsY: totalHeight - CONTROLS_HEIGHT,
controlsHeight: CONTROLS_HEIGHT,
};
}
export function drawTimeline(
ctx: CanvasRenderingContext2D,
@@ -38,7 +68,8 @@ export function drawTimeline(
pendingInPoint: number | null = null,
waveform: WaveformData = { tiers: null },
thumbnailSpritesheets: ThumbnailSpritesheet[] = [],
hasVideo: boolean = false
hasVideo: boolean = false,
mouseX: number | null = null
): void {
const { width, height } = state;
ctx.clearRect(0, 0, width, height);
@@ -46,21 +77,23 @@ export function drawTimeline(
ctx.fillStyle = '#181825';
ctx.fillRect(0, 0, width, height);
drawTimeTicks(ctx, state, duration);
const lanes = computeLanes(height);
drawTimeTicks(ctx, state, duration, mouseX, lanes);
if (thumbnailSpritesheets.length > 0) {
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, 0, THUMB_LANE_HEIGHT);
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, lanes.thumbY, lanes.thumbHeight);
} else {
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails', hasVideo);
drawPlaceholderLane(ctx, state, lanes.thumbY, lanes.thumbHeight, 'Thumbnails', hasVideo);
}
if (waveform.tiers) {
drawWaveform(ctx, state, waveform, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
drawWaveform(ctx, state, waveform, duration, lanes.waveformY, lanes.waveformHeight);
} else {
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform', hasVideo);
drawPlaceholderLane(ctx, state, lanes.waveformY, lanes.waveformHeight, 'Waveform', hasVideo);
}
drawClips(ctx, state, clips, selectedClipId, pendingInPoint);
drawClips(ctx, state, clips, selectedClipId, pendingInPoint, lanes);
drawPlayhead(ctx, state, currentTime);
}
@@ -68,27 +101,66 @@ export function drawTimeline(
function drawTimeTicks(
ctx: CanvasRenderingContext2D,
state: TimelineState,
duration: number
duration: number,
mouseX: number | null,
lanes: LaneLayout
): 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';
const baselineY = lanes.controlsY + TICK_HEIGHT + 10;
// Draw minor ticks first (behind major ticks)
const minorInterval = tickInterval / 4;
const minorTickHeight = TICK_HEIGHT * 0.5;
for (let t = startTick - tickInterval; t <= state.visibleEnd + minorInterval; t += minorInterval) {
if (t < 0 || t > duration) continue;
if (Math.abs(t % tickInterval) < 0.001 || Math.abs(t % tickInterval - tickInterval) < 0.001) continue;
const x = timeToX(t, state);
ctx.strokeStyle = '#35354a';
ctx.beginPath();
ctx.moveTo(x, lanes.controlsY);
ctx.lineTo(x, lanes.controlsY + minorTickHeight);
ctx.stroke();
}
// Draw major ticks with labels
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.moveTo(x, lanes.controlsY);
ctx.lineTo(x, lanes.controlsY + TICK_HEIGHT);
ctx.stroke();
let fontSize = BASE_FONT_SIZE;
let scale = 0;
if (mouseX !== null) {
const distance = Math.abs(x - mouseX);
if (distance < MAGNIFY_RADIUS) {
scale = (1 + Math.cos((Math.PI * distance) / MAGNIFY_RADIUS)) / 2;
fontSize = BASE_FONT_SIZE + MAGNIFY_BONUS * scale;
}
}
// Interpolate color from muted (#6c7086) to bright (#cdd6f4)
if (scale > 0) {
const r = Math.round(108 + (205 - 108) * scale);
const g = Math.round(112 + (214 - 112) * scale);
const b = Math.round(134 + (244 - 134) * scale);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
} else {
ctx.fillStyle = '#6c7086';
}
ctx.font = `${Math.round(fontSize)}px -apple-system, sans-serif`;
const label = formatTickLabel(t);
ctx.fillText(label, x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 12);
ctx.fillText(label, x, baselineY);
}
}