33 Commits

Author SHA1 Message Date
cee840b225 chore: sync package-lock.json version to 0.2.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 20:49:42 -04:00
1b035c9af6 fix: use per-video caption cache dir to prevent cross-video caption bleed
Subtitle downloads were going to a shared /tmp/video-clipper/subtitles/ dir,
so find_vtt_file could return captions from a previously-loaded video. Now
uses cache_manager::caption_cache_dir (URL-hash-keyed) with pre-download
cleanup, matching the existing per-video caching pattern.

Bump to v0.2.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 06:00:43 -04:00
c1de18715e feat: v0.2.1 — Player controls polish, screenshot capture, quit shortcut
v0.2.0 introduced Player mode with floating controls, auto-hide UI,
PiP, fullscreen, JKL shuttle, speed selector, captions, and cut-clip.

v0.2.1 builds on that with extensive UI polish and new features:

Player Controls Polish:
- Enlarged center playback icons (Play 48, Rewind/FF 36) with filled SVGs
- Moved Cut Clip scissors into center group with hidden cut-before button
- Narrowed floating panel from 70% to 60% width
- Custom-styled volume slider matching seek bar (linear-gradient fill)
- Pure opacity fade animation (0.35s) replacing slide-in
- Tinted panel background (rgba(53,54,80,0.75)) visible on black canvas
- Drop shadow on center transport controls
- Scissors buttons dimmed via color (not opacity) with single-property transitions
- ScissorsLineDashed icon for cut-before variant
- Timestamps in single seek row (time | bar | duration) with sans-serif font
- Milliseconds shown at smaller size, revealed on hover

Screenshot Capture (S / Shift+S):
- Canvas compositing at native video resolution with optional caption burn-in
- Caption rendering matches CSS preview (font, background, outline, word highlight)
- New Rust save_screenshot command using existing filename pattern
- Output: {title} - {ms}ms-screenshot.png
- crossorigin=anonymous on video element for untainted canvas

Other:
- Q keyboard shortcut to quit via Tauri process plugin
- formatTimeParts() utility for split timestamp rendering
- Updated About dialog shortcuts list
- Version bump to 0.2.1

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 05:30:45 -04:00
619fe59dbe feat: timeline zooms to clip region on cut-clip from Player mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 03:18:18 -04:00
f6ad41f5fd fix: blur via visibility (no opacity), raise to 84px, enlarge center icons 50%
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 03:16:52 -04:00
d7a207154a fix: guard against zero-length clip at EOF
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:32:46 -04:00
507e3b79b7 feat: tabbed About dialog with keyboard shortcuts reference
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:29:31 -04:00
ac7cb70cb7 feat: cut clip from Player mode (X/Z keys, scissors button)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:27:21 -04:00
d964c97fe6 feat: C and P both toggle between Clipper/Player modes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:25:45 -04:00
be27b1b3c6 feat: replace emoji icons with Lucide SVG components
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:24:03 -04:00
646f2e511b fix: raise controls to 64px, smooth blur via ::before pseudo-element
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:21:56 -04:00
d8a6e0edd0 docs: v0.2.1 implementation plan — 5 tasks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 01:49:46 -04:00
21a5445143 docs: v0.2.1 design spec — polish, icons, cut clip, shortcuts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 01:42:50 -04:00
2e36d16bfe fix: QA round 2 — playhead guard, controls position, caption lift, Tauri fullscreen, timeline sync
- Guard drawPlayhead on session.duration > 0 in PlayerTimeline
- Move floating controls pill from bottom: 24px to 48px
- Add playerControlsVisible prop to VideoPlayer; captions slide up 160px when controls visible
- Replace web Fullscreen API with Tauri getCurrentWindow().setFullscreen() (WKWebView compat)
- Add core:window:allow-set-fullscreen and core:window:allow-is-fullscreen permissions
- Tie PlayerTimeline visibility to showPlayerControls; remove unused proximity logic

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 20:16:10 -04:00
0f2a523cc2 fix: QuickTime-style floating controls pill, speed selector, rate sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:46:46 -04:00
c00c215d33 fix: adjustShuttle reads live rate with 0.25 step, fullscreen targets documentElement
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:45:50 -04:00
980f31a414 fix: auto-hide timers, status bar, keyframe shortcuts in Player mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:44:48 -04:00
d847a0cff2 docs: v0.2.0 Player mode bugfixes implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:43:34 -04:00
0e84e66cf7 docs: v0.2.0 Player mode bugfixes design spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:39:51 -04:00
711f18ffe5 fix: add tabindex to seek bar slider role, remove dead handleVideoClick
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:16:04 -04:00
7e838dc391 chore: bump version to 0.2.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:13:18 -04:00
1a86a34152 feat: add FLIP transitions for Clipper/Player mode switching
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:12:26 -04:00
2472decb20 fix: bind captionsEnabled between App and VideoPlayer for Player mode CC sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:11:21 -04:00
3e219b7760 feat: conditional CC rendering and click-to-play in Player mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:10:43 -04:00
199ad1ce3b fix: transport shortcuts work in Player mode via runTransportAction fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:09:27 -04:00
d36138c550 feat: add Player/Clipper mode switching with auto-hide UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:08:25 -04:00
56686a36e6 feat: add PlayerTimeline simplified waveform component
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:06:32 -04:00
f717687b1b feat: add PlayerControls floating overlay component
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:05:03 -04:00
bb5fa4bd63 feat: add SpeedSelector popup component
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:03:46 -04:00
d9f58747a4 feat: add PiP, fullscreen, and playbackRate helpers to playback module
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:02:03 -04:00
bd96da4dc9 feat: add appMode preference (clipper/player) with persistence
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:00:34 -04:00
8bcec23d8c docs: add v0.2.0 player mode implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:58:00 -04:00
4f7458d8f8 docs: add v0.2.0 player mode design spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:51:01 -04:00
45 changed files with 7172 additions and 188 deletions

View File

@@ -1 +1 @@
0.1.3
0.2.2

View File

@@ -0,0 +1,48 @@
# v0.2.0 Player Mode — Design & Implementation Plan
**Date:** 2026-09-22 18:58
**Task:** Brainstorm and plan a "Player" mode for the video clipper app (v0.2.0)
## Changes Made
### Design Spec
- Created `docs/superpowers/specs/2026-09-22-v020-player-mode-design.md`
- 14 sections covering: state/preferences, keyboard shortcuts, layout, mode toggle, floating controls, speed selector, player timeline, toolbar auto-hide, PiP, fullscreen, transitions, file map, version bump
### Implementation Plan
- Created `docs/superpowers/plans/2026-09-22-v020-player-mode.md`
- 9 tasks with TDD steps, exact code, and commands:
1. Preferences store — `appMode` field
2. Playback helpers — PiP, fullscreen, playbackRate
3. SpeedSelector component
4. PlayerControls floating overlay
5. PlayerTimeline simplified waveform
6. App.svelte — mode switching, layout, auto-hide, shortcuts
7. VideoPlayer.svelte — conditional CC, click-to-play
8. FLIP transitions for mode switching
9. Version bump to 0.2.0
## Key Design Decisions
- **Architecture:** Single `App.svelte` with conditional rendering (not separate layout components or windows)
- **Video element preservation:** `<VideoPlayer>` always mounted outside conditionals — only surrounding chrome changes
- **PiP:** Web API (`requestPictureInPicture`) — uses native macOS PiP via WKWebView. Player mode only.
- **Fullscreen:** Standard webview fullscreen (`requestFullscreen` + webkit fallback). Player mode only.
- **Speed control:** Both JKL shuttle keys + visible speed selector popup in floating controls
- **Auto-hide:** Floating controls show on any mouse movement (2.5s timeout), toolbar on top-edge proximity (~50px), timeline on bottom-edge proximity (~80px). All visible when paused.
- **Transitions:** Svelte `fly`/`fade` directives + CSS grid transitions + manual FLIP on video container. ~300ms budget.
- **Mode persistence:** `appMode` saved to Tauri store, restored on relaunch
- **Shortcuts:** `P` → Player, `C` → Clipper, `F` → fullscreen (Player only). Clipper-only shortcuts (I/O/Delete/Cmd+E) become no-ops in Player mode. Frame-step (`,`/`.`) works in both modes.
## Follow-up Items
- Execute the implementation plan (9 tasks)
- Tasks 1, 2, 3 can be parallelized
- Task 6 is the largest (App.svelte layout overhaul) — depends on Tasks 1, 4, 5
- Task 8 (transitions) may need visual tuning after initial implementation
## Lessons Learned
- When conditionally rendering different layouts that share a component (like `<VideoPlayer>`), the component must live outside the `{#if}` branches to avoid Svelte destroying and recreating it on branch switch
- WKWebView supports the standard PiP API — no need for native AVPlayer FFI
- Grid template transitions work in modern browsers but require explicit `transition` properties on the grid container

View File

@@ -0,0 +1,73 @@
# v0.2.0 Player Mode — Implementation Summary
## Task Description
Implemented all 9 tasks from the v0.2.0 Player Mode implementation plan using subagent-driven development (SDD). This is the execution phase following the design spec and plan created in an earlier session.
## Changes Made
### 11 commits (bd96da4..7e838dc)
| Commit | Description |
|--------|-------------|
| `bd96da4` | Task 1: `appMode` preference (clipper/player) with persistence |
| `d9f5874` | Task 2: PiP, fullscreen, playbackRate helpers in playback module |
| `bb5fa4b` | Task 3: SpeedSelector popup component |
| `f717687` | Task 4: PlayerControls floating overlay (frosted glass, seek bar, all controls) |
| `56686a3` | Task 5: PlayerTimeline simplified waveform component |
| `d36138c` | Task 6: Player/Clipper mode switching with auto-hide UI in App.svelte |
| `199ad1c` | Task 6 fix: transport shortcuts fallback via `runTransportAction` for Player mode |
| `3e219b7` | Task 7: Conditional CC rendering, click-to-play, bindable captionsEnabled |
| `2472dec` | Task 7 fix: bind captionsEnabled between App and VideoPlayer |
| `1a86a34` | Task 8: FLIP transitions (fly/fade + CSS grid transition) |
| `7e838dc` | Task 9: Version bump 0.1.3 → 0.2.0 |
### Files (17 changed, +1327 −92)
**New files:**
- `src/lib/components/PlayerControls.svelte` — floating overlay with seek bar, play/pause, skip, volume, speed, CC, PiP, fullscreen
- `src/lib/components/PlayerTimeline.svelte` — simplified waveform-only timeline
- `src/lib/components/SpeedSelector.svelte` — playback speed popup (0.5x–2x)
- `tests/lib/components/SpeedSelector.test.ts` — 3 component tests
- `tests/lib/stores/preferences.test.ts` — 3 preference tests
- `tests/lib/transport/playback.test.ts` — 10 playback helper tests
**Modified files:**
- `src/App.svelte` — mode switching, auto-hide, keyboard shortcuts, layout conditionals
- `src/app.css` — grid transitions, player-mode positioning
- `src/lib/components/VideoPlayer.svelte` — bindable captionsEnabled, click-to-play, conditional CC
- `src/lib/stores/preferences.svelte.ts` — appMode field + persistence
- `src/lib/transport/playback.ts` — PiP, fullscreen, playbackRate exports
- `vite.config.ts` — svelteTesting() plugin for Svelte 5 component tests
## Key Architecture Decisions
1. **Single always-mounted VideoPlayer** — video element stays in DOM across mode switches, avoiding destruction/recreation
2. **Proximity-based auto-hide** — controls: any mouse move + 3s timeout; toolbar: 50px from top; timeline: 80px from bottom
3. **Transport fallback** — `dispatchTransport()` falls back to `runTransportAction()` when TransportControls is null (Player mode)
4. **CSS Grid transitions** — `.content` animates grid-template changes for smooth layout shifts
5. **Polling for PiP/fullscreen state** — PlayerControls polls `isPiPActive()`/`isFullscreenActive()` at 500ms
## Bugs Found & Fixed
1. **Transport shortcuts no-op in Player mode** — TransportControls not rendered → `transportControls` null → all playback shortcuts broken. Fixed with `runTransportAction` fallback.
2. **captionsEnabled not bound** — App.svelte and VideoPlayer had separate `captionsEnabled` states. Fixed with `bind:captionsEnabled`.
## Lessons Learned
- When a component that handles keyboard dispatch is conditionally rendered, always provide a fallback path for the keyboard handler
- Svelte 5 bindable props require explicit `$bindable()` wrapper and typed `$props()` destructuring
- Svelte 5 component testing in vitest/jsdom needs `svelteTesting()` plugin in vite config (not documented in the plan)
- Timer-based click/double-click discrimination (250ms delay) is the standard pattern for video players
## Test Results
- **8 test files, 47 tests passing**
- **svelte-check: 0 errors** (1 pre-existing a11y warning — missing tabindex on slider role)
## Follow-up Items
- Add `tabindex="0"` to PlayerControls seek bar slider role (a11y warning)
- Remove dead `handleVideoClick()` function in VideoPlayer.svelte (unused, superseded by `handleVideoClickWithDelay`)
- Consider event-based PiP/fullscreen state updates instead of 500ms polling
- Visual QA with `npm run tauri dev` for transition smoothness

View File

@@ -0,0 +1,42 @@
# v0.2.0 Player Mode Bug Fixes — Summary
## Task Description
Fixed 8 issues found during QA of the v0.2.0 Player mode implementation. Issues ranged from a Svelte 5 reactivity bug preventing auto-hide from working, to a WKWebView fullscreen incompatibility, to keyboard shortcut mismatches.
## Changes Made
### 3 commits (980f31a..0f2a523), 3 files modified
| Commit | Description |
|--------|-------------|
| `980f31a` | Auto-hide timer fix, status bar hidden, `<`/`>` keyframe shortcuts |
| `c00c215` | `adjustShuttle` reads live rate with 0.25 step, fullscreen targets `document.documentElement` |
| `0f2a523` | QuickTime-style floating pill layout, speed selector `stopPropagation`, rate sync polling |
### Issues Fixed
1. **Floating controls layout** — Redesigned from full-width bottom-pinned to centered floating pill (`bottom: 24px; left: 15%; right: 15%`). Reorganized: controls row (top) → seek bar (middle) → timestamps (bottom). Matches QuickTime Player layout.
2. **Controls visible when paused** — Removed special paused branch; auto-hide timer always runs regardless of play state.
3. **Controls/toolbar never auto-hide** — `controlsHideTimer` and `toolbarHideTimer` were `$state`, causing an infinite reactive loop in the `$effect`. Changed to plain `let`.
4. **Status bar visible** — Wrapped `<StatusBar>` in `{#if !isPlayerMode}`.
5. **JKL skips 1.0x** — Changed `adjustShuttle` to read live rate from video element (not stale `shuttleRate`) and use 0.25 step (was 0.5).
6. **Speed selector broken** — Added `e.stopPropagation()` on toggle button to prevent immediate close from window click handler.
7. **Fullscreen broken** — Changed `toggleFullscreen()` to target `document.documentElement` instead of video element (WKWebView doesn't support element-level fullscreen API).
8. **Shift+,/. keyframe shortcuts** — Added `<` and `>` key cases (Shift produces these characters, not `,`/`.`).
## Lessons Learned
- **Svelte 5 `$state` in timer variables creates reactive loops**: If an `$effect` reads a `$state` timer ID to clear it, then writes a new one, Svelte re-triggers the effect infinitely. Timer IDs used only in imperative logic should be plain `let`.
- **Keyboard `e.key` values change with Shift**: `Shift+,` produces `<`, not `,`. Always check the actual key value produced.
- **WKWebView fullscreen**: `HTMLVideoElement.requestFullscreen()` doesn't work; must target `document.documentElement.requestFullscreen()` instead.
- **Click-outside handlers and same-click toggles**: A `<svelte:window onclick>` handler fires on the same click event that mounted the component, causing immediate close. Use `stopPropagation` on the toggle button.
## Test Results
- **8 test files, 54 tests passing** (7 new tests for `adjustShuttle`)
- **svelte-check: 0 errors, 0 warnings**
## Follow-up
- Visual QA with `npm run tauri dev` to verify all 8 fixes work as expected

View File

@@ -0,0 +1,55 @@
# v0.2.1 — Polish, Icons, Cut Clip & Shortcuts Reference
## Task Description
Design, plan, and implement v0.2.1 of the GUI Video Clipper app. Six items: raise floating controls position, replace emoji icons with Lucide SVGs, fix blur pop-in animation, make C/P shortcuts bidirectional toggles, add "cut clip" feature from Player mode, and add keyboard shortcuts reference in a tabbed About dialog.
## Changes Made
### Commits (d8a6e0e → d7a2071)
1. **`646f2e5`** — `fix: raise controls to 64px, smooth blur via ::before pseudo-element`
- `src/lib/components/PlayerControls.svelte`: Changed `bottom: 48px` → `64px`, moved `background` + `backdrop-filter` from `.player-controls-panel` to a `::before` pseudo-element with its own opacity transition
2. **`be27b1b`** — `feat: replace emoji icons with Lucide SVG components`
- Installed `@lucide/svelte` dependency
- `src/lib/components/PlayerControls.svelte`: Replaced 🔇/🔉/🔊, ⏪, ❚❚/▶, ⏩, CC, ⧉, ⛶/⤓ with Lucide components (VolumeX/Volume1/Volume2, Rewind, Pause/Play, FastForward, Captions, PictureInPicture2, Maximize/Minimize)
- `src/lib/components/TransportControls.svelte`: Replaced ◄K/K►, ◄|/|►, ❚❚/▶, 🔇/🔉/🔊 with ChevronFirst/ChevronLast, SkipBack/SkipForward, Pause/Play, VolumeX/Volume1/Volume2
- `src/App.svelte`: Replaced ⬒/⬓, ℹ, ⚙ with PanelLeft/PanelBottom, Info, Settings
3. **`d964c97`** — `feat: C and P both toggle between Clipper/Player modes`
- `src/App.svelte`: Combined `'p'/'P'/'c'/'C'` cases to all call `toggleMode()`
4. **`ac7cb70`** — `feat: cut clip from Player mode (X/Z keys, scissors button)`
- Created `src/lib/utils/clipRange.ts` with `computeQuickClipRange()` utility
- Created `tests/lib/utils/clipRange.test.ts` (8 tests)
- `src/lib/components/PlayerControls.svelte`: Added scissors button with `onCutClip` prop
- `src/App.svelte`: Added `cutClip()` function, `X`/`Z` keyboard shortcuts (Player mode only)
5. **`507e3b7`** — `feat: tabbed About dialog with keyboard shortcuts reference`
- Rewrote `src/lib/components/AboutDialog.svelte` with "About" / "Shortcuts" tabs
- Created `tests/lib/components/AboutDialog.test.ts` (5 tests)
- Dialog dynamically sizes: ~360px for About, ~520px for Shortcuts
6. **`d7a2071`** — `fix: guard against zero-length clip at EOF`
- `src/lib/utils/clipRange.ts`: Added `start >= end` guard returning `null`
- Added test for `computeQuickClipRange(120, 120, 'at')` → `null`
## Test Results
- **69/69 tests passing** across 10 test files
- **svelte-check**: 0 errors, 0 warnings
## Follow-Up Items
- Remaining non-Lucide glyphs in ClipList, VideoPlayer, TimelineContextMenu (visual consistency for v0.2.2)
- Icon-only buttons could benefit from `aria-label` for screen reader accessibility
- Shortcuts reference in AboutDialog is static — could extract to shared source of truth with keyboard handler
- No `role="tabpanel"` / `aria-controls` wiring on About dialog tabs (full WAI-ARIA tabs pattern)
## Lessons Learned
- **WebKit `backdrop-filter` + `opacity` compositing**: WebKit doesn't smoothly composite `backdrop-filter` through an ancestor `opacity` transition. The fix is to isolate the blur into a `::before` pseudo-element with its own opacity transition.
- **`@lucide/svelte` is the official package name** (not `lucide-svelte`). Uses tree-shakable named imports: `import { Play } from '@lucide/svelte'`.
- **Svelte component testing text collisions**: When testing components with duplicate visible text (e.g., "Play / Pause" appears twice in a shortcuts table), use `getAllByText` or `getByRole` with name filters instead of `getByText`.
- **Edge case: zero-length clips**: When clamping clip ranges to video bounds, always guard against `start >= end` after clamping to prevent zero-duration clips.

View File

@@ -0,0 +1,28 @@
# v0.2.1 Fixes: Blur, Icon Sizing, Timeline Zoom
## Task Description
Three fixes for the v0.2.1 release: (1) fix the backdrop blur pop-in animation in Player mode floating controls, raise controls another 20px; (2) enlarge center playback icons by 50%; (3) zoom timeline to clip region when using cut-clip from Player mode.
## Changes Made
### Commits
1. **`f6ad41f`** — `fix: blur via visibility (no opacity), raise to 84px, enlarge center icons 50%`
- `src/lib/components/PlayerControls.svelte`:
- **Blur fix**: Restructured CSS so the overlay no longer transitions `opacity` at all. The `::before` blur pseudo-element uses `visibility: hidden/visible` (instant toggle, always `opacity: 1` when visible) so WebKit never has to composite `backdrop-filter` during an opacity transition. A new `.panel-content` wrapper handles the fade+slide animation (`opacity 0->1`, `translateY(6px)->0`) independently.
- **Position**: `bottom: 64px` -> `bottom: 84px`
- **Icon sizes**: Play/Pause `size={18}` -> `size={27}`, Rewind/FastForward `size={16}` -> `size={24}`, play button `min-width: 40px` -> `48px`
2. **`619fe59`** — `feat: timeline zooms to clip region on cut-clip from Player mode`
- `src/lib/components/Timeline.svelte`: Added `zoomRegion` and `onZoomApplied` optional props. Added a second `$effect` (after the duration-reset effect) that zooms to the given region with 20% padding (min 2s) on each side.
- `src/App.svelte`: Added `pendingTimelineZoom` state. `cutClip()` sets it to the created clip's range before switching to Clipper mode. Passed `zoomRegion={pendingTimelineZoom}` and `onZoomApplied` callback to `<Timeline>`.
## Test Results
- 69/69 tests passing, svelte-check 0 errors/0 warnings
## Lessons Learned
- **WebKit `backdrop-filter` + `opacity` compositing**: The `::before` pseudo-element approach from the previous attempt still failed because ANY opacity transition (even on the pseudo-element itself, not just ancestors) prevents WebKit from compositing `backdrop-filter` during the animation. The only reliable fix is to NEVER transition opacity on the element that has `backdrop-filter`. Use `visibility: hidden/visible` for instant show/hide of the blur layer, and animate content separately.
- **Svelte 5 `$effect` ordering**: When two effects run on mount, the one declared later wins for overlapping state mutations. This is leveraged for the timeline zoom — the duration-reset effect runs first (sets full view), then the zoom effect runs second and overrides if `zoomRegion` is set.

View File

@@ -0,0 +1,44 @@
# Player Controls Polish — Round 2
**Date:** 2026-09-23 07:56 UTC
**Task:** Five UI polish changes to the floating PlayerControls panel
## Changes Made
All changes in `src/lib/components/PlayerControls.svelte`.
### 1. Enlarged center playback icons by 50%
- Play/Pause: 27 -> 40
- Rewind/FastForward: 24 -> 36
- `.ctrl-btn.play-btn` min-width: 48px -> 64px
### 2. Reorganized Cut Clip buttons into center group
- Moved the "cut at playhead" Scissors button from the right settings group into the center playback group, placed after FastForward. Icon enlarged to 36 to match.
- Added a new "cut before" Scissors button to the left of Rewind. It is hidden by default (`width: 0; opacity: 0; pointer-events: none`) and reveals on `.controls-group.center:hover` with a smooth transition. Appears at 0.7 opacity, full opacity on direct hover.
- Removed the old Scissors button and its right-click handler from the right group.
- New center group order: [Cut-before (hidden)] [Rewind] [Play/Pause] [FastForward] [Cut-at]
### 3. Narrowed floating panel by ~15%
- Overlay insets changed from `left: 15%; right: 15%` (70% width) to `left: 20%; right: 20%` (60% width).
### 4. Restyled volume slider to match seek bar
- Replaced native `accent-color` styling with `appearance: none` custom CSS.
- Track uses transparent background with JS-driven `linear-gradient` inline style showing accent color for the filled portion and `rgba(255,255,255,0.2)` for the dormant portion.
- Thumb is a 10px circle matching seek bar thumb, hidden by default and shown on hover.
- Added `volumePercent` derived state to compute the gradient stop value.
### 5. Replaced slide animation with pure fade
- Removed `transform: translateY(6px)` from `.panel-content` and its `translateY(0)` visible state.
- Changed transition to `opacity 0.35s ease` only (was `opacity 0.2s ease, transform 0.2s ease`).
- Blur `::before` pseudo-element now fades with `opacity 0.35s ease` instead of instant `visibility` toggle. Uses `visibility 0s 0.35s` delay on hide and `visibility 0s 0s` on show to keep pointer-events correct while avoiding the flash.
## Verification
- `svelte-check`: 0 errors, 0 warnings
- `vitest run`: 69 tests passed across 10 test files
## Lessons Learned
- WebKit's `backdrop-filter` still renders correctly with `opacity` transitions as long as the blur layer itself isn't being destroyed/recreated. The `visibility` + delayed `transition` pattern keeps the composited blur layer alive during fade.
- Using `linear-gradient` as an inline style on range inputs is the cleanest way to show a filled/unfilled track without shadow DOM hacks, since WebKit's `::-webkit-slider-runnable-track` doesn't support `background` in scoped Svelte styles as reliably.
## Follow-up Items
- None identified. Visual QA recommended to confirm feel of 0.35s fade timing and hidden cut-before button hover UX.

View File

@@ -0,0 +1,48 @@
# Quit + Screenshot Shortcuts
**Date:** 2026-09-23 09:08 UTC
**Task:** Add Q to quit, S/Shift+S to save screenshot with/without captions, update About dialog shortcuts
## Changes Made
### Rust backend
- **`src-tauri/src/commands/export.rs`** — Added `save_screenshot` Tauri command. Accepts output directory, video title, timestamp label, and PNG byte data. Uses the existing `generate_output_path` and `expand_tilde_path` from `clip_exporter` for consistent filename generation. Creates output directory if it doesn't exist.
- **`src-tauri/src/lib.rs`** — Registered `export::save_screenshot` in the invoke handler.
### Frontend bindings
- **`src/lib/bindings/export.ts`** — Added `saveScreenshot()` function that invokes the new Rust command.
### Screenshot utility
- **`src/lib/utils/screenshot.ts`** (new file) — Canvas-based screenshot capture:
- Draws the current video frame onto an offscreen canvas at native resolution (`videoWidth` x `videoHeight`)
- Optionally composites captions using Canvas 2D text APIs, matching the CSS caption style (font, color, background, outline/shadow, word highlighting, position)
- Scales caption font size proportionally from preview to native resolution
- Exports as PNG blob, converts to byte array, sends to Rust backend
- Filename format: `{videoTitle} - {totalMs}ms-screenshot.png`
### VideoPlayer
- **`src/lib/components/VideoPlayer.svelte`** — Added `export function getParsedCues()` to expose parsed VTT cues to the parent component.
### App keybindings
- **`src/App.svelte`** — Added:
- `Q` — Quit app via `exit(0)` from `@tauri-apps/plugin-process`
- `S` — Screenshot with captions
- `Shift+S` — Screenshot without captions
- `bind:this={videoPlayerRef}` on VideoPlayer for accessing `getParsedCues()`
- `captureScreenshot(withCaptions)` async function that orchestrates the capture
### About dialog
- **`src/lib/components/AboutDialog.svelte`** — Added Q, S, and Shift+S to the global shortcuts list.
## Verification
- `svelte-check`: 0 errors, 0 warnings
- `vitest run`: 69 tests passed across 10 test files
- `cargo check`: compiles clean
## Lessons Learned
- Canvas 2D text rendering requires explicit scaling when compositing at native resolution vs. CSS preview resolution. Using `videoHeight / clientHeight` as the scale factor ensures caption size matches visually.
- The `toBlob` → `arrayBuffer` → `Array.from(Uint8Array)` pipeline is necessary because Tauri's IPC expects `Vec<u8>` on the Rust side, which maps to `number[]` in TypeScript invoke calls.
## Follow-up Items
- Visual QA recommended to verify caption rendering fidelity on screenshots matches the preview exactly (especially text wrapping and word-level highlighting).
- Could add a brief toast notification on successful screenshot save (currently only logs to console).

View File

@@ -0,0 +1,50 @@
# Fix: Stale Captions Persisting Across Different Videos
**Date:** 2026-09-23
**Task:** Fix bug where captions from a previously-loaded video were displayed for a newly-loaded video.
## Problem
When switching between different YouTube videos, the app would display captions from the first video instead of the current one. The bug manifested because:
1. **Shared flat directory** — All subtitle downloads for every video went to the same temp directory: `/tmp/video-clipper/subtitles/`. Old `.vtt` files from previous videos persisted there.
2. **Non-specific file lookup** — `find_vtt_file()` in `subtitle_downloader.rs` scanned the directory and returned the first `.en.vtt` file it found, regardless of which video it belonged to.
3. **Cache amplification** — Once the wrong caption path was returned, `save_analysis_to_cache` copied that wrong `.vtt` file into the URL-keyed analysis cache, persisting the error across sessions.
## Root Cause
In `src-tauri/src/commands/video.rs`, the `download_subtitles` command used a shared temp directory for all videos:
```rust
let output_dir = std::env::temp_dir()
.join("video-clipper")
.join("subtitles")
.to_string_lossy()
.to_string();
```
This ignored the existing `cache_manager::caption_cache_dir(url)` function that provides a per-video, URL-hash-keyed cache directory — the same scheme used by thumbnail caching and the analysis cache.
## Changes Made
### `src-tauri/src/commands/video.rs`
- Added import for `cache_manager` from services
- Changed `download_subtitles` to use `cache_manager::caption_cache_dir(&url)` instead of the shared temp directory
- Added pre-download cleanup: removes any existing `.vtt` files in the directory before downloading, ensuring `find_vtt_file` can only return a file from the current download
## What Was NOT Changed
- `subtitle_downloader.rs` — already correctly accepts `output_dir` as a parameter, no changes needed
- `cache_manager.rs` — `caption_cache_dir()` already existed and was correctly implemented
- Frontend stores — `setMetadata()` already resets `captionFilePath` to `null` on video switch
## Important Note
Existing corrupted cache entries (from before this fix) will continue serving wrong captions until the user clears the cache or uses "Reprocess" for that video. The analysis cache copies the caption file into its own directory, so even though the source is now fixed, old bad copies persist.
## Lessons Learned
- When a system has per-URL cache keying (URL hash), all file outputs should use it — not just thumbnails and analysis data. The subtitle download was an oversight where the temp dir pattern diverged from the caching pattern.
- `find_vtt_file` scanning a directory without filtering by video identity is inherently fragile. The real fix is directory isolation (one dir per video), not smarter filename matching.
- Testing with only a single video will never surface cross-video state contamination bugs. Multi-video test scenarios should be part of the test plan.
## Follow-up Items
- Consider cleaning up the old shared `/tmp/video-clipper/subtitles/` directory on app startup
- Consider adding a migration or auto-invalidation for cached analysis entries that reference caption files from the wrong video

View File

@@ -0,0 +1,730 @@
# v0.2.0 Player Mode Bug Fixes — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 8 issues found during QA of the v0.2.0 Player mode: auto-hide broken, controls layout wrong, status bar visible, JKL skips 1x, speed selector broken, fullscreen broken, keyframe shortcuts broken.
**Architecture:** All fixes target existing files — no new files. Three main areas: App.svelte (auto-hide + keyboard), playback.ts (shuttle + fullscreen), PlayerControls.svelte (layout redesign + speed selector).
**Tech Stack:** Svelte 5 (runes), TypeScript, Vitest
## Global Constraints
- All imports at top of file (no inline imports).
- Exhaustive switch with `never` default for TypeScript unions/enums.
- Existing tests must continue to pass (`npm test`).
- Zero errors AND zero warnings from `npx svelte-check --tsconfig ./tsconfig.json`.
---
### Task 1: App.svelte — Auto-Hide + Status Bar + Keyboard Fixes
**Files:**
- Modify: `src/App.svelte`
**Interfaces:**
- Consumes: `isPlayerMode` (existing derived), `session.isPlaying` (existing store), `dispatchTransport` (existing function)
- Produces: Working auto-hide for controls and toolbar, hidden status bar in Player mode, working `<`/`>` keyframe shortcuts
This task fixes issues 2, 3, 4, and 8 from the spec.
- [ ] **Step 1: Fix timer variables — remove `$state`**
In `src/App.svelte`, find these two lines (around lines 54-55):
```typescript
let controlsHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
let toolbarHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
```
Replace with plain `let` (no reactivity — these are only used in imperative timer logic):
```typescript
let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
let toolbarHideTimer: ReturnType<typeof setTimeout> | null = null;
```
- [ ] **Step 2: Simplify the auto-hide `$effect`**
Find the `$effect` block that checks `session.isPlaying` (around lines 85-96):
```typescript
$effect(() => {
if (!isPlayerMode) return;
if (!session.isPlaying) {
// Video paused — show everything
showPlayerControls = true;
showToolbar = true;
if (controlsHideTimer) clearTimeout(controlsHideTimer);
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
} else {
// Video playing — start hide timers
resetControlsTimer();
}
});
```
Replace with:
```typescript
$effect(() => {
if (!isPlayerMode) return;
// Read play state so this re-runs on play/pause changes —
// controls briefly show then auto-hide regardless of play state
void session.isPlaying;
resetControlsTimer();
});
```
- [ ] **Step 3: Update `resetControlsTimer` — always start timer**
Find `resetControlsTimer` (around line 152):
```typescript
function resetControlsTimer() {
if (controlsHideTimer) clearTimeout(controlsHideTimer);
showPlayerControls = true;
if (session.isPlaying) {
controlsHideTimer = setTimeout(() => {
showPlayerControls = false;
}, CONTROLS_HIDE_DELAY);
}
}
```
Replace with (remove the `if (session.isPlaying)` guard):
```typescript
function resetControlsTimer() {
if (controlsHideTimer) clearTimeout(controlsHideTimer);
showPlayerControls = true;
controlsHideTimer = setTimeout(() => {
showPlayerControls = false;
}, CONTROLS_HIDE_DELAY);
}
```
- [ ] **Step 4: Update `resetToolbarTimer` — always start timer**
Find `resetToolbarTimer` (around line 162):
```typescript
function resetToolbarTimer() {
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
showToolbar = true;
if (session.isPlaying) {
toolbarHideTimer = setTimeout(() => {
showToolbar = false;
}, CONTROLS_HIDE_DELAY);
}
}
```
Replace with:
```typescript
function resetToolbarTimer() {
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
showToolbar = true;
toolbarHideTimer = setTimeout(() => {
showToolbar = false;
}, CONTROLS_HIDE_DELAY);
}
```
- [ ] **Step 5: Hide status bar in Player mode**
Find the StatusBar line (around line 463):
```svelte
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
```
Wrap it:
```svelte
{#if !isPlayerMode}
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
{/if}
```
- [ ] **Step 6: Fix keyframe shortcuts — add `<` and `>` cases**
In `handleGlobalKeydown`, find the `,` and `.` cases (around lines 212-219):
```typescript
case ',':
e.preventDefault();
dispatchTransport(e.shiftKey ? 'keyframe-back' : 'frame-back');
break;
case '.':
e.preventDefault();
dispatchTransport(e.shiftKey ? 'keyframe-forward' : 'frame-forward');
break;
```
Replace with (remove the now-unreachable shiftKey ternary, add separate `<`/`>` cases):
```typescript
case ',':
e.preventDefault();
dispatchTransport('frame-back');
break;
case '.':
e.preventDefault();
dispatchTransport('frame-forward');
break;
case '<':
e.preventDefault();
dispatchTransport('keyframe-back');
break;
case '>':
e.preventDefault();
dispatchTransport('keyframe-forward');
break;
```
- [ ] **Step 7: Verify**
Run: `npx svelte-check --tsconfig ./tsconfig.json`
Expected: 0 errors, 0 warnings
Run: `npm test`
Expected: All tests pass
- [ ] **Step 8: Commit**
```bash
git add src/App.svelte
git commit -m "fix: auto-hide timers, status bar, keyframe shortcuts in Player mode"
```
---
### Task 2: Playback Helpers — adjustShuttle + Fullscreen
**Files:**
- Modify: `src/lib/transport/playback.ts`
- Modify: `tests/lib/transport/playback.test.ts`
- Modify: `src/App.svelte` (call sites only)
**Interfaces:**
- Consumes: `getVideo()` (internal), `document.documentElement` (DOM)
- Produces:
- `adjustShuttle(dir: 1 | -1): number` — reads rate from video element, steps by 0.25, returns new rate
- `toggleFullscreen(): void` — targets `document.documentElement` instead of video element
- [ ] **Step 1: Write updated tests for `adjustShuttle`**
In `tests/lib/transport/playback.test.ts`, add this import at the top (with the existing imports):
```typescript
import {
setVideoElement,
togglePiP,
isPiPActive,
toggleFullscreen,
isFullscreenActive,
setPlaybackRate,
getPlaybackRate,
adjustShuttle,
} from '$lib/transport/playback';
```
Add a new describe block at the end of the file:
```typescript
describe('playback — adjustShuttle', () => {
let mockVideo: HTMLVideoElement;
beforeEach(() => {
mockVideo = createMockVideoElement();
setVideoElement(mockVideo);
mockVideo.playbackRate = 1;
mockVideo.paused = true;
Object.defineProperty(mockVideo, 'paused', {
value: true,
writable: true,
configurable: true,
});
mockVideo.play = vi.fn().mockResolvedValue(undefined);
});
it('increases rate by 0.25 when dir is 1', () => {
const result = adjustShuttle(1);
expect(result).toBe(1.25);
expect(mockVideo.playbackRate).toBe(1.25);
});
it('decreases rate by 0.25 when dir is -1', () => {
const result = adjustShuttle(-1);
expect(result).toBe(0.75);
expect(mockVideo.playbackRate).toBe(0.75);
});
it('reads current rate from video element, not external state', () => {
mockVideo.playbackRate = 0.75;
const result = adjustShuttle(1);
expect(result).toBe(1);
});
it('clamps to minimum 0.25', () => {
mockVideo.playbackRate = 0.25;
const result = adjustShuttle(-1);
expect(result).toBe(0.25);
});
it('clamps to maximum 4', () => {
mockVideo.playbackRate = 4;
const result = adjustShuttle(1);
expect(result).toBe(4);
});
it('starts playback if paused', () => {
adjustShuttle(1);
expect(mockVideo.play).toHaveBeenCalled();
});
it('returns 1 when no video element', () => {
setVideoElement(null);
expect(adjustShuttle(1)).toBe(1);
});
});
```
- [ ] **Step 2: Run tests to verify new tests fail**
Run: `npm test`
Expected: `adjustShuttle` tests fail because the current signature is `adjustShuttle(dir, shuttleRate)` — calling with one arg will use `undefined` for `shuttleRate`.
- [ ] **Step 3: Update `adjustShuttle` implementation**
In `src/lib/transport/playback.ts`, find (around line 87):
```typescript
export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
const videoEl = getVideo();
if (!videoEl) return shuttleRate;
const nextRate = Math.max(0.25, Math.min(4, shuttleRate + dir * 0.5));
videoEl.playbackRate = nextRate;
if (videoEl.paused) {
void videoEl.play();
}
return nextRate;
}
```
Replace with:
```typescript
export function adjustShuttle(dir: 1 | -1): number {
const videoEl = getVideo();
if (!videoEl) return 1;
const current = videoEl.playbackRate;
const nextRate = Math.max(0.25, Math.min(4, current + dir * 0.25));
videoEl.playbackRate = nextRate;
if (videoEl.paused) {
void videoEl.play();
}
return nextRate;
}
```
- [ ] **Step 4: Update App.svelte call sites**
In `src/App.svelte`, find the J/L key handlers (around lines 266-277):
```typescript
case 'j':
case 'J':
e.preventDefault();
shuttleRate = adjustShuttle(-1, shuttleRate);
break;
```
Replace with:
```typescript
case 'j':
case 'J':
e.preventDefault();
shuttleRate = adjustShuttle(-1);
break;
```
And:
```typescript
case 'l':
case 'L':
e.preventDefault();
shuttleRate = adjustShuttle(1, shuttleRate);
break;
```
Replace with:
```typescript
case 'l':
case 'L':
e.preventDefault();
shuttleRate = adjustShuttle(1);
break;
```
- [ ] **Step 5: Update fullscreen tests**
In `tests/lib/transport/playback.test.ts`, find the fullscreen test (around line 89):
```typescript
it('toggleFullscreen calls requestFullscreen when not fullscreen', () => {
toggleFullscreen();
expect(mockVideo.requestFullscreen).toHaveBeenCalled();
});
```
Replace with:
```typescript
it('toggleFullscreen calls requestFullscreen on document.documentElement', () => {
const spy = vi.fn().mockResolvedValue(undefined);
document.documentElement.requestFullscreen = spy;
toggleFullscreen();
expect(spy).toHaveBeenCalled();
});
```
- [ ] **Step 6: Update `toggleFullscreen` implementation**
In `src/lib/transport/playback.ts`, find `toggleFullscreen` (around line 118):
```typescript
export function toggleFullscreen(): void {
if (document.fullscreenElement) {
document.exitFullscreen().catch((e) => {
console.error('Exit fullscreen failed:', e);
});
} else {
const videoEl = getVideo();
if (!videoEl) return;
if (videoEl.requestFullscreen) {
videoEl.requestFullscreen().catch((e) => {
console.error('Fullscreen request failed:', e);
});
} else if ((videoEl as HTMLVideoElement & { webkitEnterFullscreen?: () => void }).webkitEnterFullscreen) {
(videoEl as HTMLVideoElement & { webkitEnterFullscreen: () => void }).webkitEnterFullscreen();
}
}
}
```
Replace with:
```typescript
export function toggleFullscreen(): void {
if (document.fullscreenElement) {
document.exitFullscreen().catch((e) => {
console.error('Exit fullscreen failed:', e);
});
} else {
const el = document.documentElement;
if (el.requestFullscreen) {
el.requestFullscreen().catch((e) => {
console.error('Fullscreen request failed:', e);
});
} else if ((el as HTMLElement & { webkitRequestFullscreen?: () => void }).webkitRequestFullscreen) {
(el as HTMLElement & { webkitRequestFullscreen: () => void }).webkitRequestFullscreen();
}
}
}
```
- [ ] **Step 7: Run tests**
Run: `npm test`
Expected: All tests pass (existing + new adjustShuttle tests)
Run: `npx svelte-check --tsconfig ./tsconfig.json`
Expected: 0 errors, 0 warnings
- [ ] **Step 8: Commit**
```bash
git add src/lib/transport/playback.ts tests/lib/transport/playback.test.ts src/App.svelte
git commit -m "fix: adjustShuttle reads live rate with 0.25 step, fullscreen targets documentElement"
```
---
### Task 3: PlayerControls — QuickTime Layout + Speed Selector + Rate Sync
**Files:**
- Modify: `src/lib/components/PlayerControls.svelte`
**Interfaces:**
- Consumes: `getPlaybackRate()` from `$lib/transport/playback` (already imported), `session` from videoSession store
- Produces: QuickTime-style floating pill layout, working speed selector popup, live rate sync from video element
This task fixes issues 1 and 6 from the spec. The speed selector `stopPropagation` fix (issue 6) is included in the new markup in Step 2.
- [ ] **Step 1: Add rate sync to the polling `$effect`**
Find the polling effect (around line 132):
```typescript
// Sync PiP/fullscreen state periodically
$effect(() => {
const interval = setInterval(() => {
pipActive = isPiPActive();
fullscreenActive = isFullscreenActive();
}, 500);
return () => clearInterval(interval);
});
```
Add `currentRate` sync:
```typescript
// Sync PiP/fullscreen/rate state periodically
$effect(() => {
const interval = setInterval(() => {
pipActive = isPiPActive();
fullscreenActive = isFullscreenActive();
currentRate = getPlaybackRate();
}, 500);
return () => clearInterval(interval);
});
```
- [ ] **Step 2: Reorganize markup — controls row first, seek bar second, timestamps third**
Replace the entire content inside `<div class="player-controls-panel">` (everything between the opening and closing tags of `.player-controls-panel`).
The current order is: seek bar → controls row.
New order with QuickTime layout:
```svelte
<div class="player-controls-panel">
<!-- Controls row (top) -->
<div class="controls-row">
<!-- Left group: volume -->
<div class="controls-group left">
<button
class="ctrl-btn vol-btn"
onclick={handleToggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
type="button"
>
{#if isMuted || volume === 0}🔇{:else if volume < 0.5}🔉{:else}🔊{/if}
</button>
<input
type="range"
class="vol-slider"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
oninput={handleVolumeChange}
/>
</div>
<!-- Center group: playback -->
<div class="controls-group center">
<button
class="ctrl-btn"
onclick={() => seekBy(-10)}
title="Skip back 10s"
type="button"
>⏪</button>
<button
class="ctrl-btn play-btn"
onclick={togglePlayPause}
title={session.isPlaying ? 'Pause' : 'Play'}
type="button"
>{session.isPlaying ? '❚❚' : '▶'}</button>
<button
class="ctrl-btn"
onclick={() => seekBy(10)}
title="Skip forward 10s"
type="button"
>⏩</button>
</div>
<!-- Right group: settings -->
<div class="controls-group right">
<div class="speed-wrapper">
<button
class="ctrl-btn"
onclick={(e) => { e.stopPropagation(); showSpeedSelector = !showSpeedSelector; }}
title="Playback speed"
type="button"
>{currentRate}×</button>
{#if showSpeedSelector}
<SpeedSelector
{currentRate}
onSelect={handleSpeedSelect}
onClose={() => { showSpeedSelector = false; }}
/>
{/if}
</div>
{#if hasCaptions}
<button
class="ctrl-btn"
class:active={captionsEnabled}
onclick={onToggleCaptions}
oncontextmenu={handleCCContextMenu}
title={captionsEnabled ? 'Hide captions' : 'Show captions'}
type="button"
>CC</button>
{/if}
<button
class="ctrl-btn"
class:active={pipActive}
onclick={handlePiP}
title="Picture-in-Picture"
type="button"
>⧉</button>
<button
class="ctrl-btn"
class:active={fullscreenActive}
onclick={handleFullscreen}
title={fullscreenActive ? 'Exit fullscreen' : 'Fullscreen'}
type="button"
>{fullscreenActive ? '⤓' : '⛶'}</button>
</div>
</div>
<!-- Seek bar (middle) -->
<div
class="seek-bar"
bind:this={seekBarEl}
onmousedown={handleSeekBarMouseDown}
onmousemove={handleSeekBarMouseMove}
onmouseup={handleSeekBarMouseUp}
onmouseleave={handleSeekBarMouseLeave}
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuenow={session.currentTime}
aria-valuemin={0}
aria-valuemax={session.duration}
>
<div class="seek-track">
<div class="seek-fill" style="width: {progress * 100}%"></div>
<div class="seek-thumb" style="left: {progress * 100}%"></div>
</div>
{#if hoverTime !== null}
<div class="seek-tooltip" style="left: {hoverX}px">
{formatTime(hoverTime)}
</div>
{/if}
</div>
<!-- Timestamps (bottom) -->
<div class="timestamps-row">
<span class="time-display">{formatTime(session.currentTime)}</span>
<span class="time-display">{formatTime(session.duration)}</span>
</div>
</div>
```
- [ ] **Step 3: Update CSS — floating pill positioning**
Replace the `.player-controls-overlay` CSS:
```css
.player-controls-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease 0.1s;
z-index: 20;
}
```
With:
```css
.player-controls-overlay {
position: absolute;
bottom: 24px;
left: 15%;
right: 15%;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease 0.1s;
z-index: 20;
}
```
- [ ] **Step 4: Update CSS — pill border-radius**
Replace the `.player-controls-panel` border-radius:
```css
border-radius: 12px 12px 0 0;
```
With:
```css
border-radius: 12px;
```
- [ ] **Step 5: Add timestamps-row CSS**
Add this new rule in the `<style>` block (after the `.time-display` rule):
```css
.timestamps-row {
display: flex;
justify-content: space-between;
padding: 0 4px;
}
```
Remove the `.time-sep` rule (no longer used) and update `.time-display` to remove `white-space: nowrap` if present (it's fine to keep).
Also remove the `.controls-group.center` old time-display styles. The center group now holds playback buttons, not time. Update center group:
```css
.controls-group.center {
flex: 0 0 auto;
}
```
(This is unchanged — it already handles playback buttons correctly since they have fixed sizes.)
- [ ] **Step 6: Verify**
Run: `npx svelte-check --tsconfig ./tsconfig.json`
Expected: 0 errors, 0 warnings
Run: `npm test`
Expected: All tests pass
- [ ] **Step 7: Commit**
```bash
git add src/lib/components/PlayerControls.svelte
git commit -m "fix: QuickTime-style floating controls pill, speed selector, rate sync"
```
---
## Task Dependency Graph
```
Task 1 (App.svelte: auto-hide + status bar + keys) ──→ Task 2 (playback.ts + App.svelte call sites) ──→ Task 3 (PlayerControls layout)
```
Tasks are sequential because Tasks 1 and 2 both modify `src/App.svelte`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,994 @@
# v0.2.1 — Polish, Icons, Cut Clip & Shortcuts Reference Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship v0.2.1 with Lucide icons, smoother blur animation, cut-clip from Player mode, and a keyboard shortcuts reference in the About dialog.
**Architecture:** All changes are frontend-only. A new utility function handles quick-clip range calculation. The About dialog gains a tabbed interface. Lucide replaces all emoji/text-glyph icons. CSS pseudo-element fixes the backdrop blur transition.
**Tech Stack:** Svelte 5, `@lucide/svelte`, Vitest, Tauri v2
## Global Constraints
- Svelte 5 with runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`)
- `@lucide/svelte` for icons — import from `@lucide/svelte` (tree-shakable named imports)
- All imports at top of file, no inline imports
- Exhaustive switch with `never` check in default case for discriminated unions
- Zero `svelte-check` errors and warnings after every task
- All existing tests must pass after every task (`npm test`)
---
### Task 1: Floating Controls Position Bump + Blur Fix
**Files:**
- Modify: `src/lib/components/PlayerControls.svelte` (CSS section, lines ~278–302)
**Interfaces:**
- Consumes: nothing new
- Produces: visual-only changes — no API changes
- [ ] **Step 1: Raise the controls overlay**
In `src/lib/components/PlayerControls.svelte`, change `bottom: 48px` to `bottom: 64px` in `.player-controls-overlay`:
```css
.player-controls-overlay {
position: absolute;
bottom: 64px;
left: 15%;
right: 15%;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease 0.1s;
z-index: 20;
}
```
- [ ] **Step 2: Move blur to ::before pseudo-element**
Remove `background` and `backdrop-filter` from `.player-controls-panel`. Add `position: relative` and a `::before` pseudo-element that handles the blur background. The pseudo-element's opacity is driven by the `.visible` class on the ancestor overlay.
Replace the `.player-controls-panel` rule (currently around lines 294–302):
```css
.player-controls-panel {
pointer-events: auto;
position: relative;
border-radius: 12px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.player-controls-panel::before {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 12px;
z-index: -1;
opacity: 0;
transition: opacity 0.25s ease;
}
.player-controls-overlay.visible .player-controls-panel::before {
opacity: 1;
}
```
- [ ] **Step 3: Verify**
Run:
```bash
npx svelte-check --tsconfig ./tsconfig.json
npm test
```
Expected: 0 errors, 0 warnings, all tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/lib/components/PlayerControls.svelte
git commit -m "fix: raise controls to 64px, smooth blur via ::before pseudo-element"
```
---
### Task 2: Lucide Icon Migration
**Files:**
- Modify: `src/lib/components/PlayerControls.svelte` (script + template)
- Modify: `src/lib/components/TransportControls.svelte` (script + template)
- Modify: `src/App.svelte` (toolbar buttons)
**Interfaces:**
- Consumes: nothing new
- Produces: visual-only changes — all props/events unchanged
- [ ] **Step 1: Install @lucide/svelte**
```bash
npm install @lucide/svelte
```
- [ ] **Step 2: Replace icons in PlayerControls.svelte**
Add Lucide imports at the top of the `<script>` block:
```typescript
import {
VolumeX,
Volume1,
Volume2,
Rewind,
FastForward,
Pause,
Play,
Scissors,
Captions,
PictureInPicture2,
Maximize,
Minimize,
} from '@lucide/svelte';
```
Replace each emoji/text glyph in the template:
| Location | Old | New |
|---|---|---|
| Mute button (line ~161) | `{#if isMuted \|\| volume === 0}🔇{:else if volume < 0.5}🔉{:else}🔊{/if}` | `{#if isMuted \|\| volume === 0}<VolumeX size={16} />{:else if volume < 0.5}<Volume1 size={16} />{:else}<Volume2 size={16} />{/if}` |
| Skip back (line ~181) | `⏪` | `<Rewind size={16} />` |
| Play/Pause (line ~187) | `{session.isPlaying ? '❚❚' : '▶'}` | `{#if session.isPlaying}<Pause size={18} />{:else}<Play size={18} />{/if}` |
| Skip forward (line ~193) | `⏩` | `<FastForward size={16} />` |
| CC button (line ~222) | `CC` | `<Captions size={16} />` |
| PiP button (line ~231) | `⧉` | `<PictureInPicture2 size={16} />` |
| Fullscreen button (line ~239) | `{fullscreenActive ? '⤓' : '⛶'}` | `{#if fullscreenActive}<Minimize size={16} />{:else}<Maximize size={16} />{/if}` |
- [ ] **Step 3: Replace icons in TransportControls.svelte**
Add Lucide imports at the top of the `<script>` block:
```typescript
import {
ChevronFirst,
ChevronLast,
SkipBack,
SkipForward,
Pause,
Play,
VolumeX,
Volume1,
Volume2,
} from '@lucide/svelte';
```
Replace each glyph in the template:
| Location | Old | New |
|---|---|---|
| Previous keyframe button | `◄K` | `<ChevronFirst size={16} />` |
| -5s button | `-5s` (keep as text) | `-5s` (no change — text labels are fine here) |
| -1s button | `-1s` (keep as text) | `-1s` (no change) |
| Previous frame button | `◄\|` | `<SkipBack size={16} />` |
| Play/Pause button (line ~59) | `{session.isPlaying ? '❚❚' : '▶'}` | `{#if session.isPlaying}<Pause size={16} />{:else}<Play size={16} />{/if}` |
| Next frame button | `\|►` | `<SkipForward size={16} />` |
| +1s button | `+1s` (keep as text) | `+1s` (no change) |
| +5s button | `+5s` (keep as text) | `+5s` (no change) |
| Next keyframe button | `K►` | `<ChevronLast size={16} />` |
| Mute button (line ~88–93) | `{#if isMuted \|\| volume === 0}🔇{:else if volume < 0.5}🔉{:else}🔊{/if}` | `{#if isMuted \|\| volume === 0}<VolumeX size={14} />{:else if volume < 0.5}<Volume1 size={14} />{:else}<Volume2 size={14} />{/if}` |
- [ ] **Step 4: Replace icons in App.svelte toolbar**
Add Lucide imports at the top of the `<script>` block (alongside existing imports):
```typescript
import {
PanelLeft,
PanelBottom,
Info,
Settings,
} from '@lucide/svelte';
```
Replace toolbar button contents:
| Location | Old | New |
|---|---|---|
| Layout toggle (line ~357) | `{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}` | `{#if preferences.clipListPosition === 'bottom'}<PanelLeft size={16} />{:else}<PanelBottom size={16} />{/if}` |
| About button (line ~360) | `ℹ` | `<Info size={16} />` |
| Prefs button (line ~361) | `⚙` | `<Settings size={16} />` |
- [ ] **Step 5: Verify**
Run:
```bash
npx svelte-check --tsconfig ./tsconfig.json
npm test
```
Expected: 0 errors, 0 warnings, all tests pass.
- [ ] **Step 6: Commit**
```bash
git add package.json package-lock.json src/lib/components/PlayerControls.svelte src/lib/components/TransportControls.svelte src/App.svelte
git commit -m "feat: replace emoji icons with Lucide SVG components"
```
---
### Task 3: Mode Toggle Shortcuts
**Files:**
- Modify: `src/App.svelte` (handleGlobalKeydown, lines ~220–234)
**Interfaces:**
- Consumes: existing `toggleMode()` function in App.svelte
- Produces: behavioral change only — both C and P now call `toggleMode()`
- [ ] **Step 1: Update key handlers**
In `handleGlobalKeydown` in `src/App.svelte`, replace the `'p'/'P'` and `'c'/'C'` cases. Both should call `toggleMode()` and handle the Player mode auto-show:
```typescript
case 'p':
case 'P':
case 'c':
case 'C':
e.preventDefault();
toggleMode();
break;
```
The existing `toggleMode()` function already handles showing controls when entering Player mode, so no additional logic is needed.
- [ ] **Step 2: Verify**
Run:
```bash
npx svelte-check --tsconfig ./tsconfig.json
npm test
```
Expected: 0 errors, 0 warnings, all tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/App.svelte
git commit -m "feat: C and P both toggle between Clipper/Player modes"
```
---
### Task 4: Cut Clip Feature
**Files:**
- Create: `src/lib/utils/clipRange.ts`
- Create: `tests/lib/utils/clipRange.test.ts`
- Modify: `src/App.svelte` (handleGlobalKeydown + cutClip function + PlayerControls props)
- Modify: `src/lib/components/PlayerControls.svelte` (add scissors button + onCutClip prop)
**Interfaces:**
- Consumes: `addClip(start, end)` from `$lib/stores/clips.svelte`, `setAppMode()` from `$lib/stores/preferences.svelte`, `session.currentTime` and `session.duration` from `$lib/stores/videoSession.svelte`
- Produces: `computeQuickClipRange(currentTime, duration, position, clipDuration?)` → `{ start: number; end: number } | null`
- [ ] **Step 1: Write failing tests for computeQuickClipRange**
Create `tests/lib/utils/clipRange.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { computeQuickClipRange } from '$lib/utils/clipRange';
describe('computeQuickClipRange', () => {
it('returns null when duration is 0', () => {
expect(computeQuickClipRange(5, 0, 'at')).toBeNull();
});
it('"at" creates clip from currentTime to currentTime+10', () => {
const result = computeQuickClipRange(30, 120, 'at');
expect(result).toEqual({ start: 30, end: 40 });
});
it('"before" creates clip from currentTime-10 to currentTime', () => {
const result = computeQuickClipRange(30, 120, 'before');
expect(result).toEqual({ start: 20, end: 30 });
});
it('"at" clamps end to duration', () => {
const result = computeQuickClipRange(115, 120, 'at');
expect(result).toEqual({ start: 115, end: 120 });
});
it('"before" clamps start to 0', () => {
const result = computeQuickClipRange(3, 120, 'before');
expect(result).toEqual({ start: 0, end: 3 });
});
it('accepts custom clip duration', () => {
const result = computeQuickClipRange(50, 120, 'at', 20);
expect(result).toEqual({ start: 50, end: 70 });
});
it('"at" at time 0 works', () => {
const result = computeQuickClipRange(0, 120, 'at');
expect(result).toEqual({ start: 0, end: 10 });
});
it('"before" at exact duration works', () => {
const result = computeQuickClipRange(120, 120, 'before');
expect(result).toEqual({ start: 110, end: 120 });
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
npx vitest run tests/lib/utils/clipRange.test.ts
```
Expected: FAIL — module `$lib/utils/clipRange` not found.
- [ ] **Step 3: Implement computeQuickClipRange**
Create `src/lib/utils/clipRange.ts`:
```typescript
export type QuickClipPosition = 'at' | 'before';
export function computeQuickClipRange(
currentTime: number,
duration: number,
position: QuickClipPosition,
clipDuration = 10,
): { start: number; end: number } | null {
if (duration === 0) return null;
switch (position) {
case 'at':
return {
start: currentTime,
end: Math.min(currentTime + clipDuration, duration),
};
case 'before':
return {
start: Math.max(currentTime - clipDuration, 0),
end: currentTime,
};
default: {
const _exhaustive: never = position;
void _exhaustive;
return null;
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
npx vitest run tests/lib/utils/clipRange.test.ts
```
Expected: 8 tests pass.
- [ ] **Step 5: Add scissors button to PlayerControls**
In `src/lib/components/PlayerControls.svelte`, add the `Scissors` import (alongside existing Lucide imports from Task 2):
```typescript
import {
VolumeX,
Volume1,
Volume2,
Rewind,
FastForward,
Pause,
Play,
Scissors,
Captions,
PictureInPicture2,
Maximize,
Minimize,
} from '@lucide/svelte';
```
Add `onCutClip` to the props destructure:
```typescript
let {
visible,
captionsEnabled,
hasCaptions,
onToggleCaptions,
onOpenCaptionSettings,
onCutClip,
}: {
visible: boolean;
captionsEnabled: boolean;
hasCaptions: boolean;
onToggleCaptions: () => void;
onOpenCaptionSettings: () => void;
onCutClip: (position: 'at' | 'before') => void;
} = $props();
```
In the right controls group, insert the scissors button **between** the speed-wrapper `</div>` and the `{#if hasCaptions}` block:
```svelte
<button
class="ctrl-btn"
onclick={() => onCutClip('at')}
oncontextmenu={(e) => { e.preventDefault(); onCutClip('before'); }}
title="Cut clip at playhead (X) · Right-click: 10s before (Z)"
type="button"
><Scissors size={16} /></button>
```
- [ ] **Step 6: Wire up cutClip in App.svelte**
In `src/App.svelte`, add the import at the top (alongside existing imports):
```typescript
import { computeQuickClipRange } from '$lib/utils/clipRange';
import { addClip } from '$lib/stores/clips.svelte';
```
Note: `addClip` is already available via the existing import of `getSelectedClipId, removeClip, markInPoint, markOutPoint` — add `addClip` to that import line:
```typescript
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
```
Add the `cutClip` function after `toggleMode()`:
```typescript
function cutClip(position: 'at' | 'before') {
const range = computeQuickClipRange(session.currentTime, session.duration, position);
if (!range) return;
addClip(range.start, range.end);
setAppMode('clipper');
}
```
Pass `onCutClip` to the `<PlayerControls>` component:
```svelte
<PlayerControls
visible={showPlayerControls}
{captionsEnabled}
hasCaptions={session.hasCaptions || false}
onToggleCaptions={handleToggleCaptions}
onOpenCaptionSettings={() => {}}
onCutClip={cutClip}
/>
```
Add X/Z keyboard shortcuts in `handleGlobalKeydown`. Insert these cases **before** the `'f'/'F'` case:
```typescript
case 'x':
case 'X':
if (isPlayerMode) {
e.preventDefault();
cutClip('at');
}
break;
case 'z':
case 'Z':
if (isPlayerMode) {
e.preventDefault();
cutClip('before');
}
break;
```
- [ ] **Step 7: Verify**
Run:
```bash
npx svelte-check --tsconfig ./tsconfig.json
npm test
```
Expected: 0 errors, 0 warnings, all tests pass (including 8 new clipRange tests).
- [ ] **Step 8: Commit**
```bash
git add src/lib/utils/clipRange.ts tests/lib/utils/clipRange.test.ts src/App.svelte src/lib/components/PlayerControls.svelte
git commit -m "feat: cut clip from Player mode (X/Z keys, scissors button)"
```
---
### Task 5: Tabbed About Dialog with Keyboard Shortcuts
**Files:**
- Modify: `src/lib/components/AboutDialog.svelte` (full rewrite of template + styles)
- Create: `tests/lib/components/AboutDialog.test.ts`
**Interfaces:**
- Consumes: `getVersion()` from `@tauri-apps/api/app` (already used), `onClose` prop (unchanged)
- Produces: visual changes only — `onClose` prop API unchanged
- [ ] **Step 1: Write failing component tests**
Create `tests/lib/components/AboutDialog.test.ts`:
```typescript
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import AboutDialog from '$lib/components/AboutDialog.svelte';
vi.mock('@tauri-apps/api/app', () => ({
getVersion: vi.fn().mockResolvedValue('0.2.1'),
}));
describe('AboutDialog', () => {
it('renders the About tab by default', () => {
const { getByText } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
expect(getByText('GUI Video Clipper')).toBeTruthy();
expect(getByText('by cottongin')).toBeTruthy();
});
it('shows two tabs: About and Shortcuts', () => {
const { getByRole } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
const tablist = getByRole('tablist');
expect(tablist).toBeTruthy();
expect(tablist.querySelectorAll('[role="tab"]')).toHaveLength(2);
});
it('switches to Shortcuts tab on click', async () => {
const { getByText, queryByText } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
const shortcutsTab = getByText('Shortcuts');
await fireEvent.click(shortcutsTab);
expect(getByText('Play / Pause')).toBeTruthy();
expect(getByText('Toggle mode')).toBeTruthy();
expect(getByText('Player Mode')).toBeTruthy();
expect(getByText('Clipper Mode')).toBeTruthy();
});
it('switches back to About tab', async () => {
const { getByText } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
await fireEvent.click(getByText('Shortcuts'));
await fireEvent.click(getByText('About'));
expect(getByText('GUI Video Clipper')).toBeTruthy();
expect(getByText('by cottongin')).toBeTruthy();
});
it('calls onClose when Close button is clicked', async () => {
const onClose = vi.fn();
const { getByText } = render(AboutDialog, {
props: { onClose },
});
await fireEvent.click(getByText('Close'));
expect(onClose).toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
npx vitest run tests/lib/components/AboutDialog.test.ts
```
Expected: FAIL — tests fail because the current AboutDialog has no tabs.
- [ ] **Step 3: Rewrite AboutDialog with tabs**
Replace the entire contents of `src/lib/components/AboutDialog.svelte`:
```svelte
<script lang="ts">
import { getVersion } from '@tauri-apps/api/app';
let { onClose }: { onClose: () => void } = $props();
let version = $state('');
let activeTab = $state<'about' | 'shortcuts'>('about');
$effect(() => {
getVersion().then((v) => {
version = v;
});
});
const globalShortcuts = [
{ key: 'Space', action: 'Play / Pause' },
{ key: 'K', action: 'Play / Pause' },
{ key: 'J', action: 'Shuttle slower (−0.25×)' },
{ key: 'L', action: 'Shuttle faster (+0.25×)' },
{ key: '←', action: 'Seek back 5s' },
{ key: '→', action: 'Seek forward 5s' },
{ key: 'Shift+←', action: 'Seek back 1s' },
{ key: 'Shift+→', action: 'Seek forward 1s' },
{ key: ',', action: 'Previous frame' },
{ key: '.', action: 'Next frame' },
{ key: '< (Shift+,)', action: 'Previous keyframe' },
{ key: '> (Shift+.)', action: 'Next keyframe' },
{ key: 'C / P', action: 'Toggle mode' },
{ key: '⌘/', action: 'About' },
];
const playerShortcuts = [
{ key: 'F', action: 'Toggle fullscreen' },
{ key: 'X', action: 'Cut clip at playhead → Clipper' },
{ key: 'Z', action: 'Cut clip 10s before playhead → Clipper' },
];
const clipperShortcuts = [
{ key: 'I', action: 'Set in-point' },
{ key: 'O', action: 'Set out-point' },
{ key: 'Delete / ⌫', action: 'Remove selected clip' },
{ key: '⌘E', action: 'Export' },
];
</script>
<div
class="overlay"
role="presentation"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
if (e.key !== 'Escape') e.preventDefault();
onClose();
}
}}
>
<div
class="dialog"
class:wide={activeTab === 'shortcuts'}
role="dialog"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Tab bar -->
<div class="tab-bar" role="tablist">
<button
class="tab"
class:active={activeTab === 'about'}
role="tab"
aria-selected={activeTab === 'about'}
onclick={() => { activeTab = 'about'; }}
type="button"
>About</button>
<button
class="tab"
class:active={activeTab === 'shortcuts'}
role="tab"
aria-selected={activeTab === 'shortcuts'}
onclick={() => { activeTab = 'shortcuts'; }}
type="button"
>Shortcuts</button>
</div>
{#if activeTab === 'about'}
<!-- About content -->
<div class="about-content">
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
<stop offset="0%" stop-color="#fb923c"/>
<stop offset="100%" stop-color="#ea580c"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="90" fill="#18181b"/>
<g transform="translate(-3, 3)">
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<g transform="translate(3, -3)">
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
<g transform="rotate(20, 256, 256)">
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
</g>
</svg>
<h2>GUI Video Clipper</h2>
{#if version}
<span class="version">v{version}</span>
{/if}
<p class="author">by cottongin</p>
<p class="license">MIT License</p>
<a
class="repo-link"
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
target="_blank"
rel="noopener noreferrer"
>
code.cottongin.xyz/cottongin/gui-video-clipper
</a>
</div>
{:else}
<!-- Shortcuts content -->
<div class="shortcuts-content">
<section>
<h3>Global</h3>
<table>
<tbody>
{#each globalShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section>
<h3>Player Mode</h3>
<table>
<tbody>
{#each playerShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section>
<h3>Clipper Mode</h3>
<table>
<tbody>
{#each clipperShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
{/if}
<div class="actions">
<button class="primary" onclick={onClose}>Close</button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
}
.dialog {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px 32px 32px;
max-width: 360px;
width: 100%;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
transition: max-width 0.2s ease;
}
.dialog.wide {
max-width: 520px;
max-height: 70vh;
}
/* --- Tabs --- */
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
width: 100%;
margin-bottom: 12px;
}
.tab {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
padding: 8px 0;
cursor: pointer;
border-radius: 0;
}
.tab:hover {
color: var(--text-primary);
background: none;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: none;
}
/* --- About content --- */
.about-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.app-icon {
width: 64px;
height: 64px;
border-radius: 12px;
margin-bottom: 8px;
}
h2 {
margin: 0;
font-size: 18px;
}
.version {
color: var(--text-secondary);
font-size: 14px;
font-family: var(--font-mono);
}
.author {
color: var(--text-secondary);
font-size: 13px;
margin: 0;
}
.license {
color: var(--text-muted);
font-size: 12px;
margin: 0;
}
.repo-link {
color: var(--accent);
font-size: 12px;
text-decoration: none;
}
.repo-link:hover {
text-decoration: underline;
}
/* --- Shortcuts content --- */
.shortcuts-content {
width: 100%;
text-align: left;
overflow-y: auto;
max-height: calc(70vh - 140px);
display: flex;
flex-direction: column;
gap: 16px;
}
.shortcuts-content h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin: 0 0 6px;
}
.shortcuts-content table {
width: 100%;
border-collapse: collapse;
}
.shortcuts-content tr {
border-bottom: 1px solid var(--border);
}
.shortcuts-content tr:last-child {
border-bottom: none;
}
.key-cell {
width: 40%;
padding: 5px 8px 5px 0;
white-space: nowrap;
}
.action-cell {
padding: 5px 0;
color: var(--text-secondary);
font-size: 13px;
}
kbd {
display: inline-block;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
line-height: 1.4;
}
/* --- Actions --- */
.actions {
margin-top: 12px;
}
.primary {
background: var(--accent);
color: var(--bg-primary);
font-weight: 600;
}
</style>
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
npx vitest run tests/lib/components/AboutDialog.test.ts
```
Expected: 5 tests pass.
- [ ] **Step 5: Run full verification**
Run:
```bash
npx svelte-check --tsconfig ./tsconfig.json
npm test
```
Expected: 0 errors, 0 warnings, all tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/lib/components/AboutDialog.svelte tests/lib/components/AboutDialog.test.ts
git commit -m "feat: tabbed About dialog with keyboard shortcuts reference"
```

View File

@@ -0,0 +1,264 @@
# v0.2.0 Player Mode Bug Fixes — Design Spec
**Date:** 2026-09-22
**Scope:** 8 issues found during QA of the v0.2.0 Player mode implementation
---
## 1. QuickTime-Style Floating Controls Layout
### Problem
The current `PlayerControls` overlay is pinned full-width to the bottom edge of the video, obscuring the waveform timeline. It should look and behave like QuickTime Player: a compact, centered, rounded pill floating above the bottom edge.
### Design
**New layout structure:**
```
┌─────────────────────────────────────────────────────────┐
│ 🔊━━━━ ⏪ ❚❚ ⏩ 1× CC ⧉ ⛶ │ controls row
│ ──────────────────────────────━━━━━━━━━━━━──────────── │ seek bar
│ 00:06 02:58 │ timestamps
└─────────────────────────────────────────────────────────┘
```
**Controls row** (top):
- Left group: mute button + volume slider
- Center group: skip −10s, play/pause, skip +10s
- Right group: speed selector, CC toggle, PiP, fullscreen
**Seek bar** (middle): full pill width, thin track that expands on hover, thumb visible on hover.
**Timestamps** (bottom): current time left-aligned, duration right-aligned.
**CSS changes to `.player-controls-overlay`:**
- Remove: `bottom: 0; left: 0; right: 0`
- Add: `bottom: 24px; left: 15%; right: 15%` (centered ~70% width, floating above bottom edge)
- The overlay itself becomes the positioning container; pointer-events remain `none` on the overlay, `auto` on the panel.
**CSS changes to `.player-controls-panel`:**
- Change: `border-radius: 12px 12px 0 0` → `border-radius: 12px` (rounded on all corners)
**Markup reorder:** Controls row first, seek bar second, timestamps third (currently: seek bar, controls row).
### Files Modified
- `src/lib/components/PlayerControls.svelte` — markup reorder + CSS
---
## 2. Auto-Hide Reactive Loop Fix
### Problem
`controlsHideTimer` and `toolbarHideTimer` are declared with `$state`. The `$effect` that calls `resetControlsTimer()` reads `controlsHideTimer` (via `clearTimeout`), then writes a new timer ID. Svelte 5 tracks the read as a dependency, re-runs the effect, clears the just-set timer, sets a new one — infinite loop. The timeout callback never fires. Controls and toolbar never auto-hide.
### Fix
Change both timer variables from `$state` to plain `let`:
```typescript
let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
let toolbarHideTimer: ReturnType<typeof setTimeout> | null = null;
```
Nothing in the template reads these values. They are only used in imperative timer logic and do not need reactivity.
### Files Modified
- `src/App.svelte` — 2 variable declarations
---
## 3. Controls Visible When Paused
### Problem
The current `$effect` forces `showPlayerControls = true` and `showToolbar = true` whenever `!session.isPlaying`. This means controls permanently show when paused. QuickTime hides controls when paused after the same timeout.
### Fix
Remove the special paused branch. Both playing and paused states use the same timer-based auto-hide. The `$effect` simplifies to:
```typescript
$effect(() => {
if (!isPlayerMode) return;
// Explicitly read play state so Svelte tracks it as a dependency —
// controls briefly show on every play/pause toggle
void session.isPlaying;
resetControlsTimer();
});
```
`resetControlsTimer` also changes — remove the `if (session.isPlaying)` guard so the timer always starts:
```typescript
function resetControlsTimer() {
if (controlsHideTimer) clearTimeout(controlsHideTimer);
showPlayerControls = true;
controlsHideTimer = setTimeout(() => {
showPlayerControls = false;
}, CONTROLS_HIDE_DELAY);
}
```
Same change for `resetToolbarTimer` — always start the timer.
Mouse movement resets the timer regardless of play state. The only time controls are forced visible without a timer is during the initial mode switch into Player mode (handled by `toggleMode()`).
### Files Modified
- `src/App.svelte` — `$effect` body, `resetControlsTimer` / `resetToolbarTimer` logic
---
## 4. Status Bar Hidden in Player Mode
### Problem
`<StatusBar>` is rendered unconditionally outside any `{#if}` block. It remains visible at the bottom of the window in Player mode.
### Fix
Wrap in a conditional:
```svelte
{#if !isPlayerMode}
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
{/if}
```
### Files Modified
- `src/App.svelte` — template
---
## 5. JKL Shuttle Skips 1.0x
### Problem
Two sub-issues:
1. `shuttleRate` in App.svelte and the actual video `playbackRate` are disconnected. The speed selector sets `videoEl.playbackRate` directly, but `shuttleRate` stays stale. Next JKL press uses the stale value.
2. Step size of 0.5 doesn't align with speed selector values (0.5, 0.75, 1, 1.25, 1.5, 2). If the rate is ever on the 0.25-offset grid (0.75, 1.25), pressing J/L cycles between those values and 1.0 is unreachable.
### Fix
Change `adjustShuttle` to:
- Read the current rate from `videoEl.playbackRate` (source of truth) instead of taking an external `shuttleRate` parameter
- Use step size `0.25` to align with the speed selector grid
- Return the new rate so callers can update display state
New signature:
```typescript
export function adjustShuttle(dir: 1 | -1): number {
const videoEl = getVideo();
if (!videoEl) return 1;
const current = videoEl.playbackRate;
const nextRate = Math.max(0.25, Math.min(4, current + dir * 0.25));
videoEl.playbackRate = nextRate;
if (videoEl.paused) void videoEl.play();
return nextRate;
}
```
App.svelte call sites update from `adjustShuttle(-1, shuttleRate)` to `adjustShuttle(-1)`. The returned value updates `shuttleRate` for any display that reads it.
PlayerControls syncs `currentRate` from the live video rate. The existing 500ms polling `$effect` (for PiP/fullscreen state) is extended to also sync `currentRate = getPlaybackRate()`.
### Files Modified
- `src/lib/transport/playback.ts` — `adjustShuttle` signature + body
- `src/App.svelte` — call sites
- `src/lib/components/PlayerControls.svelte` — add `currentRate` to polling effect
- `tests/lib/transport/playback.test.ts` — update test for new signature
---
## 6. Speed Selector Immediately Closes
### Problem
SpeedSelector uses `<svelte:window onclick={handleWindowClick}>` to detect click-outside. The same click that toggles `showSpeedSelector` to `true` bubbles to the window, where `handleWindowClick` sees the target is outside `.speed-selector` and calls `onClose()`. The popup renders for one frame then immediately closes.
### Fix
Add `stopPropagation` on the toggle button click in PlayerControls:
```svelte
onclick={(e) => { e.stopPropagation(); showSpeedSelector = !showSpeedSelector; }}
```
This prevents the opening click from reaching the window-level close handler.
### Files Modified
- `src/lib/components/PlayerControls.svelte` — toggle button onclick
---
## 7. Fullscreen Broken in WKWebView
### Problem
`toggleFullscreen()` calls `videoEl.requestFullscreen()`, which doesn't work in Tauri's WKWebView. The standard Fullscreen API on child elements is not supported in WKWebView.
### Fix
Target `document.documentElement` instead of the video element:
```typescript
export function toggleFullscreen(): void {
if (document.fullscreenElement) {
document.exitFullscreen().catch(console.error);
} else {
const el = document.documentElement;
if (el.requestFullscreen) {
el.requestFullscreen().catch(console.error);
} else if ((el as any).webkitRequestFullscreen) {
(el as any).webkitRequestFullscreen();
}
}
}
```
This makes the entire webview fullscreen. In Player mode the video already fills the viewport, so the result is the same as video-only fullscreen. The `webkitRequestFullscreen` fallback covers older WebKit versions.
### Files Modified
- `src/lib/transport/playback.ts` — `toggleFullscreen` body
- `tests/lib/transport/playback.test.ts` — update mocks to use `document.documentElement`
---
## 8. Shift+, / Shift+. Produce Wrong Key Values
### Problem
On US keyboards, `Shift+,` produces `<` and `Shift+.` produces `>`. The keydown handler matches on `,` and `.`, which don't match when Shift is held. Keyframe skip shortcuts never fire.
### Fix
Add explicit cases for `<` and `>`:
```typescript
case '<':
e.preventDefault();
dispatchTransport('keyframe-back');
break;
case '>':
e.preventDefault();
dispatchTransport('keyframe-forward');
break;
```
These go after the existing `,` and `.` cases (which handle frame-step and already check `e.shiftKey` — but that path is unreachable). The `,`/`.` cases can drop the `e.shiftKey` ternary since those keys only fire without Shift.
Updated `,`/`.` cases become:
```typescript
case ',':
e.preventDefault();
dispatchTransport('frame-back');
break;
case '.':
e.preventDefault();
dispatchTransport('frame-forward');
break;
```
### Files Modified
- `src/App.svelte` — keydown handler

View File

@@ -0,0 +1,331 @@
# v0.2.0 — Player Mode Design Spec
**Date:** 2026-09-22
**Version target:** 0.2.0
**Scope:** Add a dedicated "Player" mode alongside the existing "Clipper" mode, transforming the app into a clean, minimal video player when clipping isn't needed.
---
## 1. Overview
The app currently operates exclusively as a video clipper. This spec adds a second mode — **Player** — that strips away clipping UI and presents a focused video playback experience (think QuickTime / VLC). Users toggle between modes with a toolbar button or keyboard shortcut; the switch preserves all session state (playhead position, loaded video, clips, waveform data).
**Architecture approach:** Single `App.svelte` with conditional rendering. The `<video>` element inside `VideoPlayer.svelte` is never destroyed during mode switches — only the surrounding chrome changes. New Player-specific components (`PlayerControls`, `PlayerTimeline`, `SpeedSelector`) stay focused and small.
---
## 2. State & Preferences
### New preference field
Add `appMode: 'clipper' | 'player'` to the `Preferences` interface in `preferences.svelte.ts`. Default: `'clipper'`. Persisted via the Tauri store so it survives relaunches.
### New store helper
- `setAppMode(mode: 'clipper' | 'player')` — updates `preferences.appMode` and saves.
### Mode switching invariant
Toggling between modes **preserves all session state**: playhead position, loaded video, playback state (playing/paused), clips, waveform data, captions, volume, speed — everything. The `<video>` element itself never unmounts, so playback state is inherently preserved (same principle as the existing clip list layout toggle).
---
## 3. Keyboard Shortcuts
### New shortcuts
| Key | Action | Available in |
|-----|--------|-------------|
| `p` | Switch to Player mode | Both modes |
| `c` | Switch to Clipper mode | Both modes |
| `f` | Toggle fullscreen | Player mode only |
### Existing shortcuts by mode
| Key | Action | Clipper | Player |
|-----|--------|---------|--------|
| `Space` / `k` | Play/Pause | ✓ | ✓ |
| `←` / `→` | Seek ±5s (±1s with Shift) | ✓ | ✓ |
| `,` / `.` | Frame step back/forward (works when paused) | ✓ | ✓ |
| `Shift+,` / `Shift+.` | Keyframe jump | ✓ | ✓ |
| `j` / `l` | Shuttle speed down/up | ✓ | ✓ |
| `i` / `o` | Mark in/out point | ✓ | No-op |
| `Delete` / `Backspace` | Delete selected clip | ✓ | No-op |
| `Cmd+E` | Export dialog | ✓ | No-op |
---
## 4. App Layout
### Clipper mode (unchanged)
Renders exactly as v0.1.x: toolbar → video player → transport controls → resizable timeline → clip list (bottom or sidebar).
### Player mode
The layout collapses to a single full-bleed video area with auto-hiding overlays:
```
┌──────────────────────────────────┐
│ Toolbar (auto-hides) │ ← slides down from top on cursor proximity
├──────────────────────────────────┤
│ │
│ VideoPlayer │ ← fills all available space
│ (floating controls overlay) │
│ │
├──────────────────────────────────┤
│ PlayerTimeline (auto-hides) │ ← slides up from bottom on cursor proximity
└──────────────────────────────────┘
```
**Hidden in Player mode:**
- `TransportControls` bar (replaced by floating `PlayerControls`)
- `ClipList` (both bottom and sidebar variants)
- Timeline resize handle
- Minimap
- Timeline clip rendering
- I/O mark buttons
**Visible in Player mode:**
- `VideoPlayer.svelte` (unchanged, always mounted)
- `StatusBar` at the bottom (stays visible in both modes)
- `PlayerControls` (floating overlay)
- `PlayerTimeline` (auto-hiding simplified timeline)
**CSS:** Player mode sets a different `grid-template` on `.content` that gives the video area `1fr` and collapses the lower/clip-list rows to `0`. The auto-hiding panels use `position: absolute` within the app shell.
---
## 5. Mode Toggle Button
- **Position:** In the toolbar, between the URL input and the existing layout toggle button.
- **Appearance:** Text label showing current mode — `Clipper` or `Player` — styled as a small pill/badge button.
- **Click:** Toggles between modes.
- **Tooltip:** "Switch to Player (P)" or "Switch to Clipper (C)".
---
## 6. Floating Player Controls (`PlayerControls.svelte`)
An absolutely-positioned overlay rendered over the video area. Designed as a semi-transparent frosted-glass panel.
### Show/hide logic
- **Show** when: mouse moves anywhere over the video area, OR video is paused.
- **Hide** when: mouse is still for 2.5 seconds AND video is playing.
- **Transition:** fade in/out over ~200ms using CSS opacity + pointer-events.
- **Fullscreen:** Same show/hide behavior applies when video is in fullscreen mode.
### Panel layout
```
┌─────────────────────────────────────────────────────────────┐
│ ▁▂▃▅▃▂▁▂▃▅▇▅▃▂▁▂▃▅▃▂▁ ●─────────────────── │ ← seek bar
├─────────────────────────────────────────────────────────────┤
│ ▶❚❚ ◀◀ ▶▶ 🔊━━━━ 0:32 / 4:15 1.0× CC ⧉ ⛶ │ ← controls row
└─────────────────────────────────────────────────────────────┘
```
#### Top row — Seek bar
- Full-width thin progress bar spanning the entire panel width.
- Shows playback progress as a filled track (`var(--accent)`) against a dark unfilled track.
- Hovering shows a time tooltip above the cursor position.
- Click or drag to seek (reuses `seekTo()` from `playback.ts`).
- Track height: 4px, expanding to 6px on hover (CSS transition).
- Thumb: 12px circle, visible only on hover.
#### Bottom row — Controls
Three groups arranged with flexbox `space-between`:
**Left group (playback):**
- Play/Pause — primary action button, `▶` / `❚❚`
- Skip back 10s — `⏪` button, calls `seekBy(-10)`
- Skip forward 10s — `⏩` button, calls `seekBy(10)`
**Center group (time):**
- Time display: `0:32 / 4:15` in monospace font (`var(--font-mono)`), slightly dimmed
**Right group (settings):**
- Speed — shows current rate (e.g. `1×`, `1.5×`). Click opens `SpeedSelector` popup. Sets `videoElement.playbackRate` directly.
- CC — `CC` text button, highlighted when captions active. Click toggles captions. Right-click opens `CaptionSettingsPanel`.
- PiP — `⧉` icon. Click enters/exits Picture-in-Picture. Active state: accent color.
- Fullscreen — `⛶` icon. Click enters/exits fullscreen. Swaps to exit icon when active.
### Visual styling
- **Background:** `rgba(0, 0, 0, 0.75)` with `backdrop-filter: blur(8px)` (frosted glass)
- **Border radius:** `12px` on top corners only (bottom flush with video edge)
- **Padding:** `12px 16px`, `8px` gap between rows
- **Buttons:** Borderless, icon-only, `color: var(--text-primary)`, hover: `color: var(--accent)`. ~32px tap targets.
- **Pointer events:** Panel has `pointer-events: auto`; surrounding overlay has `pointer-events: none` so clicks pass through to the video.
### Video click/double-click behavior
- Single click on video (outside controls) → toggles play/pause
- Double-click on video → toggles fullscreen
---
## 7. Speed Selector (`SpeedSelector.svelte`)
A small vertical popup menu triggered by clicking the speed button in `PlayerControls`.
- Options: 0.5×, 0.75×, 1×, 1.25×, 1.5×, 2×
- Current rate has a checkmark indicator
- Click an option to set rate, popup closes
- Click outside popup to dismiss
- Popup appears above the speed button, anchored to its position
- Sets `videoElement.playbackRate` directly
- JKL shuttle keys continue to work independently (they adjust `playbackRate` too)
---
## 8. Simplified Player Timeline (`PlayerTimeline.svelte`)
A lightweight timeline component for Player mode. Shows only waveform and timestamps — no clips, thumbnails, clip handles, or minimap.
### Auto-hide behavior
- **Show** when cursor is within ~80px of the bottom edge of the video area.
- **Hide** when cursor moves away from bottom edge AND video is playing.
- **Stays visible** when video is paused.
- **Transition:** slides up from below + fades in over ~200ms.
- **Relationship to floating controls:** The floating controls and the player timeline have overlapping but distinct triggers. The floating controls show on *any* mouse movement over the video area; the player timeline shows only on bottom-edge proximity (~80px). When the cursor is near the bottom, both are visible and they appear together as a cohesive unit. When the cursor moves elsewhere over the video, only the floating controls appear (not the timeline).
### Layout
- **Fixed height:** ~50px (not user-resizable)
- **Position:** Bottom of the player area, below the floating controls panel
- **Background:** Semi-transparent dark, matching floating controls (`rgba(0, 0, 0, 0.6)`, `backdrop-filter: blur(4px)`)
### Rendering
- **Waveform:** Reuses `drawWaveform()` from `waveformRenderer.ts`, rendered at full duration (always zoom level 1, no pan).
- **Timestamps:** Reuses timestamp tick rendering from `renderer.ts`, simplified to major ticks only (no clip lanes).
- **Playhead:** Vertical white line showing current position.
- **Click-to-seek:** Clicking anywhere seeks to that position.
- **No zoom/pan:** Always shows the full duration.
---
## 9. Toolbar Auto-Hide (Player Mode)
### Clipper mode
Toolbar always visible (no change).
### Player mode
- **Show** when cursor is within ~50px of the top edge of the window.
- **Hide** when cursor moves away from top edge AND video is playing.
- **Stays visible** when video is paused.
- **Transition:** slides down from above + fades in (~200ms), matching the bottom HUD timing.
- **Content:** Same as Clipper mode (URL input, mode toggle, about, preferences).
---
## 10. Picture-in-Picture
- **API:** `HTMLVideoElement.requestPictureInPicture()` — uses the native macOS PiP window via WKWebView.
- **Scope:** Player mode only. No PiP button in Clipper mode.
- **Button:** `⧉` icon in `PlayerControls`. Active state shown with accent color.
- **Events:** Listens for `enterpictureinpicture` and `leavepictureinpicture` on the video element to track state.
- **Exiting PiP:** Returns focus to the app window.
### New playback helpers
Add to `playback.ts`:
- `requestPiP()` — calls `videoElement.requestPictureInPicture()`
- `exitPiP()` — calls `document.exitPictureInPicture()`
- `isPiPActive(): boolean` — checks `document.pictureInPictureElement`
---
## 11. Fullscreen
- **API:** `videoElement.requestFullscreen()` with fallback to `videoElement.webkitEnterFullscreen()` for WKWebView compatibility. WKWebView's webkit-prefixed API fullscreens the video natively (similar to Safari's behavior).
- **Scope:** Player mode only. Keyboard shortcut `f`.
- **Button:** `⛶` icon in `PlayerControls`. Swaps to exit-fullscreen icon when active.
- **Events:** Listens for `fullscreenchange` to track state.
- **Controls in fullscreen:** The floating `PlayerControls` overlay remains functional in fullscreen mode with the same auto-hide behavior.
### New playback helpers
Add to `playback.ts`:
- `toggleFullscreen()` — enters or exits fullscreen on the video element
- `isFullscreenActive(): boolean` — checks `document.fullscreenElement`
---
## 12. Transitions (FLIP Animations)
### Clipper ↔ Player mode switch
1. **Video player area** — FLIP animation: the video container smoothly expands to fill the full area (Clipper→Player) or shrinks back to its grid cell (Player→Clipper). Manual FLIP: snapshot bounding rect before mode change, apply mode, snapshot new rect, animate from old→new using CSS `transform`.
2. **Transport controls / Clip list** — fade + slide out downward when switching to Player, fade + slide back in when returning to Clipper. Duration ~250ms, eased.
3. **Floating controls** — fade in after video expansion completes (staggered ~100ms delay).
4. **Toolbar** — slides up and fades out (Clipper→Player), slides back down (Player→Clipper).
### Implementation approach
- CSS custom properties + `transition` for grid layout changes (grid rows/columns animate in modern browsers).
- Svelte `transition:` directives (`fly`, `fade`, `slide`) for elements entering/leaving the DOM.
- Manual FLIP on the video container for the expansion/contraction.
- Total transition budget: ~300ms.
### Edge cases
- Mid-transition mode switch: new transition takes over from current interpolated position (CSS transitions handle this naturally).
- No video loaded: transition still runs (placeholder area expands/contracts).
---
## 13. New & Modified Files
### New files
| File | Purpose |
|------|---------|
| `src/lib/components/PlayerControls.svelte` | Floating controls overlay (seek bar, play/pause, volume, speed, CC, PiP, fullscreen, time display) |
| `src/lib/components/PlayerTimeline.svelte` | Simplified auto-hiding timeline (waveform + timestamps only) |
| `src/lib/components/SpeedSelector.svelte` | Speed popup menu used by PlayerControls |
### Modified files
| File | Changes |
|------|---------|
| `src/lib/stores/preferences.svelte.ts` | Add `appMode` field, `setAppMode()`, persist/load |
| `src/App.svelte` | Mode toggle button, conditional layout, auto-hide toolbar, `p`/`c`/`f` shortcuts, FLIP transitions |
| `src/lib/components/VideoPlayer.svelte` | PiP/fullscreen event listeners, expose PiP/fullscreen state, conditional CC toggle rendering (Clipper only; Player delegates to PlayerControls) |
| `src/lib/transport/playback.ts` | Add `requestPiP()`, `exitPiP()`, `isPiPActive()`, `toggleFullscreen()`, `isFullscreenActive()`, `setPlaybackRate()` |
| `src/app.css` | CSS variables/classes for player mode transitions, auto-hide panels |
### Unchanged files
- `Timeline.svelte` — used only in Clipper mode
- `ClipList.svelte` — hidden in Player mode
- `TransportControls.svelte` — hidden in Player mode
- All Rust backend code — no backend changes
- `StatusBar.svelte` — visible in both modes
### Testing
- Existing tests unaffected (timeline interactions, clips store, time utilities).
- New tests: PlayerControls show/hide logic, speed selector state, mode switching preserves session state.
---
## 14. Version Bump
Bump version from `0.1.3` to `0.2.0` in:
- `package.json`
- `src-tauri/tauri.conf.json`
- `src-tauri/Cargo.toml`
- `VERSION` file
Use the existing `scripts/bump-version.sh` if it handles all locations, otherwise update manually.

View File

@@ -0,0 +1,159 @@
# v0.2.1 — Polish, Icons, Cut Clip & Shortcuts Reference
## Overview
Incremental polish release building on v0.2.0 Player mode. Six changes: controls position tweak, Lucide icon migration, blur animation fix, mode toggle shortcuts, cut-clip feature, and keyboard shortcuts reference in the About dialog.
---
## 1. Floating Controls Position & Blur Fix
### Position
Raise `.player-controls-overlay` from `bottom: 48px` to `bottom: 64px` (+16px).
### Blur Pop-in Fix
**Problem:** `backdrop-filter: blur(8px)` is on `.player-controls-panel` while the parent `.player-controls-overlay` transitions `opacity: 0 → 1`. WebKit doesn't smoothly composite `backdrop-filter` through an ancestor `opacity` transition — the blur snaps on once opacity crosses a rendering threshold.
**Fix:** Move the blur background into a `::before` pseudo-element on `.player-controls-panel`. The pseudo-element handles `background` + `backdrop-filter` and has its own `opacity` transition driven by the `.visible` class on the overlay ancestor. The panel itself becomes `position: relative` with no background/backdrop-filter of its own.
```css
.player-controls-panel {
position: relative;
/* background and backdrop-filter removed */
}
.player-controls-panel::before {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 12px;
z-index: -1;
opacity: 0;
transition: opacity 0.25s ease;
}
.player-controls-overlay.visible .player-controls-panel::before {
opacity: 1;
}
```
---
## 2. Lucide Icon Migration
Install `lucide-svelte` as a dependency. Replace all emoji and text-glyph icons with Lucide SVG components across `PlayerControls.svelte`, `TransportControls.svelte`, and the `App.svelte` toolbar.
### Icon Mapping
| Current | Lucide Component | Location |
|---|---|---|
| 🔇 / 🔉 / 🔊 | `VolumeX` / `Volume1` / `Volume2` | PlayerControls, TransportControls |
| ⏪ | `SkipBack` | PlayerControls |
| ❚❚ / ▶ | `Pause` / `Play` | PlayerControls, TransportControls |
| ⏩ | `SkipForward` | PlayerControls |
| CC (text) | `Captions` | PlayerControls |
| ⧉ | `PictureInPicture2` | PlayerControls |
| ⛶ / ⤓ | `Maximize` / `Minimize` | PlayerControls |
| ⚙ | `Settings` | App toolbar |
| ℹ | `Info` | App toolbar |
| ⬒ / ⬓ | `PanelLeft` / `PanelBottom` | App toolbar |
| ✂️ (new) | `Scissors` | PlayerControls (cut clip button) |
| ◄K / K► | `ChevronFirst` / `ChevronLast` | TransportControls |
| ◄\| / \|► | `StepBack` / `StepForward` | TransportControls |
Icons render inline as SVGs. Size: 16–18px to match existing button dimensions.
---
## 3. Mode Toggle Shortcuts
Both `C` and `P` keys become bidirectional toggles. Pressing either key switches from the current mode to the other mode.
Implementation: both the `'c'/'C'` and `'p'/'P'` cases in `handleGlobalKeydown` call `toggleMode()` instead of hard-coding a target mode via `setAppMode()`.
---
## 4. Cut Clip Feature
### Button
A scissors icon (`Scissors` from Lucide) in the `PlayerControls` right control group, positioned between the speed selector and the CC button.
### Behavior
- **Left-click / `X` key:** Create a 10-second clip starting at the current playhead position (`currentTime` → `currentTime + 10`). Clamp end to `session.duration`. Switch to Clipper mode. Playhead stays at its current position.
- **Right-click / `Z` key:** Create a 10-second clip ending at the current playhead position (`currentTime - 10` → `currentTime`). Clamp start to `0`. Switch to Clipper mode. Playhead stays at its current position.
### Implementation
- Add a `cutClip(position: 'at' | 'before')` helper. It calls `addClip(start, end)` from the clips store, then `setAppMode('clipper')`.
- Pass an `onCutClip` callback prop to `PlayerControls`. The button's `onclick` calls `onCutClip('at')`, `oncontextmenu` prevents default and calls `onCutClip('before')`.
- In `handleGlobalKeydown`: `X`/`x` → `cutClip('at')`, `Z`/`z` → `cutClip('before')`. Only active in Player mode.
- **Tooltip:** "Cut clip at playhead (X) · Right-click: 10s before (Z)"
### Edge Cases
- No video loaded (`session.duration === 0`): no-op.
- Playhead < 10s from start: `Z`/right-click clamps start to 0.
- Playhead < 10s from end: `X`/left-click clamps end to duration.
---
## 5. Tabbed About Dialog with Keyboard Shortcuts
### Tab Structure
Two tabs: **About** (default) and **Shortcuts**.
### About Tab
Identical to current content (icon, app name, version, author, license, repo link, close button). Dialog stays at ~360px wide.
### Shortcuts Tab
Two-column layout (key on left, description on right) grouped by section headers. Dialog expands to ~520px wide with `max-height: 70vh` and vertical scroll. Width change transitions smoothly (`transition: max-width 0.2s ease`).
Tab switcher: text tabs at the top of the dialog, active tab gets accent-color underline.
### Complete Shortcut Reference
**Global (both modes):**
| Key | Action |
|---|---|
| `Space` | Play / Pause |
| `K` | Play / Pause |
| `J` | Shuttle slower (−0.25×) |
| `L` | Shuttle faster (+0.25×) |
| `←` | Seek back 5s |
| `→` | Seek forward 5s |
| `Shift+←` | Seek back 1s |
| `Shift+→` | Seek forward 1s |
| `,` | Previous frame |
| `.` | Next frame |
| `<` (Shift+,) | Previous keyframe |
| `>` (Shift+.) | Next keyframe |
| `C` / `P` | Toggle mode |
| `Cmd+/` | About |
**Player mode:**
| Key | Action |
|---|---|
| `F` | Toggle fullscreen |
| `X` | Cut clip at playhead → Clipper |
| `Z` | Cut clip 10s before playhead → Clipper |
**Clipper mode:**
| Key | Action |
|---|---|
| `I` | Set in-point |
| `O` | Set out-point |
| `Delete` / `Backspace` | Remove selected clip |
| `Cmd+E` | Export |

33
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{
"name": "gui-video-clipper",
"version": "0.1.3",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gui-video-clipper",
"version": "0.1.3",
"version": "0.2.2",
"license": "MIT",
"dependencies": {
"@lucide/svelte": "^1.47.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-process": "^2",
@@ -196,7 +197,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -207,7 +207,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -218,7 +217,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -228,20 +226,27 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lucide/svelte": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.47.0.tgz",
"integrity": "sha512-tpuE7JIK9FzYDWsJx6PxHj9ROyCpnDxZfvHu5q5yHGKS2CFNjzSyf1o9/Ypu2A27GLvkPGqZcIMrF6idM+eQZQ==",
"license": "ISC",
"peerDependencies": {
"svelte": "^5"
}
},
"node_modules/@oxc-project/types": {
"version": "0.150.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
@@ -518,7 +523,6 @@
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -926,7 +930,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -991,7 +994,6 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1037,7 +1039,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1057,7 +1058,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1093,7 +1093,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1186,7 +1185,6 @@
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
@@ -1220,14 +1218,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz",
"integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -1359,7 +1355,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -1677,7 +1672,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
@@ -1701,7 +1695,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1981,7 +1974,6 @@
"version": "5.57.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz",
"integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
@@ -2452,7 +2444,6 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
"integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==",
"dev": true,
"license": "MIT"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "gui-video-clipper",
"version": "0.1.3",
"version": "0.2.2",
"description": "macOS GUI video clipper (Tauri + Svelte)",
"type": "module",
"scripts": {
@@ -14,6 +14,7 @@
},
"license": "MIT",
"dependencies": {
"@lucide/svelte": "^1.47.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-process": "^2",

2
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

@@ -13,6 +13,8 @@
"store:allow-set",
"store:allow-save",
"store:allow-load",
"process:allow-exit"
"process:allow-exit",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen"
]
}

View File

@@ -1,6 +1,7 @@
use crate::models::{ExportConfig, ExportScope};
use crate::services::clip_exporter;
use serde::Serialize;
use std::path::Path;
use tauri::ipc::Channel;
#[derive(Clone, Serialize)]
@@ -26,6 +27,30 @@ pub enum ExportEvent {
},
}
#[tauri::command]
pub fn save_screenshot(
output_dir: String,
video_title: String,
timestamp_label: String,
png_data: Vec<u8>,
) -> Result<String, String> {
let expanded_dir = clip_exporter::expand_tilde_path(&output_dir);
let dir_path = Path::new(&expanded_dir);
if !dir_path.exists() {
std::fs::create_dir_all(dir_path)
.map_err(|e| format!("Failed to create output directory: {e}"))?;
}
let path = clip_exporter::generate_output_path(
&expanded_dir,
&video_title,
&format!("{}-screenshot", timestamp_label),
"png",
);
std::fs::write(&path, &png_data)
.map_err(|e| format!("Failed to write screenshot: {e}"))?;
Ok(path)
}
#[tauri::command]
pub async fn export_clips(
config: ExportConfig,

View File

@@ -1,5 +1,5 @@
use crate::models::{CookieSource, VideoMetadata};
use crate::services::{download_manager, subtitle_downloader, video_resolver};
use crate::services::{cache_manager, download_manager, subtitle_downloader, video_resolver};
use serde::Serialize;
use std::io::{BufRead, BufReader};
use tauri::ipc::Channel;
@@ -161,6 +161,9 @@ pub async fn check_cached_download(title: String, variant: String) -> Option<Str
/// Download English VTT subtitles for a video URL.
/// Returns the path to the .vtt file, or an error string.
///
/// Uses a per-video cache directory (keyed by URL hash) so subtitle files
/// from different videos never collide.
#[tauri::command]
pub async fn download_subtitles(
url: String,
@@ -168,12 +171,21 @@ pub async fn download_subtitles(
is_auto: bool,
) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let output_dir = std::env::temp_dir()
.join("video-clipper")
.join("subtitles")
let output_dir = cache_manager::caption_cache_dir(&url)
.to_string_lossy()
.to_string();
// Clean any stale .vtt files before downloading so find_vtt_file
// can only return a file belonging to *this* video.
if let Ok(entries) = std::fs::read_dir(&output_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("vtt") {
let _ = std::fs::remove_file(&path);
}
}
}
eprintln!(
"[video-clipper:subtitles] downloading (auto={}) to '{}'",
is_auto, output_dir

View File

@@ -168,6 +168,7 @@ pub fn run() {
media_analysis::check_embedded_subtitles,
media_analysis::list_system_fonts,
export::export_clips,
export::save_screenshot,
cache::load_cached_analysis,
cache::save_analysis_to_cache,
cache::clear_video_cache,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GUI Video Clipper",
"version": "0.1.3",
"version": "0.2.2",
"identifier": "xyz.cottongin.gui-video-clipper",
"build": {
"beforeDevCommand": "npm run dev",

View File

@@ -10,12 +10,29 @@
import ExportDialog from '$lib/components/ExportDialog.svelte';
import AboutDialog from '$lib/components/AboutDialog.svelte';
import ProcessingModal from '$lib/components/ProcessingModal.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 PlayerControls from '$lib/components/PlayerControls.svelte';
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.svelte';
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
import { computeQuickClipRange } from '$lib/utils/clipRange';
import { captureFrame } from '$lib/utils/screenshot';
import { exit } from '@tauri-apps/plugin-process';
import {
adjustShuttle,
resetShuttleRate,
toggleFullscreen,
runTransportAction,
type TransportKeyAction,
} from '$lib/transport/playback';
import { session } from '$lib/stores/videoSession.svelte';
import { listen } from '@tauri-apps/api/event';
import type { TransportKeyAction } from '$lib/transport/playback';
import { fly, fade } from 'svelte/transition';
import {
PanelLeft,
PanelBottom,
Info,
Settings,
} from '@lucide/svelte';
let showSetupWizard = $state(true);
let showPreferences = $state(false);
@@ -24,6 +41,7 @@
let shuttleRate = $state(1);
let transportControls = $state<TransportControls | null>(null);
let videoPlayerRef = $state<VideoPlayer | null>(null);
// Resizable split: timeline height in px (clip list gets the rest)
let timelineHeight = $state(180);
@@ -36,6 +54,23 @@
let sidebarResizeStartWidth = $state(0);
let windowWidth = $state(window.innerWidth);
let isPlayerMode = $derived(preferences.appMode === 'player');
// Auto-hide state for Player mode
let showPlayerControls = $state(true);
let showToolbar = $state(true);
let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
let toolbarHideTimer: ReturnType<typeof setTimeout> | null = null;
// Caption state for PlayerControls
let captionsEnabled = $state(true);
// Pending timeline zoom for cut-clip feature
let pendingTimelineZoom = $state<{ start: number; end: number } | null>(null);
const CONTROLS_HIDE_DELAY = 2500;
const TOOLBAR_PROXIMITY = 50;
let isLeft = $derived(preferences.clipListPosition === 'left');
$effect(() => {
@@ -57,8 +92,20 @@
}
});
$effect(() => {
if (!isPlayerMode) return;
// Read play state so this re-runs on play/pause changes —
// controls briefly show then auto-hide regardless of play state
void session.isPlaying;
resetControlsTimer();
});
function dispatchTransport(action: TransportKeyAction) {
transportControls?.handleKeyAction(action);
if (transportControls) {
transportControls.handleKeyAction(action);
} else {
runTransportAction(action, markInPoint, markOutPoint);
}
}
function handleResizeStart(e: MouseEvent) {
@@ -107,6 +154,80 @@
}
}
function resetControlsTimer() {
if (controlsHideTimer) clearTimeout(controlsHideTimer);
showPlayerControls = true;
controlsHideTimer = setTimeout(() => {
showPlayerControls = false;
}, CONTROLS_HIDE_DELAY);
}
function resetToolbarTimer() {
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
showToolbar = true;
toolbarHideTimer = setTimeout(() => {
showToolbar = false;
}, CONTROLS_HIDE_DELAY);
}
function handlePlayerMouseMove(e: MouseEvent) {
if (!isPlayerMode) return;
// Controls: show on any mouse movement over the video area
resetControlsTimer();
// Toolbar: show when near top edge
if (e.clientY < TOOLBAR_PROXIMITY) {
resetToolbarTimer();
}
}
function toggleMode() {
const next = preferences.appMode === 'clipper' ? 'player' : 'clipper';
setAppMode(next);
// When entering Player mode, show controls initially
if (next === 'player') {
showPlayerControls = true;
showToolbar = true;
resetControlsTimer();
}
}
function cutClip(position: 'at' | 'before') {
const range = computeQuickClipRange(session.currentTime, session.duration, position);
if (!range) return;
addClip(range.start, range.end);
pendingTimelineZoom = { start: range.start, end: range.end };
setAppMode('clipper');
}
async function captureScreenshot(withCaptions: boolean) {
if (session.status !== 'ready') {
console.warn('[screenshot] No video ready, skipping');
return;
}
try {
console.log(`[screenshot] Capturing frame (captions: ${withCaptions})...`);
const cues = videoPlayerRef?.getParsedCues() ?? [];
const path = await captureFrame({
currentTime: session.currentTime,
videoTitle: session.title,
outputDirectory: preferences.outputDirectory,
withCaptions,
captionsEnabled,
captionSettings: preferences.captionSettings,
parsedCues: cues,
});
console.log(`[screenshot] Saved: ${path}`);
} catch (err) {
console.error('[screenshot] Failed:', err);
}
}
function handleToggleCaptions() {
captionsEnabled = !captionsEnabled;
}
function handleGlobalKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
@@ -118,11 +239,19 @@
break;
case ',':
e.preventDefault();
dispatchTransport(e.shiftKey ? 'keyframe-back' : 'frame-back');
dispatchTransport('frame-back');
break;
case '.':
e.preventDefault();
dispatchTransport(e.shiftKey ? 'keyframe-forward' : 'frame-forward');
dispatchTransport('frame-forward');
break;
case '<':
e.preventDefault();
dispatchTransport('keyframe-back');
break;
case '>':
e.preventDefault();
dispatchTransport('keyframe-forward');
break;
case 'ArrowLeft':
e.preventDefault();
@@ -132,20 +261,52 @@
e.preventDefault();
dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5');
break;
case 'p':
case 'P':
case 'c':
case 'C':
e.preventDefault();
toggleMode();
break;
case 'x':
case 'X':
if (isPlayerMode) {
e.preventDefault();
cutClip('at');
}
break;
case 'z':
case 'Z':
if (isPlayerMode) {
e.preventDefault();
cutClip('before');
}
break;
case 'f':
case 'F':
if (isPlayerMode) {
e.preventDefault();
toggleFullscreen();
}
break;
case 'i':
case 'I':
e.preventDefault();
dispatchTransport('mark-in');
if (!isPlayerMode) {
e.preventDefault();
dispatchTransport('mark-in');
}
break;
case 'o':
case 'O':
e.preventDefault();
dispatchTransport('mark-out');
if (!isPlayerMode) {
e.preventDefault();
dispatchTransport('mark-out');
}
break;
case 'j':
case 'J':
e.preventDefault();
shuttleRate = adjustShuttle(-1, shuttleRate);
shuttleRate = adjustShuttle(-1);
break;
case 'k':
case 'K':
@@ -155,13 +316,15 @@
case 'l':
case 'L':
e.preventDefault();
shuttleRate = adjustShuttle(1, shuttleRate);
shuttleRate = adjustShuttle(1);
break;
case 'Delete':
case 'Backspace': {
e.preventDefault();
const selectedId = getSelectedClipId();
if (selectedId) removeClip(selectedId);
if (!isPlayerMode) {
e.preventDefault();
const selectedId = getSelectedClipId();
if (selectedId) removeClip(selectedId);
}
break;
}
case 'a':
@@ -170,11 +333,21 @@
}
break;
case 'e':
if (e.metaKey) {
if (e.metaKey && !isPlayerMode) {
e.preventDefault();
showExportDialog = true;
}
break;
case 'q':
case 'Q':
e.preventDefault();
exit(0);
break;
case 's':
case 'S':
e.preventDefault();
captureScreenshot(!e.shiftKey);
break;
case '/':
if (e.metaKey) {
e.preventDefault();
@@ -192,6 +365,7 @@
onmousemove={(e) => {
handleResizeMove(e);
handleSidebarResizeMove(e);
handlePlayerMouseMove(e);
}}
onmouseup={() => {
handleResizeEnd();
@@ -222,97 +396,129 @@
<ProcessingModal />
{/if}
<div class="app-shell">
<header class="toolbar">
<div class="app-shell" class:player-mode={isPlayerMode}>
<header class="toolbar" class:auto-hide={isPlayerMode} class:toolbar-visible={isPlayerMode && showToolbar} class:toolbar-hidden={isPlayerMode && !showToolbar}>
<UrlInput />
<button
class="layout-btn"
onclick={toggleClipListPosition}
title={preferences.clipListPosition === 'bottom'
? 'Move clip list to sidebar'
: 'Move clip list to bottom'}
class="mode-btn"
onclick={toggleMode}
title={isPlayerMode ? 'Switch to Clipper (C)' : 'Switch to Player (P)'}
>
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
{isPlayerMode ? 'Player' : 'Clipper'}
</button>
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
{#if !isPlayerMode}
<button
class="layout-btn"
onclick={toggleClipListPosition}
title={preferences.clipListPosition === 'bottom'
? 'Move clip list to sidebar'
: 'Move clip list to bottom'}
>
{#if preferences.clipListPosition === 'bottom'}<PanelLeft size={16} />{:else}<PanelBottom size={16} />{/if}
</button>
{/if}
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About"><Info size={16} /></button>
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences"><Settings size={16} /></button>
</header>
<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">
<main class="content" class:player-mode={isPlayerMode} class:layout-left={!isPlayerMode && isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
{#if !isPlayerMode}
<!-- Sidebar clip list: visible only in left mode -->
<div class="sidebar-area" class:hidden={!isLeft}>
<ClipList onExport={() => (showExportDialog = true)} position="left" />
</div>
<div
class="resize-handle"
class:active={isResizing}
class="sidebar-handle"
class:hidden={!isLeft}
class:active={isSidebarResizing}
role="slider"
aria-orientation="horizontal"
aria-label="Resize timeline"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
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={handleResizeStart}
onmousedown={handleSidebarResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowDown') {
const maxWidth = Math.round(windowWidth * 0.4);
const minWidth = Math.min(150, maxWidth);
if (e.key === 'ArrowRight') {
e.preventDefault();
timelineHeight = Math.min(500, timelineHeight + 10);
} else if (e.key === 'ArrowUp') {
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
timelineHeight = Math.max(40, timelineHeight - 10);
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
);
}
}}
></div>
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
{/if}
<!-- Video area: always mounted, never destroyed -->
<div class="video-area" class:player-video={isPlayerMode}>
<VideoPlayer bind:this={videoPlayerRef} bind:captionsEnabled playerControlsVisible={isPlayerMode && showPlayerControls} />
{#if isPlayerMode}
<PlayerControls
visible={showPlayerControls}
{captionsEnabled}
hasCaptions={session.hasCaptions || false}
onToggleCaptions={handleToggleCaptions}
onOpenCaptionSettings={() => {}}
onCutClip={cutClip}
/>
<PlayerTimeline visible={showPlayerControls} />
{:else}
<div transition:fade={{ duration: 200 }}>
<TransportControls bind:this={transportControls} />
</div>
{/if}
</div>
<!-- Bottom clip list: visible only in bottom mode -->
<div class="bottom-cliplist" class:hidden={isLeft}>
<ClipList onExport={() => (showExportDialog = true)} />
</div>
{#if !isPlayerMode}
<!-- Timeline section: always full width -->
<div class="lower-section" transition:fly={{ y: 50, duration: 250 }}>
<div
class="resize-handle"
class:active={isResizing}
role="slider"
aria-orientation="horizontal"
aria-label="Resize timeline"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
tabindex="0"
onmousedown={handleResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
timelineHeight = Math.min(500, timelineHeight + 10);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
timelineHeight = Math.max(40, timelineHeight - 10);
}
}}
></div>
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline
zoomRegion={pendingTimelineZoom}
onZoomApplied={() => { pendingTimelineZoom = null; }}
/>
</div>
</div>
<!-- Bottom clip list: visible only in bottom mode -->
<div class="bottom-cliplist" class:hidden={isLeft} transition:fly={{ y: 30, duration: 250 }}>
<ClipList onExport={() => (showExportDialog = true)} />
</div>
{/if}
</main>
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
{#if !isPlayerMode}
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
{/if}
</div>
<style>
@@ -422,6 +628,49 @@
background: var(--accent, #89b4fa);
}
/* --- Mode toggle button --- */
.mode-btn {
font-size: 12px;
padding: 4px 10px;
border-radius: 10px;
font-weight: 600;
letter-spacing: 0.3px;
}
/* --- Player mode layout --- */
.content.player-mode {
grid-template-areas: "video";
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.player-video {
position: relative;
overflow: hidden;
}
/* --- Toolbar auto-hide --- */
.toolbar.auto-hide {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 30;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toolbar.toolbar-hidden {
opacity: 0;
transform: translateY(-100%);
pointer-events: none;
}
.toolbar.toolbar-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.layout-btn {
font-size: 16px;
padding: 4px 8px;

View File

@@ -37,6 +37,15 @@ body {
height: 100vh;
}
.content {
transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease;
}
/* Player mode: app shell becomes relative for overlay positioning */
.app-shell.player-mode {
position: relative;
}
button {
cursor: pointer;
border: none;

View File

@@ -81,3 +81,17 @@ export async function exportClips(
throw e;
}
}
export async function saveScreenshot(
outputDir: string,
videoTitle: string,
timestampLabel: string,
pngData: number[],
): Promise<string> {
return invoke('save_screenshot', {
outputDir,
videoTitle,
timestampLabel,
pngData,
});
}

View File

@@ -4,12 +4,46 @@
let { onClose }: { onClose: () => void } = $props();
let version = $state('');
let activeTab = $state<'about' | 'shortcuts'>('about');
$effect(() => {
getVersion().then((v) => {
version = v;
});
});
const globalShortcuts = [
{ key: 'Space', action: 'Play / Pause' },
{ key: 'K', action: 'Play / Pause' },
{ key: 'J', action: 'Shuttle slower (−0.25×)' },
{ key: 'L', action: 'Shuttle faster (+0.25×)' },
{ key: '←', action: 'Seek back 5s' },
{ key: '→', action: 'Seek forward 5s' },
{ key: 'Shift+←', action: 'Seek back 1s' },
{ key: 'Shift+→', action: 'Seek forward 1s' },
{ key: ',', action: 'Previous frame' },
{ key: '.', action: 'Next frame' },
{ key: '< (Shift+,)', action: 'Previous keyframe' },
{ key: '> (Shift+.)', action: 'Next keyframe' },
{ key: 'C / P', action: 'Toggle mode' },
{ key: 'S', action: 'Screenshot (with captions)' },
{ key: 'Shift+S', action: 'Screenshot (without captions)' },
{ key: 'Q', action: 'Quit' },
{ key: '⌘/', action: 'About' },
];
const playerShortcuts = [
{ key: 'F', action: 'Toggle fullscreen' },
{ key: 'X', action: 'Cut clip at playhead → Clipper' },
{ key: 'Z', action: 'Cut clip 10s before playhead → Clipper' },
];
const clipperShortcuts = [
{ key: 'I', action: 'Set in-point' },
{ key: 'O', action: 'Set out-point' },
{ key: 'Delete / ⌫', action: 'Remove selected clip' },
{ key: '⌘E', action: 'Export' },
];
</script>
<div
@@ -26,62 +60,134 @@
>
<div
class="dialog"
class:wide={activeTab === 'shortcuts'}
role="dialog"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
<stop offset="0%" stop-color="#fb923c"/>
<stop offset="100%" stop-color="#ea580c"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="90" fill="#18181b"/>
<g transform="translate(-3, 3)">
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<g transform="translate(3, -3)">
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
<g transform="rotate(20, 256, 256)">
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
</g>
</svg>
<h2>GUI Video Clipper</h2>
{#if version}
<span class="version">v{version}</span>
<!-- Tab bar -->
<div class="tab-bar" role="tablist">
<button
class="tab"
class:active={activeTab === 'about'}
role="tab"
aria-selected={activeTab === 'about'}
onclick={() => { activeTab = 'about'; }}
type="button"
>About</button>
<button
class="tab"
class:active={activeTab === 'shortcuts'}
role="tab"
aria-selected={activeTab === 'shortcuts'}
onclick={() => { activeTab = 'shortcuts'; }}
type="button"
>Shortcuts</button>
</div>
{#if activeTab === 'about'}
<!-- About content -->
<div class="about-content">
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
<stop offset="0%" stop-color="#fb923c"/>
<stop offset="100%" stop-color="#ea580c"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="90" fill="#18181b"/>
<g transform="translate(-3, 3)">
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<g transform="translate(3, -3)">
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
<g transform="rotate(20, 256, 256)">
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
</g>
</svg>
<h2>GUI Video Clipper</h2>
{#if version}
<span class="version">v{version}</span>
{/if}
<p class="author">by cottongin</p>
<p class="license">MIT License</p>
<a
class="repo-link"
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
target="_blank"
rel="noopener noreferrer"
>
code.cottongin.xyz/cottongin/gui-video-clipper
</a>
</div>
{:else}
<!-- Shortcuts content -->
<div class="shortcuts-content">
<section>
<h3>Global</h3>
<table>
<tbody>
{#each globalShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section>
<h3>Player Mode</h3>
<table>
<tbody>
{#each playerShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section>
<h3>Clipper Mode</h3>
<table>
<tbody>
{#each clipperShortcuts as { key, action }}
<tr>
<td class="key-cell"><kbd>{key}</kbd></td>
<td class="action-cell">{action}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
{/if}
<p class="author">by cottongin</p>
<p class="license">MIT License</p>
<a
class="repo-link"
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
target="_blank"
rel="noopener noreferrer"
>
code.cottongin.xyz/cottongin/gui-video-clipper
</a>
<div class="actions">
<button class="primary" onclick={onClose}>Close</button>
</div>
@@ -103,7 +209,7 @@
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
padding: 24px 32px 32px;
max-width: 360px;
width: 100%;
text-align: center;
@@ -111,6 +217,53 @@
flex-direction: column;
align-items: center;
gap: 8px;
transition: max-width 0.2s ease;
}
.dialog.wide {
max-width: 520px;
max-height: 70vh;
}
/* --- Tabs --- */
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
width: 100%;
margin-bottom: 12px;
}
.tab {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
padding: 8px 0;
cursor: pointer;
border-radius: 0;
}
.tab:hover {
color: var(--text-primary);
background: none;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: none;
}
/* --- About content --- */
.about-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.app-icon {
@@ -153,6 +306,64 @@
text-decoration: underline;
}
/* --- Shortcuts content --- */
.shortcuts-content {
width: 100%;
text-align: left;
overflow-y: auto;
max-height: calc(70vh - 140px);
display: flex;
flex-direction: column;
gap: 16px;
}
.shortcuts-content h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin: 0 0 6px;
}
.shortcuts-content table {
width: 100%;
border-collapse: collapse;
}
.shortcuts-content tr {
border-bottom: 1px solid var(--border);
}
.shortcuts-content tr:last-child {
border-bottom: none;
}
.key-cell {
width: 40%;
padding: 5px 8px 5px 0;
white-space: nowrap;
}
.action-cell {
padding: 5px 0;
color: var(--text-secondary);
font-size: 13px;
}
kbd {
display: inline-block;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
line-height: 1.4;
}
/* --- Actions --- */
.actions {
margin-top: 12px;
}

View File

@@ -0,0 +1,580 @@
<script lang="ts">
import SpeedSelector from '$lib/components/SpeedSelector.svelte';
import { session } from '$lib/stores/videoSession.svelte';
import {
seekTo,
seekBy,
togglePlayPause,
setVolume,
setMuted,
getVolume,
getMuted,
togglePiP,
isPiPActive,
toggleFullscreen,
isFullscreenActive,
setPlaybackRate,
getPlaybackRate,
} from '$lib/transport/playback';
import { formatTime, formatTimeParts } from '$lib/utils/time';
import {
VolumeX,
Volume1,
Volume2,
Rewind,
FastForward,
Pause,
Play,
Scissors,
ScissorsLineDashed,
Captions,
PictureInPicture2,
Maximize,
Minimize,
} from '@lucide/svelte';
let {
visible,
captionsEnabled,
hasCaptions,
onToggleCaptions,
onOpenCaptionSettings,
onCutClip,
}: {
visible: boolean;
captionsEnabled: boolean;
hasCaptions: boolean;
onToggleCaptions: () => void;
onOpenCaptionSettings: () => void;
onCutClip: (position: 'at' | 'before') => void;
} = $props();
let showSpeedSelector = $state(false);
let volume = $state(getVolume());
let isMuted = $state(getMuted());
let currentRate = $state(getPlaybackRate());
let pipActive = $state(false);
let fullscreenActive = $state(false);
// Seek bar state
let seekBarEl = $state<HTMLDivElement | null>(null);
let isSeeking = $state(false);
let hoverTime = $state<number | null>(null);
let hoverX = $state(0);
let progress = $derived(
session.duration > 0 ? session.currentTime / session.duration : 0
);
let currentParts = $derived(formatTimeParts(session.currentTime));
let durationParts = $derived(formatTimeParts(session.duration));
let volumePercent = $derived(
((isMuted ? 0 : volume) * 100).toFixed(1)
);
function handleSeekBarMouseDown(e: MouseEvent) {
isSeeking = true;
seekFromMouseEvent(e);
}
function handleSeekBarMouseMove(e: MouseEvent) {
if (!seekBarEl) return;
const rect = seekBarEl.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
hoverX = x;
hoverTime = (x / rect.width) * session.duration;
if (isSeeking) {
seekFromMouseEvent(e);
}
}
function handleSeekBarMouseUp() {
isSeeking = false;
}
function handleSeekBarMouseLeave() {
hoverTime = null;
if (isSeeking) {
isSeeking = false;
}
}
function seekFromMouseEvent(e: MouseEvent) {
if (!seekBarEl) return;
const rect = seekBarEl.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const time = (x / rect.width) * session.duration;
seekTo(time);
}
function handleVolumeChange(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value);
volume = val;
if (val > 0 && isMuted) {
isMuted = false;
}
setVolume(val);
if (val > 0) {
setMuted(false);
}
}
function handleToggleMute() {
isMuted = !isMuted;
setMuted(isMuted);
}
function handleSpeedSelect(rate: number) {
currentRate = rate;
setPlaybackRate(rate);
showSpeedSelector = false;
}
function handlePiP() {
togglePiP();
// State is updated via event listeners in VideoPlayer
setTimeout(() => {
pipActive = isPiPActive();
}, 100);
}
async function handleFullscreen() {
await toggleFullscreen();
fullscreenActive = await isFullscreenActive();
}
function handleCCContextMenu(e: MouseEvent) {
e.preventDefault();
onOpenCaptionSettings();
}
// Sync PiP/fullscreen/rate state periodically
$effect(() => {
const interval = setInterval(async () => {
pipActive = isPiPActive();
fullscreenActive = await isFullscreenActive();
currentRate = getPlaybackRate();
}, 500);
return () => clearInterval(interval);
});
</script>
<svelte:window
onmousemove={(e) => { if (isSeeking) seekFromMouseEvent(e); }}
onmouseup={() => { isSeeking = false; }}
/>
<div
class="player-controls-overlay"
class:visible
role="toolbar"
aria-label="Player controls"
>
<div class="player-controls-panel">
<div class="panel-content">
<!-- Controls row (top) -->
<div class="controls-row">
<!-- Left group: volume -->
<div class="controls-group left">
<button
class="ctrl-btn vol-btn"
onclick={handleToggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
type="button"
>
{#if isMuted || volume === 0}<VolumeX size={16} />{:else if volume < 0.5}<Volume1 size={16} />{:else}<Volume2 size={16} />{/if}
</button>
<input
type="range"
class="vol-slider"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
oninput={handleVolumeChange}
style="background: linear-gradient(to right, var(--accent, #89b4fa) {volumePercent}%, rgba(255,255,255,0.2) {volumePercent}%)"
/>
</div>
<!-- Center group: playback -->
<div class="controls-group center">
<button
class="ctrl-btn scissors-btn cut-before-btn"
onclick={() => onCutClip('before')}
title="Cut clip 10s before playhead (Z)"
type="button"
><ScissorsLineDashed size={36} /></button>
<button
class="ctrl-btn"
onclick={() => seekBy(-10)}
title="Skip back 10s"
type="button"
><Rewind size={36} /></button>
<button
class="ctrl-btn play-btn"
onclick={togglePlayPause}
title={session.isPlaying ? 'Pause' : 'Play'}
type="button"
>{#if session.isPlaying}<Pause size={48} />{:else}<Play size={48} />{/if}</button>
<button
class="ctrl-btn"
onclick={() => seekBy(10)}
title="Skip forward 10s"
type="button"
><FastForward size={36} /></button>
<button
class="ctrl-btn scissors-btn"
onclick={() => onCutClip('at')}
title="Cut clip at playhead (X)"
type="button"
><Scissors size={36} /></button>
</div>
<!-- Right group: settings -->
<div class="controls-group right">
<div class="speed-wrapper">
<button
class="ctrl-btn"
onclick={(e) => { e.stopPropagation(); showSpeedSelector = !showSpeedSelector; }}
title="Playback speed"
type="button"
>{currentRate}×</button>
{#if showSpeedSelector}
<SpeedSelector
{currentRate}
onSelect={handleSpeedSelect}
onClose={() => { showSpeedSelector = false; }}
/>
{/if}
</div>
{#if hasCaptions}
<button
class="ctrl-btn"
class:active={captionsEnabled}
onclick={onToggleCaptions}
oncontextmenu={handleCCContextMenu}
title={captionsEnabled ? 'Hide captions' : 'Show captions'}
type="button"
><Captions size={16} /></button>
{/if}
<button
class="ctrl-btn"
class:active={pipActive}
onclick={handlePiP}
title="Picture-in-Picture"
type="button"
><PictureInPicture2 size={16} /></button>
<button
class="ctrl-btn"
class:active={fullscreenActive}
onclick={handleFullscreen}
title={fullscreenActive ? 'Exit fullscreen' : 'Fullscreen'}
type="button"
>{#if fullscreenActive}<Minimize size={16} />{:else}<Maximize size={16} />{/if}</button>
</div>
</div>
<!-- Seek row: timestamp | seek bar | duration -->
<div class="seek-row">
<span class="time-display time-current">{currentParts.main}<span class="time-ms">{currentParts.ms}</span></span>
<div
class="seek-bar"
bind:this={seekBarEl}
onmousedown={handleSeekBarMouseDown}
onmousemove={handleSeekBarMouseMove}
onmouseup={handleSeekBarMouseUp}
onmouseleave={handleSeekBarMouseLeave}
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuenow={session.currentTime}
aria-valuemin={0}
aria-valuemax={session.duration}
>
<div class="seek-track">
<div class="seek-fill" style="width: {progress * 100}%"></div>
<div class="seek-thumb" style="left: {progress * 100}%"></div>
</div>
{#if hoverTime !== null}
<div class="seek-tooltip" style="left: {hoverX}px">
{formatTime(hoverTime)}
</div>
{/if}
</div>
<span class="time-display time-duration">{durationParts.main}</span>
</div>
</div>
</div>
</div>
<style>
/* --- Overlay: positioning + pointer-events only, NO opacity --- */
.player-controls-overlay {
position: absolute;
bottom: 84px;
left: 20%;
right: 20%;
pointer-events: none;
z-index: 20;
}
.player-controls-overlay.visible {
pointer-events: auto;
}
/* --- Panel: layout shell --- */
.player-controls-panel {
position: relative;
border-radius: 12px;
padding: 12px 16px;
}
/* Blur background — fades in/out with content */
.player-controls-panel::before {
content: '';
position: absolute;
inset: 0;
background: rgba(53, 54, 80, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 12px;
z-index: -1;
opacity: 0;
visibility: hidden;
transition: opacity 0.35s ease, visibility 0s 0.35s;
}
.player-controls-overlay.visible .player-controls-panel::before {
opacity: 1;
visibility: visible;
transition: opacity 0.35s ease, visibility 0s 0s;
}
/* Content wrapper — pure opacity fade */
.panel-content {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
opacity: 0;
transition: opacity 0.35s ease;
}
.player-controls-overlay.visible .panel-content {
opacity: 1;
}
/* --- Seek bar --- */
.seek-bar {
flex: 1;
min-width: 0;
height: 20px;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
}
.seek-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
position: relative;
transition: height 0.15s ease;
}
.seek-bar:hover .seek-track {
height: 6px;
}
.seek-fill {
height: 100%;
background: var(--accent, #89b4fa);
border-radius: 2px;
}
.seek-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
background: var(--accent, #89b4fa);
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.15s ease;
}
.seek-bar:hover .seek-thumb {
opacity: 1;
}
.seek-tooltip {
position: absolute;
bottom: 100%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: var(--text-primary, #cdd6f4);
font-family: var(--font-mono, monospace);
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
margin-bottom: 4px;
}
/* --- Controls row --- */
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.controls-group {
display: flex;
align-items: center;
gap: 2px;
}
.controls-group.left {
flex: 1;
justify-content: flex-start;
}
.controls-group.center {
flex: 0 0 auto;
opacity: 0.9;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.4));
}
/* Fill center transport icons (rewind, play/pause, forward) but not scissors */
.controls-group.center > .ctrl-btn:not(.scissors-btn):not(.cut-before-btn) :global(svg) {
fill: currentColor;
}
.controls-group.right {
flex: 1;
justify-content: flex-end;
}
/* --- Buttons --- */
.ctrl-btn {
background: transparent;
border: none;
color: var(--text-primary, #cdd6f4);
font-size: 16px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
min-width: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
}
.ctrl-btn:hover {
color: var(--accent, #89b4fa);
background: transparent;
}
.ctrl-btn.active {
color: var(--accent, #89b4fa);
}
.ctrl-btn.play-btn {
min-width: 72px;
}
/* Scissors buttons — dimmed via color, single-property transition */
.scissors-btn {
color: rgba(205, 214, 244, 0.5);
}
.scissors-btn:hover {
color: var(--accent, #89b4fa);
}
/* Cut-before: invisible by default, dimmed on self-hover */
.cut-before-btn {
color: transparent;
}
.cut-before-btn:hover {
color: rgba(205, 214, 244, 0.5);
}
/* --- Volume slider (custom styled to match seek bar) --- */
.vol-slider {
-webkit-appearance: none;
appearance: none;
width: 70px;
height: 4px;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent, #89b4fa);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.vol-slider:hover::-webkit-slider-thumb {
opacity: 1;
}
/* --- Time --- */
.time-display {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 14px;
color: var(--text-secondary, #a6adc8);
white-space: nowrap;
}
.time-current {
min-width: 5.5ch;
text-align: left;
}
.time-duration {
min-width: 5.5ch;
text-align: right;
}
.time-ms {
font-size: 12px;
opacity: 0;
transition: opacity 0.15s ease;
}
.seek-row:hover .time-ms {
opacity: 1;
}
/* --- Seek row: single-line timestamp | bar | duration --- */
.seek-row {
display: flex;
align-items: center;
gap: 10px;
}
/* --- Speed wrapper --- */
.speed-wrapper {
position: relative;
}
</style>

View File

@@ -0,0 +1,196 @@
<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
if (session.duration > 0) {
drawPlayhead(ctx, timelineState, session.currentTime);
}
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>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
let {
currentRate,
onSelect,
onClose,
}: {
currentRate: number;
onSelect: (rate: number) => void;
onClose: () => void;
} = $props();
function handleSelect(rate: number) {
onSelect(rate);
onClose();
}
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.speed-selector')) {
onClose();
}
}
</script>
<svelte:window onclick={handleWindowClick} />
<div class="speed-selector" role="menu">
{#each SPEED_OPTIONS as rate (rate)}
<button
class="speed-option"
class:active={Math.abs(currentRate - rate) < 0.01}
role="menuitem"
onclick={() => handleSelect(rate)}
>
<span class="check">{Math.abs(currentRate - rate) < 0.01 ? '✓' : ''}</span>
{rate}×
</button>
{/each}
</div>
<style>
.speed-selector {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
border-radius: 8px;
padding: 4px;
display: flex;
flex-direction: column;
min-width: 80px;
z-index: 50;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.speed-option {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
color: var(--text-primary, #cdd6f4);
font-size: 13px;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
}
.speed-option:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--accent, #89b4fa);
}
.speed-option.active {
color: var(--accent, #89b4fa);
font-weight: 600;
}
.check {
width: 14px;
font-size: 11px;
text-align: center;
}
</style>

View File

@@ -22,6 +22,14 @@
type ClipDragTarget = { clipId: string; edge: 'start' | 'end' };
let {
zoomRegion,
onZoomApplied,
}: {
zoomRegion?: { start: number; end: number } | null;
onZoomApplied?: () => void;
} = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let minimapCanvas = $state<HTMLCanvasElement | null>(null);
let containerEl = $state<HTMLDivElement | null>(null);
@@ -189,6 +197,19 @@
}
});
$effect(() => {
if (zoomRegion && session.duration > 0) {
const clipLen = zoomRegion.end - zoomRegion.start;
const pad = Math.max(clipLen * 0.2, 2);
const vs = Math.max(0, zoomRegion.start - pad);
const ve = Math.min(session.duration, zoomRegion.end + pad);
timelineState.visibleStart = vs;
timelineState.visibleEnd = ve;
timelineState.zoom = session.duration / (ve - vs);
onZoomApplied?.();
}
});
$effect(() => {
if (!containerEl) return;
const observer = new ResizeObserver((entries) => {

View File

@@ -14,6 +14,17 @@
getMuted,
type TransportKeyAction,
} from '$lib/transport/playback';
import {
ChevronFirst,
ChevronLast,
SkipBack,
SkipForward,
Pause,
Play,
VolumeX,
Volume1,
Volume2,
} from '@lucide/svelte';
let hasKeyframes = $derived(session.keyframePositions.length > 0);
@@ -50,15 +61,15 @@
title="Previous keyframe (Shift+,)"
type="button"
>
◄K
<ChevronFirst size={16} />
</button>
<button onclick={() => seekBy(-5)} title="Seek back 5s (←)" type="button">-5s</button>
<button onclick={() => seekBy(-1)} title="Seek back 1s (Shift+←)" type="button">-1s</button>
<button onclick={() => stepFrame(-1)} title="Previous frame (,)" type="button">◄|</button>
<button onclick={() => stepFrame(-1)} title="Previous frame (,)" type="button"><SkipBack size={16} /></button>
<button onclick={togglePlayPause} title="Play/Pause (Space / K)" type="button">
{session.isPlaying ? '❚❚' : '▶'}
{#if session.isPlaying}<Pause size={16} />{:else}<Play size={16} />{/if}
</button>
<button onclick={() => stepFrame(1)} title="Next frame (.)" type="button">|►</button>
<button onclick={() => stepFrame(1)} title="Next frame (.)" type="button"><SkipForward size={16} /></button>
<button onclick={() => seekBy(1)} title="Seek forward 1s (Shift+→)" type="button">+1s</button>
<button onclick={() => seekBy(5)} title="Seek forward 5s (→)" type="button">+5s</button>
<button
@@ -67,7 +78,7 @@
title="Next keyframe (Shift+.)"
type="button"
>
K►
<ChevronLast size={16} />
</button>
</div>
@@ -85,11 +96,11 @@
type="button"
>
{#if isMuted || volume === 0}
🔇
<VolumeX size={14} />
{:else if volume < 0.5}
🔉
<Volume1 size={14} />
{:else}
🔊
<Volume2 size={14} />
{/if}
</button>
<input

View File

@@ -1,6 +1,10 @@
<script lang="ts">
import { session, upgradePreview, dismissUpgradeToast } from '$lib/stores/videoSession.svelte';
import { setVideoElement } from '$lib/transport/playback';
import {
setVideoElement,
togglePlayPause as playbackTogglePlayPause,
toggleFullscreen,
} from '$lib/transport/playback';
import { preferences } from '$lib/stores/preferences.svelte';
import { parseVtt, getActiveCues, getActiveWords, type VttCue } from '$lib/utils/vttParser';
import { getMediaServerPort } from '$lib/bindings/video';
@@ -14,14 +18,23 @@
return `${r}, ${g}, ${b}`;
}
let {
captionsEnabled = $bindable(true),
playerControlsVisible = false,
}: {
captionsEnabled?: boolean;
playerControlsVisible?: boolean;
} = $props();
let videoElement = $state<HTMLVideoElement | null>(null);
let videoError = $state<string | null>(null);
let videoSrc = $state<string | null>(null);
let captionsEnabled = $state(true);
let showCaptionSettings = $state(false);
let parsedCues = $state<VttCue[]>([]);
let mediaServerPort = $state<number | null>(null);
let isPlayerMode = $derived(preferences.appMode === 'player');
// Fetch media server port on mount
$effect(() => {
getMediaServerPort().then((port) => {
@@ -108,6 +121,24 @@
showCaptionSettings = !showCaptionSettings;
}
let clickTimer: ReturnType<typeof setTimeout> | null = null;
function handleVideoClickWithDelay(_e: MouseEvent) {
if (!isPlayerMode) return;
// Distinguish single click from double click
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
// Double click
toggleFullscreen();
} else {
clickTimer = setTimeout(() => {
clickTimer = null;
playbackTogglePlayPause();
}, 250);
}
}
let lastTimeUpdate = 0;
const TIME_UPDATE_INTERVAL = 66;
@@ -184,6 +215,10 @@
export function getVideoElement(): HTMLVideoElement | null {
return videoElement;
}
export function getParsedCues(): VttCue[] {
return parsedCues;
}
</script>
<div class="video-player">
@@ -191,18 +226,20 @@
<video
bind:this={videoElement}
src={videoSrc}
crossorigin="anonymous"
ontimeupdate={handleTimeUpdate}
onplay={handlePlay}
onpause={handlePause}
onerror={handleError}
onloadeddata={handleLoadedData}
onclick={handleVideoClickWithDelay}
preload="metadata"
playsinline
>
<track kind="captions" />
</video>
{#if activeCues.length > 0}
<div class="caption-overlay" style={captionPosition}>
<div class="caption-overlay" class:lifted={isPlayerMode && playerControlsVisible} style={captionPosition}>
{#each activeCues as cue}
<span
class="caption-text"
@@ -219,7 +256,7 @@
{/each}
</div>
{/if}
{#if parsedCues.length > 0}
{#if !isPlayerMode && parsedCues.length > 0}
<div class="cc-controls">
<button
class="cc-toggle"
@@ -234,7 +271,7 @@
>⚙</button>
</div>
{/if}
{#if showCaptionSettings}
{#if !isPlayerMode && showCaptionSettings}
<CaptionSettingsPanel onClose={() => (showCaptionSettings = false)} />
{/if}
{#if session.showUpgradeToast}
@@ -295,6 +332,11 @@
pointer-events: none;
z-index: 5;
padding: 0 10%;
transition: transform 0.2s ease;
}
.caption-overlay.lifted {
transform: translateY(-160px);
}
.caption-text {

View File

@@ -49,6 +49,7 @@ export interface Preferences {
captionSettings: CaptionSettings;
clipListPosition: 'bottom' | 'left';
clipListWidth: number;
appMode: 'clipper' | 'player';
}
const DEFAULT_PREFERENCES: Preferences = {
@@ -57,6 +58,7 @@ const DEFAULT_PREFERENCES: Preferences = {
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
clipListPosition: 'bottom',
clipListWidth: 220,
appMode: 'clipper',
};
let store: Store | null = null;
@@ -81,6 +83,8 @@ export async function loadPreferences(): Promise<void> {
const savedClipListWidth = await store.get<number>('clipListWidth');
if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition;
if (savedClipListWidth != null) preferences.clipListWidth = savedClipListWidth;
const savedAppMode = await store.get<'clipper' | 'player'>('appMode');
if (savedAppMode) preferences.appMode = savedAppMode;
})();
await loadPromise;
}
@@ -95,6 +99,7 @@ export async function savePreferences(): Promise<void> {
await store.set('captionSettings', preferences.captionSettings);
await store.set('clipListPosition', preferences.clipListPosition);
await store.set('clipListWidth', preferences.clipListWidth);
await store.set('appMode', preferences.appMode);
await store.save();
}
@@ -122,3 +127,8 @@ export async function setClipListWidth(width: number): Promise<void> {
preferences.clipListWidth = width;
await savePreferences();
}
export async function setAppMode(mode: 'clipper' | 'player'): Promise<void> {
preferences.appMode = mode;
await savePreferences();
}

View File

@@ -1,4 +1,5 @@
import { session } from '$lib/stores/videoSession.svelte';
import { getCurrentWindow } from '@tauri-apps/api/window';
let _videoEl: HTMLVideoElement | null = null;
@@ -84,11 +85,12 @@ export function resetShuttleRate() {
}
}
export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
export function adjustShuttle(dir: 1 | -1): number {
const videoEl = getVideo();
if (!videoEl) return shuttleRate;
if (!videoEl) return 1;
const nextRate = Math.max(0.25, Math.min(4, shuttleRate + dir * 0.5));
const current = videoEl.playbackRate;
const nextRate = Math.max(0.25, Math.min(4, current + dir * 0.25));
videoEl.playbackRate = nextRate;
if (videoEl.paused) {
void videoEl.play();
@@ -96,6 +98,54 @@ export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
return nextRate;
}
export function togglePiP(): void {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch((e) => {
console.error('Exit PiP failed:', e);
});
} else {
const videoEl = getVideo();
if (videoEl && document.pictureInPictureEnabled) {
videoEl.requestPictureInPicture().catch((e) => {
console.error('PiP request failed:', e);
});
}
}
}
export function isPiPActive(): boolean {
return !!document.pictureInPictureElement;
}
export async function toggleFullscreen(): Promise<void> {
try {
const win = getCurrentWindow();
const isFs = await win.isFullscreen();
await win.setFullscreen(!isFs);
} catch (e) {
console.error('Fullscreen toggle failed:', e);
}
}
export async function isFullscreenActive(): Promise<boolean> {
try {
return await getCurrentWindow().isFullscreen();
} catch {
return false;
}
}
export function setPlaybackRate(rate: number): void {
const videoEl = getVideo();
if (videoEl) {
videoEl.playbackRate = rate;
}
}
export function getPlaybackRate(): number {
return getVideo()?.playbackRate ?? 1;
}
export type TransportKeyAction =
| 'play-pause'
| 'frame-back'

View File

@@ -0,0 +1,32 @@
export type QuickClipPosition = 'at' | 'before';
export function computeQuickClipRange(
currentTime: number,
duration: number,
position: QuickClipPosition,
clipDuration = 10,
): { start: number; end: number } | null {
if (duration === 0) return null;
let start: number;
let end: number;
switch (position) {
case 'at':
start = currentTime;
end = Math.min(currentTime + clipDuration, duration);
break;
case 'before':
start = Math.max(currentTime - clipDuration, 0);
end = currentTime;
break;
default: {
const _exhaustive: never = position;
void _exhaustive;
return null;
}
}
if (start >= end) return null;
return { start, end };
}

212
src/lib/utils/screenshot.ts Normal file
View File

@@ -0,0 +1,212 @@
import type { CaptionSettings } from '$lib/stores/preferences.svelte';
import type { VttCue } from '$lib/utils/vttParser';
import { getActiveCues, getActiveWords } from '$lib/utils/vttParser';
import { saveScreenshot } from '$lib/bindings/export';
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return { r, g, b };
}
function renderCaptions(
ctx: CanvasRenderingContext2D,
cues: VttCue[],
currentTime: number,
canvasWidth: number,
canvasHeight: number,
settings: CaptionSettings,
scale: number,
): void {
const activeCues = getActiveCues(cues, currentTime);
if (activeCues.length === 0) return;
const fontSize = Math.round(settings.fontSize * scale);
const fontWeight = settings.bold ? 'bold' : 'normal';
const font = `${fontWeight} ${fontSize}px ${settings.fontFamily}`;
ctx.font = font;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const lineHeight = fontSize * 1.4;
const padding = Math.round(4 * scale);
const hPadding = Math.round(10 * scale);
const maxWidth = canvasWidth * 0.8;
const positionY =
settings.position === 'top'
? canvasHeight * 0.08
: canvasHeight * 0.92 - activeCues.length * lineHeight;
const centerX = canvasWidth / 2;
for (let i = 0; i < activeCues.length; i++) {
const cue = activeCues[i];
const y = positionY + i * lineHeight;
if (settings.wordHighlight && cue.words) {
const words = getActiveWords(cue, currentTime);
const fullText = words.map((w) => w.text).join(' ');
const textMetrics = ctx.measureText(fullText);
const textWidth = Math.min(textMetrics.width, maxWidth);
if (settings.backgroundEnabled) {
const { r, g, b } = hexToRgb(settings.backgroundColor);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${settings.backgroundOpacity})`;
ctx.fillRect(
centerX - textWidth / 2 - hPadding,
y - padding,
textWidth + hPadding * 2,
lineHeight + padding * 2,
);
}
let xOffset = centerX - textWidth / 2;
for (const word of words) {
const wordWithSpace = word.text + ' ';
if (word.spoken) {
ctx.fillStyle = settings.textColor;
ctx.globalAlpha = 1;
} else {
if (settings.dimmedColorMode === 'custom') {
ctx.fillStyle = settings.dimmedColor;
ctx.globalAlpha = 1;
} else {
ctx.fillStyle = settings.textColor;
ctx.globalAlpha = settings.dimmedOpacity;
}
}
applyTextEffects(ctx, wordWithSpace, xOffset, y, settings, scale);
ctx.textAlign = 'left';
ctx.fillText(wordWithSpace, xOffset, y);
xOffset += ctx.measureText(wordWithSpace).width;
ctx.globalAlpha = 1;
}
ctx.textAlign = 'center';
} else {
const textMetrics = ctx.measureText(cue.text);
const textWidth = Math.min(textMetrics.width, maxWidth);
if (settings.backgroundEnabled) {
const { r, g, b } = hexToRgb(settings.backgroundColor);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${settings.backgroundOpacity})`;
ctx.fillRect(
centerX - textWidth / 2 - hPadding,
y - padding,
textWidth + hPadding * 2,
lineHeight + padding * 2,
);
}
ctx.fillStyle = settings.textColor;
applyTextEffects(ctx, cue.text, centerX, y, settings, scale);
ctx.fillText(cue.text, centerX, y, maxWidth);
}
}
}
function applyTextEffects(
ctx: CanvasRenderingContext2D,
_text: string,
_x: number,
_y: number,
settings: CaptionSettings,
scale: number,
): void {
if (settings.shadowEnabled) {
const depth = settings.shadowDepth * scale;
ctx.shadowColor = settings.shadowColor;
ctx.shadowOffsetX = depth;
ctx.shadowOffsetY = depth;
ctx.shadowBlur = depth * 2;
} else if (settings.textOutline) {
ctx.shadowColor = 'rgba(0,0,0,0.9)';
ctx.shadowOffsetX = 1 * scale;
ctx.shadowOffsetY = 1 * scale;
ctx.shadowBlur = 2 * scale;
} else {
ctx.shadowColor = 'transparent';
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.shadowBlur = 0;
}
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('Canvas toBlob returned null'));
},
'image/png',
);
});
}
export interface ScreenshotOptions {
currentTime: number;
videoTitle: string;
outputDirectory: string;
withCaptions: boolean;
captionsEnabled: boolean;
captionSettings: CaptionSettings;
parsedCues: VttCue[];
}
export async function captureFrame(options: ScreenshotOptions): Promise<string> {
const videoEl = document.querySelector('video');
if (!videoEl) throw new Error('No video element found');
if (videoEl.readyState < 2) throw new Error('Video not ready for capture');
const nativeWidth = videoEl.videoWidth;
const nativeHeight = videoEl.videoHeight;
if (nativeWidth === 0 || nativeHeight === 0) {
throw new Error('Video has zero dimensions');
}
const canvas = document.createElement('canvas');
canvas.width = nativeWidth;
canvas.height = nativeHeight;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get canvas 2D context');
ctx.drawImage(videoEl, 0, 0, nativeWidth, nativeHeight);
if (
options.withCaptions &&
options.captionsEnabled &&
options.parsedCues.length > 0
) {
const renderedHeight = videoEl.clientHeight || 600;
const scale = nativeHeight / renderedHeight;
renderCaptions(
ctx,
options.parsedCues,
options.currentTime,
nativeWidth,
nativeHeight,
options.captionSettings,
scale,
);
}
const blob = await canvasToBlob(canvas);
const buffer = await blob.arrayBuffer();
const pngData = Array.from(new Uint8Array(buffer));
const totalMs = Math.round(options.currentTime * 1000);
const timestampLabel = `${totalMs}ms`;
const savedPath = await saveScreenshot(
options.outputDirectory,
options.videoTitle,
timestampLabel,
pngData,
);
return savedPath;
}

View File

@@ -14,6 +14,20 @@ export function formatTime(totalSeconds: number): string {
return `${mm}:${ss}.${ms}`;
}
export function formatTimeParts(totalSeconds: number): { main: string; ms: string } {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const millis = Math.round((totalSeconds % 1) * 1000);
const mm = String(minutes).padStart(2, '0');
const ss = String(seconds).padStart(2, '0');
const ms = String(millis).padStart(3, '0');
const main = hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
return { main, ms: `.${ms}` };
}
export function parseTime(str: string): number | null {
const hmsMatch = str.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
if (hmsMatch) {

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import AboutDialog from '$lib/components/AboutDialog.svelte';
vi.mock('@tauri-apps/api/app', () => ({
getVersion: vi.fn().mockResolvedValue('0.2.1'),
}));
describe('AboutDialog', () => {
it('renders the About tab by default', () => {
const { getByText } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
expect(getByText('GUI Video Clipper')).toBeTruthy();
expect(getByText('by cottongin')).toBeTruthy();
});
it('shows two tabs: About and Shortcuts', () => {
const { getByRole } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
const tablist = getByRole('tablist');
expect(tablist).toBeTruthy();
expect(tablist.querySelectorAll('[role="tab"]')).toHaveLength(2);
});
it('switches to Shortcuts tab on click', async () => {
const { getByText, getAllByText } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
const shortcutsTab = getByText('Shortcuts');
await fireEvent.click(shortcutsTab);
expect(getAllByText('Play / Pause').length).toBeGreaterThan(0);
expect(getByText('Toggle mode')).toBeTruthy();
expect(getByText('Player Mode')).toBeTruthy();
expect(getByText('Clipper Mode')).toBeTruthy();
});
it('switches back to About tab', async () => {
const { getByText, getByRole } = render(AboutDialog, {
props: { onClose: vi.fn() },
});
await fireEvent.click(getByRole('tab', { name: 'Shortcuts' }));
await fireEvent.click(getByRole('tab', { name: 'About' }));
expect(getByText('GUI Video Clipper')).toBeTruthy();
expect(getByText('by cottongin')).toBeTruthy();
});
it('calls onClose when Close button is clicked', async () => {
const onClose = vi.fn();
const { getByText } = render(AboutDialog, {
props: { onClose },
});
await fireEvent.click(getByText('Close'));
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import SpeedSelector from '$lib/components/SpeedSelector.svelte';
describe('SpeedSelector', () => {
it('renders all speed options', () => {
const { getByText } = render(SpeedSelector, {
props: { currentRate: 1, onSelect: vi.fn(), onClose: vi.fn() },
});
expect(getByText('0.5×')).toBeTruthy();
expect(getByText('0.75×')).toBeTruthy();
expect(getByText('1×')).toBeTruthy();
expect(getByText('1.25×')).toBeTruthy();
expect(getByText('1.5×')).toBeTruthy();
expect(getByText('2×')).toBeTruthy();
});
it('highlights the current rate', () => {
const { container } = render(SpeedSelector, {
props: { currentRate: 1.5, onSelect: vi.fn(), onClose: vi.fn() },
});
const activeBtn = container.querySelector('.speed-option.active');
expect(activeBtn).toBeTruthy();
expect(activeBtn!.textContent).toContain('1.5×');
});
it('calls onSelect when an option is clicked', async () => {
const onSelect = vi.fn();
const { getByText } = render(SpeedSelector, {
props: { currentRate: 1, onSelect, onClose: vi.fn() },
});
await fireEvent.click(getByText('2×'));
expect(onSelect).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the Tauri store before importing the module under test
vi.mock('@tauri-apps/plugin-store', () => {
const mockStore = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
save: vi.fn().mockResolvedValue(undefined),
};
return {
load: vi.fn().mockResolvedValue(mockStore),
Store: vi.fn(),
};
});
describe('preferences store — appMode', () => {
beforeEach(() => {
vi.resetModules();
});
it('defaults appMode to clipper', async () => {
const { preferences } = await import('$lib/stores/preferences.svelte');
expect(preferences.appMode).toBe('clipper');
});
it('setAppMode updates appMode to player', async () => {
const { preferences, setAppMode } = await import('$lib/stores/preferences.svelte');
await setAppMode('player');
expect(preferences.appMode).toBe('player');
});
it('setAppMode updates appMode back to clipper', async () => {
const { preferences, setAppMode } = await import('$lib/stores/preferences.svelte');
await setAppMode('player');
await setAppMode('clipper');
expect(preferences.appMode).toBe('clipper');
});
});

View File

@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the videoSession store (playback.ts imports it)
vi.mock('$lib/stores/videoSession.svelte', () => ({
session: {
duration: 120,
currentTime: 0,
fps: 30,
isPlaying: false,
keyframePositions: [],
},
}));
// Mock Tauri window API for fullscreen
const mockTauriWindow = {
isFullscreen: vi.fn().mockResolvedValue(false),
setFullscreen: vi.fn().mockResolvedValue(undefined),
};
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => mockTauriWindow,
}));
import {
setVideoElement,
togglePiP,
isPiPActive,
toggleFullscreen,
isFullscreenActive,
setPlaybackRate,
getPlaybackRate,
adjustShuttle,
} from '$lib/transport/playback';
function createMockVideoElement(): HTMLVideoElement {
const el = document.createElement('video');
// PiP
(el as any).requestPictureInPicture = vi.fn().mockResolvedValue({});
// Fullscreen
el.requestFullscreen = vi.fn().mockResolvedValue(undefined);
return el;
}
describe('playback — PiP helpers', () => {
let mockVideo: HTMLVideoElement;
beforeEach(() => {
mockVideo = createMockVideoElement();
setVideoElement(mockVideo);
// Reset PiP state
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
writable: true,
configurable: true,
});
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
writable: true,
configurable: true,
});
});
it('isPiPActive returns false when no PiP element', () => {
expect(isPiPActive()).toBe(false);
});
it('togglePiP calls requestPictureInPicture', () => {
togglePiP();
expect((mockVideo as any).requestPictureInPicture).toHaveBeenCalled();
});
it('isPiPActive returns true when PiP element exists', () => {
Object.defineProperty(document, 'pictureInPictureElement', {
value: mockVideo,
writable: true,
configurable: true,
});
expect(isPiPActive()).toBe(true);
});
});
describe('playback — fullscreen helpers', () => {
beforeEach(() => {
mockTauriWindow.isFullscreen.mockResolvedValue(false);
mockTauriWindow.setFullscreen.mockResolvedValue(undefined);
});
it('isFullscreenActive returns false when not fullscreen', async () => {
expect(await isFullscreenActive()).toBe(false);
});
it('toggleFullscreen calls setFullscreen(true) when not fullscreen', async () => {
mockTauriWindow.isFullscreen.mockResolvedValue(false);
await toggleFullscreen();
expect(mockTauriWindow.setFullscreen).toHaveBeenCalledWith(true);
});
it('toggleFullscreen calls setFullscreen(false) when fullscreen', async () => {
mockTauriWindow.isFullscreen.mockResolvedValue(true);
await toggleFullscreen();
expect(mockTauriWindow.setFullscreen).toHaveBeenCalledWith(false);
});
it('isFullscreenActive returns true when fullscreen', async () => {
mockTauriWindow.isFullscreen.mockResolvedValue(true);
expect(await isFullscreenActive()).toBe(true);
});
});
describe('playback — playback rate helpers', () => {
let mockVideo: HTMLVideoElement;
beforeEach(() => {
mockVideo = createMockVideoElement();
setVideoElement(mockVideo);
});
it('getPlaybackRate returns 1 by default', () => {
expect(getPlaybackRate()).toBe(1);
});
it('setPlaybackRate changes the video playback rate', () => {
setPlaybackRate(1.5);
expect(mockVideo.playbackRate).toBe(1.5);
});
it('getPlaybackRate returns the current rate', () => {
setPlaybackRate(2);
expect(getPlaybackRate()).toBe(2);
});
it('getPlaybackRate returns 1 when no video element', () => {
setVideoElement(null);
expect(getPlaybackRate()).toBe(1);
});
});
describe('playback — adjustShuttle', () => {
let mockVideo: HTMLVideoElement;
beforeEach(() => {
mockVideo = createMockVideoElement();
setVideoElement(mockVideo);
mockVideo.playbackRate = 1;
Object.defineProperty(mockVideo, 'paused', {
value: true,
writable: true,
configurable: true,
});
mockVideo.play = vi.fn().mockResolvedValue(undefined);
});
it('increases rate by 0.25 when dir is 1', () => {
const result = adjustShuttle(1);
expect(result).toBe(1.25);
expect(mockVideo.playbackRate).toBe(1.25);
});
it('decreases rate by 0.25 when dir is -1', () => {
const result = adjustShuttle(-1);
expect(result).toBe(0.75);
expect(mockVideo.playbackRate).toBe(0.75);
});
it('reads current rate from video element, not external state', () => {
mockVideo.playbackRate = 0.75;
const result = adjustShuttle(1);
expect(result).toBe(1);
});
it('clamps to minimum 0.25', () => {
mockVideo.playbackRate = 0.25;
const result = adjustShuttle(-1);
expect(result).toBe(0.25);
});
it('clamps to maximum 4', () => {
mockVideo.playbackRate = 4;
const result = adjustShuttle(1);
expect(result).toBe(4);
});
it('starts playback if paused', () => {
adjustShuttle(1);
expect(mockVideo.play).toHaveBeenCalled();
});
it('returns 1 when no video element', () => {
setVideoElement(null);
expect(adjustShuttle(1)).toBe(1);
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { computeQuickClipRange } from '$lib/utils/clipRange';
describe('computeQuickClipRange', () => {
it('returns null when duration is 0', () => {
expect(computeQuickClipRange(5, 0, 'at')).toBeNull();
});
it('"at" creates clip from currentTime to currentTime+10', () => {
const result = computeQuickClipRange(30, 120, 'at');
expect(result).toEqual({ start: 30, end: 40 });
});
it('"before" creates clip from currentTime-10 to currentTime', () => {
const result = computeQuickClipRange(30, 120, 'before');
expect(result).toEqual({ start: 20, end: 30 });
});
it('"at" clamps end to duration', () => {
const result = computeQuickClipRange(115, 120, 'at');
expect(result).toEqual({ start: 115, end: 120 });
});
it('"before" clamps start to 0', () => {
const result = computeQuickClipRange(3, 120, 'before');
expect(result).toEqual({ start: 0, end: 3 });
});
it('accepts custom clip duration', () => {
const result = computeQuickClipRange(50, 120, 'at', 20);
expect(result).toEqual({ start: 50, end: 70 });
});
it('"at" at time 0 works', () => {
const result = computeQuickClipRange(0, 120, 'at');
expect(result).toEqual({ start: 0, end: 10 });
});
it('"before" at exact duration works', () => {
const result = computeQuickClipRange(120, 120, 'before');
expect(result).toEqual({ start: 110, end: 120 });
});
it('"at" returns null when currentTime equals duration', () => {
expect(computeQuickClipRange(120, 120, 'at')).toBeNull();
});
});

View File

@@ -3,17 +3,19 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelteTesting } from '@testing-library/svelte/vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const host = process.env.TAURI_DEV_HOST;
const libAlias = path.resolve(__dirname, 'src/lib');
export default defineConfig({
plugins: [svelte()],
plugins: [svelte(), svelteTesting()],
resolve: {
alias: {
$lib: libAlias,
},
...(process.env.VITEST ? { conditions: ['browser'] } : {}),
},
clearScreen: false,
server: {