Compare commits
31 Commits
c90b6765b7
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
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).
|
||||||
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",
|
"name": "gui-video-clipper",
|
||||||
"version": "0.1.3",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gui-video-clipper",
|
"name": "gui-video-clipper",
|
||||||
"version": "0.1.3",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lucide/svelte": "^1.47.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2",
|
"@tauri-apps/plugin-process": "^2",
|
||||||
@@ -196,7 +197,6 @@
|
|||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -207,7 +207,6 @@
|
|||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
@@ -218,7 +217,6 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -228,20 +226,27 @@
|
|||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.31",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.150.0",
|
"version": "0.150.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||||
@@ -518,7 +523,6 @@
|
|||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
|
||||||
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
|
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8.9.0"
|
"acorn": "^8.9.0"
|
||||||
@@ -926,7 +930,6 @@
|
|||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
@@ -991,7 +994,6 @@
|
|||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
@@ -1037,7 +1039,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -1057,7 +1058,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -1093,7 +1093,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -1186,7 +1185,6 @@
|
|||||||
"version": "5.9.4",
|
"version": "5.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
|
||||||
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
|
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
@@ -1220,14 +1218,12 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "2.3.7",
|
"version": "2.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz",
|
||||||
"integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==",
|
"integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||||
@@ -1359,7 +1355,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
@@ -1677,7 +1672,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
@@ -1701,7 +1695,6 @@
|
|||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
@@ -1981,7 +1974,6 @@
|
|||||||
"version": "5.57.1",
|
"version": "5.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz",
|
||||||
"integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
|
"integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
@@ -2452,7 +2444,6 @@
|
|||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
||||||
"integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==",
|
"integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gui-video-clipper",
|
"name": "gui-video-clipper",
|
||||||
"version": "0.1.3",
|
"version": "0.2.1",
|
||||||
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lucide/svelte": "^1.47.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2",
|
"@tauri-apps/plugin-process": "^2",
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -1281,7 +1281,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gui-video-clipper"
|
name = "gui-video-clipper"
|
||||||
version = "0.1.3"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gui-video-clipper"
|
name = "gui-video-clipper"
|
||||||
version = "0.1.3"
|
version = "0.2.1"
|
||||||
description = "A macOS GUI app for clipping online videos"
|
description = "A macOS GUI app for clipping online videos"
|
||||||
authors = ["cottongin"]
|
authors = ["cottongin"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"store:allow-set",
|
"store:allow-set",
|
||||||
"store:allow-save",
|
"store:allow-save",
|
||||||
"store:allow-load",
|
"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::models::{ExportConfig, ExportScope};
|
||||||
use crate::services::clip_exporter;
|
use crate::services::clip_exporter;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[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]
|
#[tauri::command]
|
||||||
pub async fn export_clips(
|
pub async fn export_clips(
|
||||||
config: ExportConfig,
|
config: ExportConfig,
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ pub fn run() {
|
|||||||
media_analysis::check_embedded_subtitles,
|
media_analysis::check_embedded_subtitles,
|
||||||
media_analysis::list_system_fonts,
|
media_analysis::list_system_fonts,
|
||||||
export::export_clips,
|
export::export_clips,
|
||||||
|
export::save_screenshot,
|
||||||
cache::load_cached_analysis,
|
cache::load_cached_analysis,
|
||||||
cache::save_analysis_to_cache,
|
cache::save_analysis_to_cache,
|
||||||
cache::clear_video_cache,
|
cache::clear_video_cache,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "GUI Video Clipper",
|
"productName": "GUI Video Clipper",
|
||||||
"version": "0.1.3",
|
"version": "0.2.1",
|
||||||
"identifier": "xyz.cottongin.gui-video-clipper",
|
"identifier": "xyz.cottongin.gui-video-clipper",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
427
src/App.svelte
427
src/App.svelte
@@ -10,12 +10,29 @@
|
|||||||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||||
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||||
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
||||||
import { loadPreferences, preferences, setClipListPosition, setClipListWidth } from '$lib/stores/preferences.svelte';
|
import PlayerControls from '$lib/components/PlayerControls.svelte';
|
||||||
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
|
||||||
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
|
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 { session } from '$lib/stores/videoSession.svelte';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
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 showSetupWizard = $state(true);
|
||||||
let showPreferences = $state(false);
|
let showPreferences = $state(false);
|
||||||
@@ -24,6 +41,7 @@
|
|||||||
let shuttleRate = $state(1);
|
let shuttleRate = $state(1);
|
||||||
|
|
||||||
let transportControls = $state<TransportControls | null>(null);
|
let transportControls = $state<TransportControls | null>(null);
|
||||||
|
let videoPlayerRef = $state<VideoPlayer | null>(null);
|
||||||
|
|
||||||
// Resizable split: timeline height in px (clip list gets the rest)
|
// Resizable split: timeline height in px (clip list gets the rest)
|
||||||
let timelineHeight = $state(180);
|
let timelineHeight = $state(180);
|
||||||
@@ -36,6 +54,23 @@
|
|||||||
let sidebarResizeStartWidth = $state(0);
|
let sidebarResizeStartWidth = $state(0);
|
||||||
let windowWidth = $state(window.innerWidth);
|
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');
|
let isLeft = $derived(preferences.clipListPosition === 'left');
|
||||||
|
|
||||||
$effect(() => {
|
$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) {
|
function dispatchTransport(action: TransportKeyAction) {
|
||||||
transportControls?.handleKeyAction(action);
|
if (transportControls) {
|
||||||
|
transportControls.handleKeyAction(action);
|
||||||
|
} else {
|
||||||
|
runTransportAction(action, markInPoint, markOutPoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResizeStart(e: MouseEvent) {
|
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) {
|
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
@@ -118,11 +239,19 @@
|
|||||||
break;
|
break;
|
||||||
case ',':
|
case ',':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatchTransport(e.shiftKey ? 'keyframe-back' : 'frame-back');
|
dispatchTransport('frame-back');
|
||||||
break;
|
break;
|
||||||
case '.':
|
case '.':
|
||||||
e.preventDefault();
|
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;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -132,20 +261,52 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5');
|
dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5');
|
||||||
break;
|
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':
|
||||||
case 'I':
|
case 'I':
|
||||||
e.preventDefault();
|
if (!isPlayerMode) {
|
||||||
dispatchTransport('mark-in');
|
e.preventDefault();
|
||||||
|
dispatchTransport('mark-in');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'o':
|
case 'o':
|
||||||
case 'O':
|
case 'O':
|
||||||
e.preventDefault();
|
if (!isPlayerMode) {
|
||||||
dispatchTransport('mark-out');
|
e.preventDefault();
|
||||||
|
dispatchTransport('mark-out');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'j':
|
case 'j':
|
||||||
case 'J':
|
case 'J':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
shuttleRate = adjustShuttle(-1, shuttleRate);
|
shuttleRate = adjustShuttle(-1);
|
||||||
break;
|
break;
|
||||||
case 'k':
|
case 'k':
|
||||||
case 'K':
|
case 'K':
|
||||||
@@ -155,13 +316,15 @@
|
|||||||
case 'l':
|
case 'l':
|
||||||
case 'L':
|
case 'L':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
shuttleRate = adjustShuttle(1, shuttleRate);
|
shuttleRate = adjustShuttle(1);
|
||||||
break;
|
break;
|
||||||
case 'Delete':
|
case 'Delete':
|
||||||
case 'Backspace': {
|
case 'Backspace': {
|
||||||
e.preventDefault();
|
if (!isPlayerMode) {
|
||||||
const selectedId = getSelectedClipId();
|
e.preventDefault();
|
||||||
if (selectedId) removeClip(selectedId);
|
const selectedId = getSelectedClipId();
|
||||||
|
if (selectedId) removeClip(selectedId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'a':
|
case 'a':
|
||||||
@@ -170,11 +333,21 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'e':
|
case 'e':
|
||||||
if (e.metaKey) {
|
if (e.metaKey && !isPlayerMode) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showExportDialog = true;
|
showExportDialog = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'q':
|
||||||
|
case 'Q':
|
||||||
|
e.preventDefault();
|
||||||
|
exit(0);
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
e.preventDefault();
|
||||||
|
captureScreenshot(!e.shiftKey);
|
||||||
|
break;
|
||||||
case '/':
|
case '/':
|
||||||
if (e.metaKey) {
|
if (e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -192,6 +365,7 @@
|
|||||||
onmousemove={(e) => {
|
onmousemove={(e) => {
|
||||||
handleResizeMove(e);
|
handleResizeMove(e);
|
||||||
handleSidebarResizeMove(e);
|
handleSidebarResizeMove(e);
|
||||||
|
handlePlayerMouseMove(e);
|
||||||
}}
|
}}
|
||||||
onmouseup={() => {
|
onmouseup={() => {
|
||||||
handleResizeEnd();
|
handleResizeEnd();
|
||||||
@@ -222,97 +396,129 @@
|
|||||||
<ProcessingModal />
|
<ProcessingModal />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell" class:player-mode={isPlayerMode}>
|
||||||
<header class="toolbar">
|
<header class="toolbar" class:auto-hide={isPlayerMode} class:toolbar-visible={isPlayerMode && showToolbar} class:toolbar-hidden={isPlayerMode && !showToolbar}>
|
||||||
<UrlInput />
|
<UrlInput />
|
||||||
<button
|
<button
|
||||||
class="layout-btn"
|
class="mode-btn"
|
||||||
onclick={toggleClipListPosition}
|
onclick={toggleMode}
|
||||||
title={preferences.clipListPosition === 'bottom'
|
title={isPlayerMode ? 'Switch to Clipper (C)' : 'Switch to Player (P)'}
|
||||||
? 'Move clip list to sidebar'
|
|
||||||
: 'Move clip list to bottom'}
|
|
||||||
>
|
>
|
||||||
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
|
{isPlayerMode ? 'Player' : 'Clipper'}
|
||||||
</button>
|
</button>
|
||||||
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
|
{#if !isPlayerMode}
|
||||||
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
|
<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>
|
</header>
|
||||||
|
|
||||||
<main class="content" class:layout-left={isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
<main class="content" class:player-mode={isPlayerMode} class:layout-left={!isPlayerMode && isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
||||||
<!-- Sidebar clip list: visible only in left mode -->
|
{#if !isPlayerMode}
|
||||||
<div class="sidebar-area" class:hidden={!isLeft}>
|
<!-- Sidebar clip list: visible only in left mode -->
|
||||||
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
<div class="sidebar-area" class:hidden={!isLeft}>
|
||||||
</div>
|
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
||||||
<div
|
</div>
|
||||||
class="sidebar-handle"
|
|
||||||
class:hidden={!isLeft}
|
|
||||||
class:active={isSidebarResizing}
|
|
||||||
role="slider"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-label="Resize clip list sidebar"
|
|
||||||
aria-valuenow={preferences.clipListWidth}
|
|
||||||
aria-valuemin={150}
|
|
||||||
aria-valuemax={Math.round(windowWidth * 0.4)}
|
|
||||||
tabindex="0"
|
|
||||||
onmousedown={handleSidebarResizeStart}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
const maxWidth = Math.round(windowWidth * 0.4);
|
|
||||||
const minWidth = Math.min(150, maxWidth);
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault();
|
|
||||||
setClipListWidth(
|
|
||||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
|
|
||||||
);
|
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault();
|
|
||||||
setClipListWidth(
|
|
||||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Video + Transport: always rendered, never destroyed -->
|
|
||||||
<div class="video-area">
|
|
||||||
<VideoPlayer />
|
|
||||||
<TransportControls bind:this={transportControls} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timeline section: always full width -->
|
|
||||||
<div class="lower-section">
|
|
||||||
<div
|
<div
|
||||||
class="resize-handle"
|
class="sidebar-handle"
|
||||||
class:active={isResizing}
|
class:hidden={!isLeft}
|
||||||
|
class:active={isSidebarResizing}
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-orientation="horizontal"
|
aria-orientation="vertical"
|
||||||
aria-label="Resize timeline"
|
aria-label="Resize clip list sidebar"
|
||||||
aria-valuenow={timelineHeight}
|
aria-valuenow={preferences.clipListWidth}
|
||||||
aria-valuemin={40}
|
aria-valuemin={150}
|
||||||
aria-valuemax={500}
|
aria-valuemax={Math.round(windowWidth * 0.4)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onmousedown={handleResizeStart}
|
onmousedown={handleSidebarResizeStart}
|
||||||
onkeydown={(e) => {
|
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();
|
e.preventDefault();
|
||||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
setClipListWidth(
|
||||||
} else if (e.key === 'ArrowUp') {
|
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
setClipListWidth(
|
||||||
|
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
{/if}
|
||||||
<Timeline />
|
|
||||||
</div>
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom clip list: visible only in bottom mode -->
|
{#if !isPlayerMode}
|
||||||
<div class="bottom-cliplist" class:hidden={isLeft}>
|
<!-- Timeline section: always full width -->
|
||||||
<ClipList onExport={() => (showExportDialog = true)} />
|
<div class="lower-section" transition:fly={{ y: 50, duration: 250 }}>
|
||||||
</div>
|
<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>
|
</main>
|
||||||
|
|
||||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
{#if !isPlayerMode}
|
||||||
|
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -422,6 +628,49 @@
|
|||||||
background: var(--accent, #89b4fa);
|
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 {
|
.layout-btn {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ body {
|
|||||||
height: 100vh;
|
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 {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -81,3 +81,17 @@ export async function exportClips(
|
|||||||
throw e;
|
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 { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
let version = $state('');
|
let version = $state('');
|
||||||
|
let activeTab = $state<'about' | 'shortcuts'>('about');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
getVersion().then((v) => {
|
getVersion().then((v) => {
|
||||||
version = 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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -26,62 +60,134 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="dialog"
|
class="dialog"
|
||||||
|
class:wide={activeTab === 'shortcuts'}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
<!-- Tab bar -->
|
||||||
<defs>
|
<div class="tab-bar" role="tablist">
|
||||||
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
<button
|
||||||
<stop offset="0%" stop-color="#fb923c"/>
|
class="tab"
|
||||||
<stop offset="100%" stop-color="#ea580c"/>
|
class:active={activeTab === 'about'}
|
||||||
</linearGradient>
|
role="tab"
|
||||||
</defs>
|
aria-selected={activeTab === 'about'}
|
||||||
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
onclick={() => { activeTab = 'about'; }}
|
||||||
<g transform="translate(-3, 3)">
|
type="button"
|
||||||
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
>About</button>
|
||||||
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
<button
|
||||||
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
class="tab"
|
||||||
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
class:active={activeTab === 'shortcuts'}
|
||||||
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
role="tab"
|
||||||
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
aria-selected={activeTab === 'shortcuts'}
|
||||||
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
onclick={() => { activeTab = 'shortcuts'; }}
|
||||||
</g>
|
type="button"
|
||||||
<g transform="translate(3, -3)">
|
>Shortcuts</button>
|
||||||
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
|
</div>
|
||||||
<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"/>
|
{#if activeTab === 'about'}
|
||||||
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
<!-- About content -->
|
||||||
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
<div class="about-content">
|
||||||
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
<defs>
|
||||||
</g>
|
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
||||||
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
|
<stop offset="0%" stop-color="#fb923c"/>
|
||||||
<g transform="rotate(20, 256, 256)">
|
<stop offset="100%" stop-color="#ea580c"/>
|
||||||
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
|
</linearGradient>
|
||||||
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
|
</defs>
|
||||||
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
|
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
||||||
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
|
<g transform="translate(-3, 3)">
|
||||||
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
||||||
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
|
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
</g>
|
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
</svg>
|
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
<h2>GUI Video Clipper</h2>
|
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
{#if version}
|
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
<span class="version">v{version}</span>
|
</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}
|
{/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">
|
<div class="actions">
|
||||||
<button class="primary" onclick={onClose}>Close</button>
|
<button class="primary" onclick={onClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +209,7 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 32px;
|
padding: 24px 32px 32px;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -111,6 +217,53 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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 {
|
.app-icon {
|
||||||
@@ -153,6 +306,64 @@
|
|||||||
text-decoration: underline;
|
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 {
|
.actions {
|
||||||
margin-top: 12px;
|
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' };
|
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 canvas = $state<HTMLCanvasElement | null>(null);
|
||||||
let minimapCanvas = $state<HTMLCanvasElement | null>(null);
|
let minimapCanvas = $state<HTMLCanvasElement | null>(null);
|
||||||
let containerEl = $state<HTMLDivElement | 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(() => {
|
$effect(() => {
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
|||||||
@@ -14,6 +14,17 @@
|
|||||||
getMuted,
|
getMuted,
|
||||||
type TransportKeyAction,
|
type TransportKeyAction,
|
||||||
} from '$lib/transport/playback';
|
} from '$lib/transport/playback';
|
||||||
|
import {
|
||||||
|
ChevronFirst,
|
||||||
|
ChevronLast,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
VolumeX,
|
||||||
|
Volume1,
|
||||||
|
Volume2,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
|
||||||
let hasKeyframes = $derived(session.keyframePositions.length > 0);
|
let hasKeyframes = $derived(session.keyframePositions.length > 0);
|
||||||
|
|
||||||
@@ -50,15 +61,15 @@
|
|||||||
title="Previous keyframe (Shift+,)"
|
title="Previous keyframe (Shift+,)"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
◄K
|
<ChevronFirst size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => seekBy(-5)} title="Seek back 5s (←)" type="button">-5s</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={() => 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">
|
<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>
|
||||||
<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(1)} title="Seek forward 1s (Shift+→)" type="button">+1s</button>
|
||||||
<button onclick={() => seekBy(5)} title="Seek forward 5s (→)" type="button">+5s</button>
|
<button onclick={() => seekBy(5)} title="Seek forward 5s (→)" type="button">+5s</button>
|
||||||
<button
|
<button
|
||||||
@@ -67,7 +78,7 @@
|
|||||||
title="Next keyframe (Shift+.)"
|
title="Next keyframe (Shift+.)"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
K►
|
<ChevronLast size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,11 +96,11 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{#if isMuted || volume === 0}
|
{#if isMuted || volume === 0}
|
||||||
🔇
|
<VolumeX size={14} />
|
||||||
{:else if volume < 0.5}
|
{:else if volume < 0.5}
|
||||||
🔉
|
<Volume1 size={14} />
|
||||||
{:else}
|
{:else}
|
||||||
🔊
|
<Volume2 size={14} />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { session, upgradePreview, dismissUpgradeToast } from '$lib/stores/videoSession.svelte';
|
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 { preferences } from '$lib/stores/preferences.svelte';
|
||||||
import { parseVtt, getActiveCues, getActiveWords, type VttCue } from '$lib/utils/vttParser';
|
import { parseVtt, getActiveCues, getActiveWords, type VttCue } from '$lib/utils/vttParser';
|
||||||
import { getMediaServerPort } from '$lib/bindings/video';
|
import { getMediaServerPort } from '$lib/bindings/video';
|
||||||
@@ -14,14 +18,23 @@
|
|||||||
return `${r}, ${g}, ${b}`;
|
return `${r}, ${g}, ${b}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
captionsEnabled = $bindable(true),
|
||||||
|
playerControlsVisible = false,
|
||||||
|
}: {
|
||||||
|
captionsEnabled?: boolean;
|
||||||
|
playerControlsVisible?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let videoElement = $state<HTMLVideoElement | null>(null);
|
let videoElement = $state<HTMLVideoElement | null>(null);
|
||||||
let videoError = $state<string | null>(null);
|
let videoError = $state<string | null>(null);
|
||||||
let videoSrc = $state<string | null>(null);
|
let videoSrc = $state<string | null>(null);
|
||||||
let captionsEnabled = $state(true);
|
|
||||||
let showCaptionSettings = $state(false);
|
let showCaptionSettings = $state(false);
|
||||||
let parsedCues = $state<VttCue[]>([]);
|
let parsedCues = $state<VttCue[]>([]);
|
||||||
let mediaServerPort = $state<number | null>(null);
|
let mediaServerPort = $state<number | null>(null);
|
||||||
|
|
||||||
|
let isPlayerMode = $derived(preferences.appMode === 'player');
|
||||||
|
|
||||||
// Fetch media server port on mount
|
// Fetch media server port on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
getMediaServerPort().then((port) => {
|
getMediaServerPort().then((port) => {
|
||||||
@@ -108,6 +121,24 @@
|
|||||||
showCaptionSettings = !showCaptionSettings;
|
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;
|
let lastTimeUpdate = 0;
|
||||||
const TIME_UPDATE_INTERVAL = 66;
|
const TIME_UPDATE_INTERVAL = 66;
|
||||||
|
|
||||||
@@ -184,6 +215,10 @@
|
|||||||
export function getVideoElement(): HTMLVideoElement | null {
|
export function getVideoElement(): HTMLVideoElement | null {
|
||||||
return videoElement;
|
return videoElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getParsedCues(): VttCue[] {
|
||||||
|
return parsedCues;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="video-player">
|
<div class="video-player">
|
||||||
@@ -191,18 +226,20 @@
|
|||||||
<video
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
|
crossorigin="anonymous"
|
||||||
ontimeupdate={handleTimeUpdate}
|
ontimeupdate={handleTimeUpdate}
|
||||||
onplay={handlePlay}
|
onplay={handlePlay}
|
||||||
onpause={handlePause}
|
onpause={handlePause}
|
||||||
onerror={handleError}
|
onerror={handleError}
|
||||||
onloadeddata={handleLoadedData}
|
onloadeddata={handleLoadedData}
|
||||||
|
onclick={handleVideoClickWithDelay}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
playsinline
|
playsinline
|
||||||
>
|
>
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
</video>
|
</video>
|
||||||
{#if activeCues.length > 0}
|
{#if activeCues.length > 0}
|
||||||
<div class="caption-overlay" style={captionPosition}>
|
<div class="caption-overlay" class:lifted={isPlayerMode && playerControlsVisible} style={captionPosition}>
|
||||||
{#each activeCues as cue}
|
{#each activeCues as cue}
|
||||||
<span
|
<span
|
||||||
class="caption-text"
|
class="caption-text"
|
||||||
@@ -219,7 +256,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if parsedCues.length > 0}
|
{#if !isPlayerMode && parsedCues.length > 0}
|
||||||
<div class="cc-controls">
|
<div class="cc-controls">
|
||||||
<button
|
<button
|
||||||
class="cc-toggle"
|
class="cc-toggle"
|
||||||
@@ -234,7 +271,7 @@
|
|||||||
>⚙</button>
|
>⚙</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showCaptionSettings}
|
{#if !isPlayerMode && showCaptionSettings}
|
||||||
<CaptionSettingsPanel onClose={() => (showCaptionSettings = false)} />
|
<CaptionSettingsPanel onClose={() => (showCaptionSettings = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if session.showUpgradeToast}
|
{#if session.showUpgradeToast}
|
||||||
@@ -295,6 +332,11 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
padding: 0 10%;
|
padding: 0 10%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption-overlay.lifted {
|
||||||
|
transform: translateY(-160px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.caption-text {
|
.caption-text {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface Preferences {
|
|||||||
captionSettings: CaptionSettings;
|
captionSettings: CaptionSettings;
|
||||||
clipListPosition: 'bottom' | 'left';
|
clipListPosition: 'bottom' | 'left';
|
||||||
clipListWidth: number;
|
clipListWidth: number;
|
||||||
|
appMode: 'clipper' | 'player';
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: Preferences = {
|
const DEFAULT_PREFERENCES: Preferences = {
|
||||||
@@ -57,6 +58,7 @@ const DEFAULT_PREFERENCES: Preferences = {
|
|||||||
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
|
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
|
||||||
clipListPosition: 'bottom',
|
clipListPosition: 'bottom',
|
||||||
clipListWidth: 220,
|
clipListWidth: 220,
|
||||||
|
appMode: 'clipper',
|
||||||
};
|
};
|
||||||
|
|
||||||
let store: Store | null = null;
|
let store: Store | null = null;
|
||||||
@@ -81,6 +83,8 @@ export async function loadPreferences(): Promise<void> {
|
|||||||
const savedClipListWidth = await store.get<number>('clipListWidth');
|
const savedClipListWidth = await store.get<number>('clipListWidth');
|
||||||
if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition;
|
if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition;
|
||||||
if (savedClipListWidth != null) preferences.clipListWidth = savedClipListWidth;
|
if (savedClipListWidth != null) preferences.clipListWidth = savedClipListWidth;
|
||||||
|
const savedAppMode = await store.get<'clipper' | 'player'>('appMode');
|
||||||
|
if (savedAppMode) preferences.appMode = savedAppMode;
|
||||||
})();
|
})();
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
}
|
}
|
||||||
@@ -95,6 +99,7 @@ export async function savePreferences(): Promise<void> {
|
|||||||
await store.set('captionSettings', preferences.captionSettings);
|
await store.set('captionSettings', preferences.captionSettings);
|
||||||
await store.set('clipListPosition', preferences.clipListPosition);
|
await store.set('clipListPosition', preferences.clipListPosition);
|
||||||
await store.set('clipListWidth', preferences.clipListWidth);
|
await store.set('clipListWidth', preferences.clipListWidth);
|
||||||
|
await store.set('appMode', preferences.appMode);
|
||||||
await store.save();
|
await store.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,3 +127,8 @@ export async function setClipListWidth(width: number): Promise<void> {
|
|||||||
preferences.clipListWidth = width;
|
preferences.clipListWidth = width;
|
||||||
await savePreferences();
|
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 { session } from '$lib/stores/videoSession.svelte';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
|
||||||
let _videoEl: HTMLVideoElement | null = null;
|
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();
|
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;
|
videoEl.playbackRate = nextRate;
|
||||||
if (videoEl.paused) {
|
if (videoEl.paused) {
|
||||||
void videoEl.play();
|
void videoEl.play();
|
||||||
@@ -96,6 +98,54 @@ export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
|
|||||||
return nextRate;
|
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 =
|
export type TransportKeyAction =
|
||||||
| 'play-pause'
|
| 'play-pause'
|
||||||
| 'frame-back'
|
| '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}`;
|
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 {
|
export function parseTime(str: string): number | null {
|
||||||
const hmsMatch = str.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
const hmsMatch = str.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
||||||
if (hmsMatch) {
|
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 { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
const libAlias = path.resolve(__dirname, 'src/lib');
|
const libAlias = path.resolve(__dirname, 'src/lib');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte(), svelteTesting()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
$lib: libAlias,
|
$lib: libAlias,
|
||||||
},
|
},
|
||||||
|
...(process.env.VITEST ? { conditions: ['browser'] } : {}),
|
||||||
},
|
},
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user