Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cee840b225
|
|||
|
1b035c9af6
|
|||
|
c1de18715e
|
|||
|
619fe59dbe
|
|||
|
f6ad41f5fd
|
|||
|
d7a207154a
|
|||
|
507e3b79b7
|
|||
|
ac7cb70cb7
|
|||
|
d964c97fe6
|
|||
|
be27b1b3c6
|
|||
|
646f2e511b
|
|||
|
d8a6e0edd0
|
|||
|
21a5445143
|
|||
|
2e36d16bfe
|
|||
|
0f2a523cc2
|
|||
|
c00c215d33
|
|||
|
980f31a414
|
|||
|
d847a0cff2
|
|||
|
0e84e66cf7
|
|||
|
711f18ffe5
|
|||
|
7e838dc391
|
|||
|
1a86a34152
|
|||
|
2472decb20
|
|||
|
3e219b7760
|
|||
|
199ad1ce3b
|
|||
|
d36138c550
|
|||
|
56686a36e6
|
|||
|
f717687b1b
|
|||
|
bb5fa4bd63
|
|||
|
d9f58747a4
|
|||
|
bd96da4dc9
|
|||
|
8bcec23d8c
|
|||
|
4f7458d8f8
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
730
docs/superpowers/plans/2026-09-22-v020-player-mode-bugfixes.md
Normal file
730
docs/superpowers/plans/2026-09-22-v020-player-mode-bugfixes.md
Normal 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`.
|
||||
1974
docs/superpowers/plans/2026-09-22-v020-player-mode.md
Normal file
1974
docs/superpowers/plans/2026-09-22-v020-player-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
994
docs/superpowers/plans/2026-09-23-v021-polish-and-cut-clip.md
Normal file
994
docs/superpowers/plans/2026-09-23-v021-polish-and-cut-clip.md
Normal 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"
|
||||
```
|
||||
@@ -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
|
||||
331
docs/superpowers/specs/2026-09-22-v020-player-mode-design.md
Normal file
331
docs/superpowers/specs/2026-09-22-v020-player-mode-design.md
Normal 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.
|
||||
@@ -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
33
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -1281,7 +1281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gui-video-clipper"
|
||||
version = "0.1.3"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"dirs",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
427
src/App.svelte
427
src/App.svelte
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
580
src/lib/components/PlayerControls.svelte
Normal file
580
src/lib/components/PlayerControls.svelte
Normal 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>
|
||||
196
src/lib/components/PlayerTimeline.svelte
Normal file
196
src/lib/components/PlayerTimeline.svelte
Normal 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>
|
||||
89
src/lib/components/SpeedSelector.svelte
Normal file
89
src/lib/components/SpeedSelector.svelte
Normal 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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
32
src/lib/utils/clipRange.ts
Normal file
32
src/lib/utils/clipRange.ts
Normal 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
212
src/lib/utils/screenshot.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
59
tests/lib/components/AboutDialog.test.ts
Normal file
59
tests/lib/components/AboutDialog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
tests/lib/components/SpeedSelector.test.ts
Normal file
35
tests/lib/components/SpeedSelector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
tests/lib/stores/preferences.test.ts
Normal file
38
tests/lib/stores/preferences.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
191
tests/lib/transport/playback.test.ts
Normal file
191
tests/lib/transport/playback.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
tests/lib/utils/clipRange.test.ts
Normal file
47
tests/lib/utils/clipRange.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user