Compare commits
56 Commits
d46973a719
...
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
|
|||
|
c90b6765b7
|
|||
|
23b30be357
|
|||
|
124542b4c4
|
|||
|
279e27a9b5
|
|||
|
8c271773bd
|
|||
|
27c199e090
|
|||
|
923c7b0e23
|
|||
|
b371ec6c3e
|
|||
|
0b8bec2496
|
|||
|
91550dacd6
|
|||
|
9752a6d614
|
|||
|
9499d776fd
|
|||
|
925a3fc054
|
|||
|
36ff3094b6
|
|||
|
2c3d035d1f
|
|||
|
b180d4324c
|
|||
|
b7a5caf4ae
|
|||
|
6818938cb0
|
|||
|
b7fb7929f0
|
|||
|
1773ae81e6
|
|||
|
dc3e7945c6
|
|||
|
8bd2b34103
|
|||
|
90f598050c
|
|||
|
2f810111cc
|
|||
|
2055524d30
|
@@ -0,0 +1,43 @@
|
||||
# Fix pyenv pot-provider detection & version bump to v0.1.1
|
||||
|
||||
## Task Description
|
||||
The built `.app` bundle was not detecting the `bgutil-ytdlp-pot-provider` yt-dlp plugin, even after the initial `fix_path_env()` implementation that added Homebrew paths to `PATH`. After fixing, bump version to 0.1.1, commit, push, and tag.
|
||||
|
||||
## Root Cause
|
||||
`fix_path_env()` added `/opt/homebrew/bin` to PATH, which found Homebrew's `yt-dlp` binary. However, the pot-provider plugin was pip-installed under **pyenv**'s Python (`~/.pyenv/versions/3.12.6/lib/python3.12/site-packages/yt_dlp_plugins`). Homebrew's yt-dlp uses a different Python runtime with no plugin directories (`[debug] Plugin directories: none`). The pyenv shim for `yt-dlp` correctly resolves to pyenv's Python where the plugin is installed.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### `src-tauri/src/lib.rs` — Enhanced `fix_path_env()`
|
||||
- Added `~/.pyenv/shims` to PATH **before** Homebrew paths so pyenv-managed yt-dlp (with pip plugins) takes priority
|
||||
- Sets `PYENV_ROOT` env var when pyenv exists so shims resolve correctly
|
||||
- Added `~/.nvm/versions/node/<latest>/bin` discovery for nvm-managed Node.js
|
||||
- Added `~/.deno/bin` to handled paths
|
||||
- Restructured the function for clarity: home-relative paths first, then system-wide paths
|
||||
|
||||
### Version Bump (via `scripts/bump-version.sh 0.1.1`)
|
||||
- `VERSION` → 0.1.1
|
||||
- `package.json` → 0.1.1
|
||||
- `src-tauri/Cargo.toml` → 0.1.1
|
||||
- `src-tauri/tauri.conf.json` → 0.1.1
|
||||
- `src-tauri/Cargo.lock` regenerated
|
||||
|
||||
### Git Operations
|
||||
- Committed: `d46973a` on `master`
|
||||
- Pushed to `origin/master`
|
||||
- Tagged: `v0.1.1` (annotated, pushed)
|
||||
|
||||
## Verification
|
||||
- `npm run check` — 0 errors, 0 warnings
|
||||
- `npm test` — 5 files, 31 tests passed
|
||||
- `cargo build` — clean compile
|
||||
- `npm run release` — built `.app` and `.dmg` successfully
|
||||
- Launched `.app` bundle — all 4 dependencies detected (ffmpeg, yt-dlp, JS runtime, bgutil-ytdlp-pot-provider)
|
||||
|
||||
## Lessons Learned
|
||||
- **Python version managers (pyenv) create shim binaries** that dispatch to a specific Python version. When a tool like yt-dlp is both Homebrew-installed and pyenv/pip-installed, PATH ordering determines which one runs — and they can have different plugin directories.
|
||||
- **Simulating the `.app` environment** with `env -i HOME="$HOME" PATH="<minimal>" yt-dlp -v` was the key diagnostic step that instantly reproduced the issue (`Plugin directories: none` vs `Plugin directories: ~/.pyenv/...`).
|
||||
- The `bump-version.sh` script works correctly for multi-file version bumps across `VERSION`, `package.json`, `Cargo.toml`, `tauri.conf.json`, and `Cargo.lock`.
|
||||
|
||||
## Follow-up Items
|
||||
- The `npm pkg set` command in `bump-version.sh` produces a cosmetic JSON error with `--json` flag before falling back; could be cleaned up.
|
||||
86
chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md
Normal file
86
chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# v0.1.2 Polish — Placeholders, Auto-Upgrade, Analysis Cache
|
||||
|
||||
**Date**: 2026-09-22 14:50 ET
|
||||
**Duration**: ~20 minutes (brainstorm) + ~20 minutes (execution)
|
||||
**Version**: 0.1.1 → 0.1.2
|
||||
**Commits**: 2f81011..925a3fc (11 commits)
|
||||
|
||||
## Task Description
|
||||
|
||||
Three polish features for v0.1.2:
|
||||
1. Fix misleading "Generating Waveform…" / "Generating Thumbnails…" placeholder text shown in the timeline before any video is loaded
|
||||
2. Automatically use cached HQ video files instead of prompting user to "Upgrade" when the export file is already downloaded
|
||||
3. Cache video analysis results (waveforms, keyframes, thumbnails, captions) to avoid reprocessing the same video on reload
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Feature 1: Idle-State Placeholder Text
|
||||
- **`src/lib/timeline/renderer.ts`**: Added `hasVideo` boolean parameter to `drawTimeline()` and `showLabel` to `drawPlaceholderLane()`. When no video is loaded, timeline lanes show empty backgrounds without misleading "Generating…" text.
|
||||
- **`src/lib/components/Timeline.svelte`**: Passes `session.status === 'ready'` as the `hasVideo` argument.
|
||||
|
||||
### Feature 2: Auto-Use Cached HQ Video
|
||||
- **`src/lib/stores/videoSession.svelte.ts`**: Restructured `beginDownload()` to check export (HQ) cache before preview cache. When HQ file exists on disk, it's used directly as the active video — no preview download, no upgrade toast. The upgrade toast is preserved for mid-session HQ completions (to avoid jarring video source swaps during playback).
|
||||
|
||||
### Feature 3: Analysis Cache System
|
||||
|
||||
**New Rust service** (`src-tauri/src/services/cache_manager.rs`):
|
||||
- Cache stored at `~/Library/Application Support/gui-video-clipper/cache/`
|
||||
- Videos keyed by SHA-256 URL hash (first 16 hex chars)
|
||||
- JSON manifest per video with waveform tiers, keyframe positions, thumbnail spritesheets, caption paths
|
||||
- Functions: `load_manifest`, `save_manifest`, `clear_cache_entry`, `clear_all_cache`, `get_cache_stats`
|
||||
- 5 unit tests for URL hashing and path computation
|
||||
|
||||
**New IPC commands** (`src-tauri/src/commands/cache.rs`):
|
||||
- `load_cached_analysis` / `save_analysis_to_cache` / `clear_video_cache` / `clear_all_cache` / `get_cache_stats` / `get_thumbnail_cache_dir` / `get_caption_cache_dir`
|
||||
- Path resolution: manifest stores relative paths, IPC layer resolves to absolute for frontend
|
||||
|
||||
**Updated `extract_thumbnails`**: Added optional `output_dir` parameter so thumbnails can be directed to cache directory instead of random temp dir.
|
||||
|
||||
**TypeScript bindings** (`src/lib/bindings/cache.ts`): Full wrapper layer for all cache IPC commands.
|
||||
|
||||
**Frontend integration** (`src/lib/stores/videoSession.svelte.ts`):
|
||||
- `triggerPostDownloadProcessing()` checks cache first — on hit, populates session state instantly and skips ffmpeg. On miss, extracts normally then saves to cache.
|
||||
- Thumbnails directed to cache dir via `getThumbnailCacheDir`
|
||||
- New `reprocessCurrentVideo()` export: clears cache entry and re-runs extraction
|
||||
|
||||
**Preferences panel**: Cache management section showing total size and entry count with "Clear All Cache" button.
|
||||
|
||||
**StatusBar**: Per-video "↻" reprocess button visible when processing is complete.
|
||||
|
||||
**Dependency added**: `sha2 = "0.10"` for URL hashing.
|
||||
|
||||
## Files Changed (15 files)
|
||||
|
||||
### New Files (3)
|
||||
- `src-tauri/src/services/cache_manager.rs`
|
||||
- `src-tauri/src/commands/cache.rs`
|
||||
- `src/lib/bindings/cache.ts`
|
||||
|
||||
### Modified Files (12)
|
||||
- `src/lib/timeline/renderer.ts`
|
||||
- `src/lib/components/Timeline.svelte`
|
||||
- `src/lib/stores/videoSession.svelte.ts`
|
||||
- `src-tauri/src/services/mod.rs`
|
||||
- `src-tauri/src/commands/mod.rs`
|
||||
- `src-tauri/src/lib.rs`
|
||||
- `src-tauri/Cargo.toml` + `Cargo.lock`
|
||||
- `src-tauri/src/commands/media_analysis.rs`
|
||||
- `src/lib/bindings/mediaAnalysis.ts`
|
||||
- `src/lib/components/PreferencesPanel.svelte`
|
||||
- `src/lib/components/StatusBar.svelte`
|
||||
- `VERSION`, `package.json`, `tauri.conf.json`
|
||||
|
||||
## Test Results
|
||||
- **Rust**: 56 tests pass, 0 warnings
|
||||
- **Frontend**: svelte-check 0 errors/warnings, vitest 31/31 pass
|
||||
|
||||
## Follow-Up Items
|
||||
- **HQ+toast edge case**: When video loaded with cached HQ file, `triggerPostDownloadProcessing` can still set `showUpgradeToast = true` even though playback is already HQ. Cosmetic — toast appears but does nothing harmful. Could add a guard `activeVideoPath !== exportFilePath` in a future pass.
|
||||
- **`now_iso8601` naming**: Function in `cache_manager.rs` returns Unix epoch seconds, not ISO 8601. Cosmetic naming issue.
|
||||
- **Caption cache dir**: `getCaptionCacheDir` is wired but not used yet — captions still go to temp dir. The manifest stores whatever path captions end up at. Could wire caption output to cache dir in a future pass for full persistence.
|
||||
- **Project files**: Cache manifest structure is designed as forward-looking scaffold for future project file save/load. No UI implemented yet.
|
||||
|
||||
## Lessons Learned
|
||||
- Checking export cache before preview cache in `beginDownload()` was a clean refactor that simplified the flow — early return avoids duplicate logic.
|
||||
- Inlining waveform/keyframe data in JSON manifests works well for the data sizes involved (~2 MB worst case). No need for binary serialization.
|
||||
- The `#![allow(dead_code)]` at module scope in `cache_manager.rs` was pragmatic but broader than ideal — individual annotations would be more precise. Resolved naturally once Task 5 wired up the commands.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Fix v0.1.2 Regressions
|
||||
|
||||
**Date:** 2026-09-22 15:21
|
||||
**Task:** Fix three regressions introduced by the v0.1.2 caching work
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Bug 1: Slow keyframe/thumbnail/caption generation (seconds → minutes)
|
||||
|
||||
**Root cause:** When the HQ export file was cached from a prior session, `beginDownload()` passed the HQ file to `triggerPostDownloadProcessing()`. All ffmpeg operations ran on the full-resolution file instead of the ~360p preview, causing 10-50x slowdown.
|
||||
|
||||
**Fix in `src/lib/stores/videoSession.svelte.ts`:** In the HQ early-return path, also check for cached preview and prefer it for ffmpeg analysis while still using HQ for playback:
|
||||
```typescript
|
||||
const previewForProcessing = await checkCachedDownload(session.title, 'preview').catch(() => null);
|
||||
triggerPostDownloadProcessing(previewForProcessing || exportCached);
|
||||
```
|
||||
|
||||
### Bug 2: Blank thumbnails
|
||||
|
||||
**Root cause:** Tauri's asset protocol scope in `tauri.conf.json` only allowed temp directories. The new thumbnail cache directory under `~/Library/Application Support/` was blocked, so `convertFileSrc()` requests were denied.
|
||||
|
||||
**Fix (3 files):**
|
||||
1. `src-tauri/src/services/cache_manager.rs`: Added `OnceLock<PathBuf>` + `init()` function so `cache_base_dir()` uses the Tauri-resolved app data directory (matching `$APPDATA`).
|
||||
2. `src-tauri/src/lib.rs`: Call `cache_manager::init(app.path().app_data_dir())` during setup.
|
||||
3. `src-tauri/tauri.conf.json`: Added `"$APPDATA/**"` to the asset protocol scope.
|
||||
|
||||
**Side effect:** Existing caches under `~/Library/Application Support/gui-video-clipper/cache/` are orphaned. New location is `~/Library/Application Support/xyz.cottongin.gui-video-clipper/cache/`. One-time cost for a brand-new feature.
|
||||
|
||||
### Bug 3: False upgrade toast when already on HQ
|
||||
|
||||
**Root cause:** `triggerPostDownloadProcessing()` unconditionally set `showUpgradeToast = true` when `exportStatus === 'complete'`, even when `activeVideoPath` was already the export file.
|
||||
|
||||
**Fix in `src/lib/stores/videoSession.svelte.ts`:** Added guard `&& session.activeVideoPath !== session.exportFilePath` to both toast-setting locations (cache-hit and cache-miss blocks).
|
||||
|
||||
### Defensive fix: ffmpeg `-y` flag
|
||||
|
||||
**File:** `src-tauri/src/services/thumbnail_extractor.rs`
|
||||
Added `-y` flag to the ffmpeg thumbnail extraction command to prevent hangs if re-extracting to a directory with existing frame files.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `src/lib/stores/videoSession.svelte.ts` — Fixes 1 and 3
|
||||
- `src-tauri/src/services/cache_manager.rs` — Fix 2 (OnceLock for app data dir)
|
||||
- `src-tauri/src/lib.rs` — Fix 2 (init cache manager in setup)
|
||||
- `src-tauri/tauri.conf.json` — Fix 2 (add $APPDATA to scope)
|
||||
- `src-tauri/src/services/thumbnail_extractor.rs` — Defensive `-y` flag
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. When adding a caching layer that moves files from temp dirs to persistent dirs, always verify that security scopes (Tauri asset protocol, CSP, etc.) cover the new location.
|
||||
2. When adding an "HQ early return" optimization, consider that downstream processing might still benefit from the smaller preview file — separate the "playback source" from the "analysis source."
|
||||
3. Toast/notification conditions should always guard against the "already in target state" case, not just the "target state reached" case.
|
||||
4. ffmpeg commands writing to potentially non-empty directories need `-y` to avoid interactive prompts that hang `.output()` calls.
|
||||
|
||||
## Follow-up Items
|
||||
|
||||
- Consider cleaning up orphaned caches under the old `gui-video-clipper` directory.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Fix Post-v0.1.2 Issues
|
||||
|
||||
**Date:** 2026-09-22 15:48
|
||||
**Task:** Fix broken keyframe detection, missing cached captions, and About dialog version duplication
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Issue 1: Keyframe buttons always greyed out
|
||||
|
||||
**Root cause:** ffprobe 8.x no longer populates the `flags` field. The parser in `keyframe_index.rs` expected 3-column CSV output (`media_type,pts_time,flags`) and checked `parts[2].contains('K')`. With ffprobe 8.0.1, the output was just timestamps (flags absent), so the parser's `parts.len() >= 3` check failed on every line, always returning `[]`.
|
||||
|
||||
Confirmed locally: `ffprobe -show_entries frame=pts_time,flags -of csv=p=0 -skip_frame nokey` produces:
|
||||
```
|
||||
0.000000,
|
||||
1.000000
|
||||
```
|
||||
|
||||
**Fix in `src-tauri/src/services/keyframe_index.rs`:**
|
||||
- Changed ffprobe command to only request `frame=pts_time` (dropped `flags` since `-skip_frame nokey` already filters to keyframes)
|
||||
- Rewrote `parse_ffprobe_output` to handle: (a) new format: plain timestamp per line with optional trailing comma, (b) legacy format: `section,pts_time[,flags]`
|
||||
- Updated all tests — 5 tests covering new format, legacy with flags, legacy with section prefix, empty, and whitespace
|
||||
|
||||
### Issue 2: Captions not cached or loaded from cache
|
||||
|
||||
**Root cause:** In `save_analysis_to_cache` (cache.rs), caption paths were relativized via `strip_prefix(&cache_dir)`. But captions live outside the cache dir (e.g. `/tmp/video-clipper/subtitles/embedded.vtt`), so `strip_prefix` always failed and `relative_caption` was `None`. Captions were never saved to the manifest.
|
||||
|
||||
**Fix (2 files):**
|
||||
1. `src-tauri/src/commands/cache.rs`: When `strip_prefix` fails, copy the caption file into `cache_dir/captions/` and store the relative path.
|
||||
2. `src/lib/stores/videoSession.svelte.ts`: In the cache-hit path of `triggerPostDownloadProcessing`, if `captionFilePath` is null (old caches without captions), fall back to `loadCaptionsWithCache(filePath)` for re-extraction.
|
||||
|
||||
### Issue 3: About dialog shows "v0.1.2 (0.1.2)"
|
||||
|
||||
**Root cause:** The native macOS About dialog shows "Version {CFBundleShortVersionString} ({CFBundleVersion})". Tauri sets both from the same `version` field, producing "Version 0.1.2 (0.1.2)".
|
||||
|
||||
**Fix in `src-tauri/src/lib.rs`:** Created a custom macOS app menu with `AboutMetadataBuilder` that only sets `short_version` (no build number). Also provides standard Edit menu (undo/redo/cut/copy/paste/select-all). Version is read at compile time via `env!("CARGO_PKG_VERSION")`.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `src-tauri/src/services/keyframe_index.rs` — Fix 1: ffprobe command + parser + tests
|
||||
- `src-tauri/src/commands/cache.rs` — Fix 2: copy captions into cache dir
|
||||
- `src/lib/stores/videoSession.svelte.ts` — Fix 2: caption fallback on cache hit
|
||||
- `src-tauri/src/lib.rs` — Fix 3: custom macOS menu
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo check`: 0 warnings, 0 errors
|
||||
- `cargo test`: 58 passed, 0 failed
|
||||
- `svelte-check`: 0 errors, 0 warnings
|
||||
- `ReadLints`: no issues
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. ffprobe output format can change across major versions. The `flags` field was silently dropped in ffprobe 8.x. Always test against the actual binary on the target system.
|
||||
2. When caching file references, files outside the cache directory need to be copied in — `strip_prefix` silently returns `None` on failure via `.ok()`, making the bug invisible.
|
||||
3. Native macOS About dialogs auto-format version info as "Version X (Build Y)". When both values are identical, it looks like a bug. Use `AboutMetadataBuilder` to control this.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Fix About Dialog & Reprocess Always Uses Low-Res
|
||||
|
||||
**Date**: 2026-09-22 16:07
|
||||
**Task**: Fix native About dialog duplication/icon + force reprocessing to always use low-res preview file
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### 1. Native About Dialog Still Showing (Duplicate Version)
|
||||
**Problem**: The macOS menu "About" item still opened the native About panel (which shows duplicate version strings "v0.1.2 (0.1.2)") instead of the custom Svelte `AboutDialog`.
|
||||
|
||||
**Fix**: Replaced the `AboutMetadataBuilder`-based `.about()` menu item with a custom `MenuItemBuilder` that emits a Tauri event (`open-about`). The frontend listens for this event via `listen('open-about', ...)` in `App.svelte` and toggles `showAboutDialog = true`.
|
||||
|
||||
**Files changed**:
|
||||
- `src-tauri/src/lib.rs` — Replaced `AboutMetadataBuilder` usage with `MenuItemBuilder` + `PredefinedMenuItem` for hide/show/quit. Added `.on_menu_event()` handler to emit `open-about` event.
|
||||
- `src/App.svelte` — Added `import { listen } from '@tauri-apps/api/event'` and a `$effect` that listens for `open-about` and opens the custom dialog.
|
||||
|
||||
### 2. Broken Icon in About Dialog
|
||||
**Problem**: The `<img src="/favicon.svg">` tag failed to load in the Tauri webview, showing a broken image placeholder.
|
||||
|
||||
**Fix**: Inlined the SVG directly into the `AboutDialog.svelte` component, eliminating the external file reference. Added `rx="90"` to the background rect for rounded-corner macOS icon styling.
|
||||
|
||||
**Files changed**:
|
||||
- `src/lib/components/AboutDialog.svelte` — Replaced `<img>` tag with inline `<svg>`.
|
||||
|
||||
### 3. Reprocessing Uses Full-Resolution File (Performance)
|
||||
**Problem**: `reprocessCurrentVideo()` used `session.activeVideoPath` which points to the HQ export file after upgrade. All ffmpeg analysis (waveform, keyframes, thumbnails) on the full-res file takes minutes instead of seconds.
|
||||
|
||||
**Fix**: `reprocessCurrentVideo()` now explicitly prefers `session.previewFilePath` for processing. If that equals the export file (or is null), it falls back to checking the disk cache for a low-res preview via `checkCachedDownload(title, 'preview')`. Only uses `activeVideoPath` as a last resort.
|
||||
|
||||
**Files changed**:
|
||||
- `src/lib/stores/videoSession.svelte.ts` — Rewrote `reprocessCurrentVideo()` to resolve the low-res preview path before triggering processing.
|
||||
|
||||
## Verification
|
||||
- `cargo check` — 0 errors, 0 warnings
|
||||
- `cargo test` — 58 tests passed
|
||||
- `svelte-check` — 0 errors, 0 warnings
|
||||
- `ReadLints` — No linter errors
|
||||
|
||||
## Lessons Learned
|
||||
- Tauri's `.about(Some(metadata))` on `SubmenuBuilder` always creates the *native* macOS About panel — there is no way to redirect it to a custom webview dialog. To intercept "About", you must use a regular `MenuItemBuilder` with `.on_menu_event()`.
|
||||
- Inline SVGs in Svelte components are more reliable than `<img src="...">` references in Tauri webviews, where path resolution can differ between dev and production builds.
|
||||
- When a session tracks both a preview and export file path, any function that runs ffmpeg analysis must explicitly resolve which path to use — defaulting to `activeVideoPath` is a footgun since that gets upgraded to HQ.
|
||||
@@ -0,0 +1,56 @@
|
||||
# v0.1.3 Polish/Tweaks Implementation Summary
|
||||
|
||||
**Date:** 2026-09-22 16:55 ET
|
||||
**Version:** 0.1.2 → 0.1.3
|
||||
|
||||
## Task Description
|
||||
|
||||
Implemented three UI polish features for v0.1.3 of the GUI Video Clipper (Tauri + Svelte 5):
|
||||
|
||||
1. **Adjustable clip list layout** — clip list can appear below the timeline (default) or as a left sidebar next to the video preview, with a toolbar toggle button and persisted preference
|
||||
2. **Timeline timestamp magnification** — increased base font from 10px to 12px, with a mouse-proximity magnification effect using cosine interpolation (~18px peak, 80px radius)
|
||||
3. **Timeline right-click context menu** — custom context menu replacing the default web menu, with Mark In/Out, Go to Clip Start/End, and Delete Clip actions
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Commits (oldest → newest)
|
||||
- `0b8bec2` — chore: bump version to 0.1.3
|
||||
- `b371ec6` — feat: adjustable clip list layout (bottom/left sidebar)
|
||||
- `923c7b0` — fix: clip list width edge cases (falsy check, narrow-window clamp)
|
||||
- `27c199e` — feat: larger timeline timestamps with mouse-proximity magnification
|
||||
- `8c27177` — feat: timeline right-click context menu with mark/seek/delete actions
|
||||
- `279e27a` — docs: user edits to v0.1.3 spec (version bump section)
|
||||
|
||||
### Files Modified
|
||||
- `VERSION` — new file, version 0.1.3
|
||||
- `package.json` — version bump
|
||||
- `package-lock.json` — lockfile sync
|
||||
- `src-tauri/tauri.conf.json` — version bump
|
||||
- `src-tauri/Cargo.toml` — version bump
|
||||
- `src-tauri/Cargo.lock` — lockfile sync
|
||||
- `src/lib/stores/preferences.svelte.ts` — added `clipListPosition` and `clipListWidth` preferences
|
||||
- `src/App.svelte` — dual layout modes (bottom/left), toggle button, sidebar resize
|
||||
- `src/lib/components/ClipList.svelte` — added `position` prop for sidebar styling
|
||||
- `src/lib/timeline/renderer.ts` — larger base font, magnification math in `drawTimeTicks()`
|
||||
- `src/lib/components/Timeline.svelte` — mouseX tracking, context menu wiring
|
||||
- `src/lib/components/TimelineContextMenu.svelte` — new context menu component
|
||||
|
||||
### Design & Plan Docs
|
||||
- `docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md` — design spec
|
||||
- `docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md` — implementation plan
|
||||
|
||||
## Process
|
||||
|
||||
Used subagent-driven development: 5 tasks dispatched as subagents with task reviews between each. Two reviewer findings on Task 2 (falsy width check, narrow-window clamp) were fixed before proceeding.
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
- The layout restructure required careful conditional rendering in App.svelte — the `{#if}/{:else}` approach creates two separate DOM trees. This means TransportControls gets remounted on layout toggle, which should be smoke-tested.
|
||||
- Context menu `$effect` for viewport clamping can flash position (0,0) for one frame before adjustment — initializing adjusted positions from the raw x/y props would eliminate this.
|
||||
- The bump-version.sh script doesn't sync package-lock.json — needed manual `npm install --package-lock-only`.
|
||||
|
||||
## Follow-Up Items
|
||||
|
||||
- [ ] Manual smoke test with `npm run tauri dev` — layout toggle, sidebar resize, timestamp magnification feel, context menu flow
|
||||
- [ ] Consider fixing first-frame context menu position flash
|
||||
- [ ] Consider adding `onresize` clamp for sidebar width when window shrinks below saved width
|
||||
@@ -0,0 +1,44 @@
|
||||
# Fix Resize + Waveform Alignment
|
||||
|
||||
**Date:** 2026-09-22 17:42
|
||||
**Task:** Implement the "Fix Resize + Waveform Alignment" plan to fix horizontal resize regression and waveform/lane alignment issues.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Resize Regression Fix — `src/App.svelte`
|
||||
- **Root cause:** After the CSS-grid refactor, `handleResizeMove` used `splitAreaEl.clientHeight` as the upper bound. Since `.lower-section` only contained the resize handle (5px) + timeline pane, `totalHeight ≈ timelineHeight + 5`, so the clamp `Math.min(totalHeight - 20, ...)` immediately locked the height.
|
||||
- **Fix:**
|
||||
- Removed `splitAreaEl` state variable.
|
||||
- Removed `bind:this={splitAreaEl}` from the `.lower-section` div.
|
||||
- Simplified `handleResizeMove` to use a fixed max of 500 (matching the existing keyboard resize handler): `Math.max(40, Math.min(500, resizeStartHeight + delta))`.
|
||||
|
||||
### 2. Dynamic Lane Layout — `src/lib/timeline/renderer.ts`
|
||||
- Removed fixed lane height constants (`THUMB_LANE_HEIGHT`, `WAVEFORM_LANE_HEIGHT`, `CONTROLS_LANE_HEIGHT`).
|
||||
- Added `LaneLayout` interface and `computeLanes(totalHeight)` function that dynamically computes lane positions proportionally based on total canvas height.
|
||||
- Controls lane: fixed 30px at bottom.
|
||||
- Clip lane: fixed 30px above controls.
|
||||
- Remaining space split 55/45 between thumbnails and waveform (with minimums of 20px and 15px).
|
||||
- Updated `drawTimeline` to call `computeLanes(height)` and pass layout to all sub-draw functions.
|
||||
- Updated `drawTimeTicks` to accept `LaneLayout` and use `lanes.controlsY` instead of `state.height - CONTROLS_LANE_HEIGHT`.
|
||||
|
||||
### 3. Clip Renderer Update — `src/lib/timeline/clipRenderer.ts`
|
||||
- Removed hardcoded `CLIP_LANE_Y` and `CLIP_LANE_HEIGHT` constants.
|
||||
- Updated `drawClips` and `hitTestClip` signatures to accept `LaneLayout` parameter.
|
||||
- All clip positioning now uses `lanes.clipY` and `lanes.clipHeight`.
|
||||
|
||||
### 4. Timeline Component Update — `src/lib/components/Timeline.svelte`
|
||||
- Imported `computeLanes` from renderer.
|
||||
- Updated `hitTestClip` call to pass `computeLanes(timelineState.height)`.
|
||||
|
||||
## Verification
|
||||
- `npm run check` (svelte-check): 0 errors, 0 warnings.
|
||||
|
||||
## Files Modified
|
||||
- `src/App.svelte`
|
||||
- `src/lib/timeline/renderer.ts`
|
||||
- `src/lib/timeline/clipRenderer.ts`
|
||||
- `src/lib/components/Timeline.svelte`
|
||||
|
||||
## Lessons Learned
|
||||
- When using CSS grid layouts, elements that previously represented the "total space" may now only contain their own children. Relying on `clientHeight` of such elements for resize calculations breaks — use fixed bounds instead.
|
||||
- Dynamic lane computation is preferable to fixed pixel constants because it adapts to any canvas height, preventing gaps or overlaps when the user resizes the timeline.
|
||||
@@ -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).
|
||||
1498
docs/superpowers/plans/2026-09-22-v0.1.2-polish.md
Normal file
1498
docs/superpowers/plans/2026-09-22-v0.1.2-polish.md
Normal file
File diff suppressed because it is too large
Load Diff
1060
docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md
Normal file
1060
docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md
Normal file
File diff suppressed because it is too large
Load Diff
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"
|
||||
```
|
||||
211
docs/superpowers/specs/2026-09-22-v0.1.2-polish-design.md
Normal file
211
docs/superpowers/specs/2026-09-22-v0.1.2-polish-design.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# v0.1.2 Polish: Placeholders, Auto-Upgrade, and Analysis Cache
|
||||
|
||||
**Date**: 2026-09-22
|
||||
**Version**: 0.1.2
|
||||
**Status**: Design
|
||||
|
||||
## Overview
|
||||
|
||||
Three polish features for v0.1.2:
|
||||
|
||||
1. Fix misleading placeholder text shown before any video is loaded
|
||||
2. Automatically use cached HQ video files instead of prompting to upgrade
|
||||
3. Cache video analysis results (waveforms, keyframes, thumbnails, captions) to avoid reprocessing
|
||||
|
||||
## 1. Idle-State Placeholder Text
|
||||
|
||||
### Problem
|
||||
|
||||
When the app first loads with no video, the timeline shows "Generating Waveform..." and "Generating Thumbnails..." in the placeholder lanes. No work is happening — this is misleading.
|
||||
|
||||
### Solution
|
||||
|
||||
Make `drawTimeline` context-aware by accepting a `hasVideo` boolean (derived from `session.status === 'ready'`).
|
||||
|
||||
- **No video loaded** (`hasVideo === false`): Draw empty lane backgrounds with no text.
|
||||
- **Video loaded, data pending** (`hasVideo === true`, data missing): Show "Generating Waveform..." / "Generating Thumbnails..." as today.
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `src/lib/timeline/renderer.ts`: Add `hasVideo` parameter to `drawTimeline()`. Update `drawPlaceholderLane()` to accept a boolean controlling whether to show text. When `hasVideo` is false, render the background rectangle but skip the text label.
|
||||
- `src/lib/components/Timeline.svelte`: Pass `session.status === 'ready'` as the `hasVideo` argument.
|
||||
|
||||
## 2. Auto-Use Cached HQ Video
|
||||
|
||||
### Problem
|
||||
|
||||
When a video's HQ (export) file is already cached on disk from a prior session, the app still downloads a low-res preview, then shows an "Upgrade" toast to switch to HQ. The user should get the HQ version immediately.
|
||||
|
||||
### Solution
|
||||
|
||||
At the start of `beginDownload()`, check the export cache first. If the HQ file exists:
|
||||
|
||||
- Set `activeVideoPath` to the HQ file immediately
|
||||
- Set `exportFilePath`, `exportStatus = 'complete'`, `exportProgress = 1.0`
|
||||
- Mark preview as complete too (skip the preview download entirely)
|
||||
- Run `triggerPostDownloadProcessing()` against the HQ file
|
||||
- No upgrade toast shown
|
||||
|
||||
When no cached HQ exists, the existing two-track download flow (preview first, background HQ) continues unchanged. The upgrade toast still appears when HQ completes mid-session — silently swapping the video source during active playback would be jarring.
|
||||
|
||||
### Behavior Matrix
|
||||
|
||||
| HQ cached? | Preview cached? | Behavior |
|
||||
|---|---|---|
|
||||
| Yes | (irrelevant) | Use HQ directly. No preview download. No toast. |
|
||||
| No | Yes | Use cached preview. Start background HQ download. Toast on HQ completion. |
|
||||
| No | No | Download preview, start background HQ. Toast on HQ completion. |
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `src/lib/stores/videoSession.svelte.ts`: Restructure `beginDownload()` to check export cache before preview cache. Add early-return path that sets HQ as active video.
|
||||
|
||||
## 3. Analysis Cache System
|
||||
|
||||
### Problem
|
||||
|
||||
Every time a video is loaded, waveforms, keyframes, and thumbnails are re-extracted from scratch via ffmpeg. For a 5-minute video this takes 10-30 seconds. Re-opening the same video should be instant.
|
||||
|
||||
### Architecture
|
||||
|
||||
#### Cache Directory
|
||||
|
||||
```
|
||||
~/Library/Application Support/gui-video-clipper/cache/
|
||||
<url-hash>/
|
||||
manifest.json
|
||||
thumbnails/
|
||||
frame_000001.jpg
|
||||
frame_000002.jpg
|
||||
...
|
||||
captions/
|
||||
en.vtt
|
||||
```
|
||||
|
||||
The URL hash is the first 16 hex characters of the SHA-256 of the source URL. This provides sufficient collision resistance while keeping paths short.
|
||||
|
||||
#### Manifest Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sourceUrl": "https://youtube.com/watch?v=abc123",
|
||||
"title": "Example Video",
|
||||
"duration": 312.5,
|
||||
"fps": 30.0,
|
||||
"createdAt": "2026-09-22T18:00:00Z",
|
||||
"lastAccessedAt": "2026-09-22T18:05:00Z",
|
||||
"waveformTiers": {
|
||||
"tier0": [0.1, 0.5, ...],
|
||||
"tier1": [0.1, 0.3, 0.5, ...],
|
||||
"tier2": [0.05, 0.1, 0.15, ...]
|
||||
},
|
||||
"keyframePositions": [0.0, 2.5, 5.0, 7.5],
|
||||
"thumbnailSpritesheets": [
|
||||
{
|
||||
"filePath": "thumbnails",
|
||||
"startIndex": 0,
|
||||
"count": 100,
|
||||
"thumbWidth": 160,
|
||||
"thumbHeight": 90,
|
||||
"columns": 10,
|
||||
"intervalSeconds": 1.0
|
||||
}
|
||||
],
|
||||
"captionFilePath": "captions/en.vtt"
|
||||
}
|
||||
```
|
||||
|
||||
**Data sizing**: Waveform tier2 is at most 200K floats (~1.6 MB as JSON). Tier0 and tier1 add ~100 KB combined. Keyframes are a few hundred entries. This all fits comfortably inline in JSON. Thumbnails are JPEG files on disk, referenced by relative path.
|
||||
|
||||
All paths in the manifest are relative to the cache entry directory.
|
||||
|
||||
#### New Rust Service: `cache_manager.rs`
|
||||
|
||||
```rust
|
||||
// Public API
|
||||
pub fn cache_dir_for_url(url: &str) -> PathBuf
|
||||
pub fn load_manifest(url: &str) -> Option<CacheManifest>
|
||||
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String>
|
||||
pub fn clear_cache_entry(url: &str) -> Result<(), String>
|
||||
pub fn clear_all_cache() -> Result<(), String>
|
||||
pub fn get_cache_stats() -> Result<CacheStats, String>
|
||||
pub fn thumbnail_cache_dir(url: &str) -> PathBuf
|
||||
pub fn caption_cache_dir(url: &str) -> PathBuf
|
||||
```
|
||||
|
||||
`CacheManifest` struct mirrors the JSON schema above. `CacheStats` contains `entry_count: usize` and `total_bytes: u64`.
|
||||
|
||||
#### New IPC Commands
|
||||
|
||||
Added to `src-tauri/src/commands/` (new file `cache.rs` or added to existing):
|
||||
|
||||
| Command | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `load_cached_analysis` | `(url: String) -> Option<CachedAnalysis>` | Load all cached data for a URL |
|
||||
| `save_analysis_to_cache` | `(url, waveform_tiers, keyframe_positions, thumbnail_spritesheets, caption_file_path) -> ()` | Persist analysis results |
|
||||
| `clear_video_cache` | `(url: String) -> ()` | Delete one video's cache |
|
||||
| `clear_all_cache` | `() -> ()` | Delete the entire cache dir |
|
||||
| `get_cache_stats` | `() -> CacheStats` | Get entry count and total size |
|
||||
| `get_thumbnail_cache_dir` | `(url: String) -> String` | Get the cache directory path for thumbnail output |
|
||||
| `get_caption_cache_dir` | `(url: String) -> String` | Get the cache directory path for caption output |
|
||||
|
||||
`CachedAnalysis` contains:
|
||||
- `waveformTiers: WaveformTiers`
|
||||
- `keyframePositions: Vec<f64>`
|
||||
- `thumbnailSpritesheets: Vec<ThumbnailSpritesheet>` (with absolute paths resolved from the cache dir)
|
||||
- `captionFilePath: Option<String>` (absolute path)
|
||||
|
||||
`save_analysis_to_cache` accepts the individual analysis fields rather than a wrapper struct, keeping the IPC boundary simple. The Rust side constructs the `CacheManifest` from these fields plus metadata (source URL, title, timestamps) looked up from the manifest or computed fresh.
|
||||
|
||||
#### Frontend Integration
|
||||
|
||||
**`videoSession.svelte.ts` — `triggerPostDownloadProcessing()`**:
|
||||
|
||||
1. Call `load_cached_analysis(session.url)` before running ffmpeg.
|
||||
2. On cache hit: populate session state directly, jump to `processingStep = 'done'`. Skip all ffmpeg extraction.
|
||||
3. On cache miss: run extraction as today. After all steps complete (waveform, keyframes, thumbnails, captions), call `save_analysis_to_cache(...)` to persist.
|
||||
|
||||
**New TypeScript bindings**: `src/lib/bindings/cache.ts` with wrapper functions for the new IPC commands.
|
||||
|
||||
#### Thumbnail Path Changes
|
||||
|
||||
Currently, `extract_thumbnails` (Rust command) generates a UUID-based temp dir for thumbnails. With caching:
|
||||
|
||||
- Add an optional `output_dir: Option<String>` parameter to the `extract_thumbnails` Tauri command. When provided, thumbnails are written there. When `None`, the existing UUID-based temp dir behavior is preserved as a fallback.
|
||||
- The frontend calls `get_thumbnail_cache_dir(session.url)` to get the cache-managed output path, then passes it to `extract_thumbnails`.
|
||||
- Similarly, `get_caption_cache_dir(session.url)` provides the path for caption file output.
|
||||
|
||||
### Cache Invalidation UX
|
||||
|
||||
**Preferences Panel** (`PreferencesPanel.svelte`):
|
||||
- New "Cache" section at the bottom
|
||||
- Display total cache size (e.g., "Cache: 450 MB across 12 videos")
|
||||
- "Clear All Cache" button with confirmation
|
||||
|
||||
**Per-Video Reprocess** (in `StatusBar.svelte` or near the transport controls):
|
||||
- A small "↻" (reprocess) button visible when a video is loaded and processing is complete (`processingStep === 'done'`)
|
||||
- Calls `clear_video_cache(session.url)` then re-runs `triggerPostDownloadProcessing()` on the current video file
|
||||
- This re-extracts analysis data only — it does not re-download the video
|
||||
|
||||
### Future: Project Files
|
||||
|
||||
The cache manifest structure is designed to be referenced by future project files. A project file would store:
|
||||
- The source URL
|
||||
- Clip regions (start/end times, labels, colors)
|
||||
- Caption settings
|
||||
- A reference to the cache entry (by URL hash)
|
||||
|
||||
This design means a project file doesn't duplicate analysis data — it points to the cache. If the cache is cleared, the project can re-derive everything from the source URL.
|
||||
|
||||
No project file save/load UI is implemented in this version. The manifest structure is the forward-looking scaffold.
|
||||
|
||||
### Dead Code Annotations
|
||||
|
||||
Any types or fields scaffolded for future project file support (e.g., fields in `CacheManifest` not yet read by the frontend) will be annotated with `#[allow(dead_code)]` and a `// Future: used by project file save/load` comment. This satisfies the requirement to handle dead code warnings proactively (item 3b).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Zero new warnings/errors**: All existing warnings and errors were recently resolved. Any new warnings introduced during implementation must be addressed immediately.
|
||||
- **macOS-only paths**: `~/Library/Application Support/` is macOS-specific, which matches the project's macOS-only target.
|
||||
- **Backward compatibility**: Existing temp-dir downloads (preview/export) continue to work as before. The cache is additive — it doesn't replace the download mechanism.
|
||||
201
docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md
Normal file
201
docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# v0.1.3 Polish & Tweaks — Design Spec
|
||||
|
||||
**Date:** 2026-09-22
|
||||
**Version:** 0.1.2 → 0.1.3
|
||||
|
||||
## Overview
|
||||
|
||||
Three UI polish features for v0.1.3: adjustable clip-list layout, improved timeline timestamp labels with a mouse-proximity magnification effect, and a custom right-click context menu on the timeline.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Adjustable Layout (Clip List Position)
|
||||
|
||||
### Current State
|
||||
|
||||
The main content area is a vertical stack:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Toolbar │
|
||||
├──────────────────────────────┤
|
||||
│ Video Preview │
|
||||
├──────────────────────────────┤
|
||||
│ Transport Controls │
|
||||
├──────────────────────────────┤
|
||||
│ Timeline / Thumbs / Waveform │
|
||||
├──────────────────────────────┤
|
||||
│ Clip List │
|
||||
├──────────────────────────────┤
|
||||
│ Status Bar │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Proposed: Two Layout Modes
|
||||
|
||||
A new `clipListPosition` preference (`'bottom' | 'left'`) controls where the clip list appears. Default: `'bottom'` (current behavior, unchanged).
|
||||
|
||||
When set to `'left'`, the layout becomes:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Toolbar │
|
||||
├──────────┬───────────────────────────────┤
|
||||
│ Clip │ Video Preview │
|
||||
│ List │ │
|
||||
│ │ │
|
||||
│ ├───────────────────────────────┤
|
||||
│ │ Transport Controls │
|
||||
├──────────┴───────────────────────────────┤
|
||||
│ Timeline / Thumbs / Waveform (full) │
|
||||
├──────────────────────────────────────────┤
|
||||
│ Status Bar │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Key details:
|
||||
|
||||
- The clip list shares horizontal space only with the video preview and transport controls. The timeline remains full-width underneath — no horizontal space is sacrificed for timeline real estate.
|
||||
- A vertical drag-to-resize handle sits between the clip list and the video area, allowing the user to adjust the sidebar width. Default width: ~220px. Min: 150px. Max: 40% of window width.
|
||||
- The existing vertical resize handle between timeline and clip list only appears in `'bottom'` mode. In `'left'` mode, the timeline height is controlled by the existing `timelineHeight` state (still resizable via a horizontal drag handle between the upper section and the timeline).
|
||||
|
||||
### Toggle Button
|
||||
|
||||
A button in the toolbar (alongside ⚙ and ℹ) toggles between `'bottom'` and `'left'` modes. The icon visually hints at the current layout — e.g. a stacked-rows icon for bottom mode, a sidebar icon for left mode. Clicking it switches immediately and persists the preference.
|
||||
|
||||
### Persistence
|
||||
|
||||
- Add `clipListPosition: 'bottom' | 'left'` to the `Preferences` interface in `preferences.svelte.ts`.
|
||||
- Add `clipListWidth: number` (default 220) for the sidebar width in left mode.
|
||||
- Both are saved/loaded via the existing Tauri store mechanism.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- `App.svelte` is the only component that needs structural changes. `ClipList.svelte`, `VideoPlayer.svelte`, and `Timeline.svelte` remain unchanged — they just get placed in different containers.
|
||||
- In `'left'` mode, the `.content` area becomes:
|
||||
- A flex column with two children:
|
||||
1. **Upper section** (flex row): `[ClipList sidebar | flex column of [VideoPlayer, TransportControls]]`
|
||||
2. **Lower section** (same as before): timeline pane with resize handle
|
||||
- The vertical resize handle between the upper and lower sections works the same as the existing one.
|
||||
- In `'bottom'` mode: the current layout is preserved exactly.
|
||||
- The `ClipList` component needs no changes; it already fills its container. The only adjustment is removing the `border-top` when in left mode (the sidebar should have a `border-right` instead), which can be handled via a CSS class or prop.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Timeline Timestamp Labels
|
||||
|
||||
### Current State
|
||||
|
||||
Timestamp labels are rendered at `10px` font size in `drawTimeTicks()` in `renderer.ts`. They are small and hard to read.
|
||||
|
||||
### Proposed Changes
|
||||
|
||||
1. **Increase base font size** from `10px` to `12px`.
|
||||
2. **Mouse-proximity magnification effect:** As the cursor moves along the timeline, labels near the cursor smoothly scale up. This creates an invisible "magnifying lens" effect for timestamps.
|
||||
|
||||
### Magnification Behavior
|
||||
|
||||
- **Radius:** ~80px from cursor (in canvas pixel space).
|
||||
- **Peak size:** ~18px directly under the cursor.
|
||||
- **Falloff:** Smooth cosine interpolation from peak to base size. A label 80px away renders at base size; a label directly under the cursor renders at peak size.
|
||||
- **Vertical alignment:** Labels stay bottom-aligned as they grow — the text baseline remains constant, so larger labels grow upward. This prevents layout jitter.
|
||||
- **Cursor leaves timeline:** When the mouse leaves the canvas (`onmouseleave`), `mouseX` is set to `null` and all labels render at the base size. No magnification in this state.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- `Timeline.svelte` already handles `onmousemove`. Add tracking of the mouse X coordinate (a simple `let mouseX = $state<number | null>(null)`), updated on `mousemove` and cleared on `mouseleave`.
|
||||
- Pass `mouseX` into `drawTimeline()` → `drawTimeTicks()`.
|
||||
- `drawTimeTicks()` computes the per-label font size based on distance from `mouseX`:
|
||||
```
|
||||
distance = abs(labelX - mouseX)
|
||||
if distance > MAGNIFY_RADIUS: scale = 0
|
||||
else: scale = (1 + cos(PI * distance / MAGNIFY_RADIUS)) / 2
|
||||
fontSize = BASE_FONT_SIZE + BONUS_FONT_SIZE * scale
|
||||
```
|
||||
- Trigger a redraw whenever `mouseX` changes. Since `requestDraw()` already coalesces via `requestAnimationFrame`, this adds no extra frame overhead — it simply piggybacks on the next scheduled frame.
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Timeline Right-Click Context Menu
|
||||
|
||||
### Current State
|
||||
|
||||
Right-clicking the timeline shows the default web/Tauri context menu (Reload, Inspect Element), which is not useful.
|
||||
|
||||
### Proposed: Custom Context Menu
|
||||
|
||||
A styled context menu appears on right-click anywhere on the timeline canvas. Right-clicking also moves the playhead to the clicked position.
|
||||
|
||||
### Menu Items
|
||||
|
||||
| # | Label | Behavior | Availability |
|
||||
|---|-------|----------|-------------|
|
||||
| 1 | **Mark In** | Sets in-point at the right-clicked time (calls `markInPoint`) | Always |
|
||||
| 2 | **Mark Out** | Sets out-point at the right-clicked time (calls `markOutPoint`) | Always |
|
||||
| — | *(separator)* | | |
|
||||
| 3 | **Go to Clip Start** | Seeks playhead to `selectedClip.startTime` | Disabled if no clip selected |
|
||||
| 4 | **Go to Clip End** | Seeks playhead to `selectedClip.endTime` | Disabled if no clip selected |
|
||||
| — | *(separator)* | | |
|
||||
| 5 | **Delete Clip** | Removes the selected clip | Hidden if no clip selected |
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Right-click on canvas:** `contextmenu` event is intercepted, default is prevented. The playhead moves to the clicked time position. The context menu appears at the cursor's screen coordinates.
|
||||
- **Selecting an item:** Executes the action and closes the menu.
|
||||
- **Closing without action:** Click anywhere outside the menu, press Escape, or scroll — the menu closes.
|
||||
- **Positioning:** The menu is rendered as a positioned `<div>` appended at the `App.svelte` level (or uses a portal pattern) so it isn't clipped by any `overflow: hidden` containers. If the menu would extend beyond the window edge, it flips direction (e.g. opens leftward or upward).
|
||||
|
||||
### Component: `TimelineContextMenu.svelte`
|
||||
|
||||
Props:
|
||||
- `x: number` — screen X position
|
||||
- `y: number` — screen Y position
|
||||
- `time: number` — the timeline time at the right-click position
|
||||
- `selectedClipId: string | null` — to determine which items are enabled/hidden
|
||||
- `onAction: (action: ContextMenuAction) => void` — callback for menu item selection
|
||||
- `onClose: () => void` — callback to close the menu
|
||||
|
||||
Actions (discriminated union):
|
||||
```typescript
|
||||
type ContextMenuAction =
|
||||
| { type: 'mark-in' }
|
||||
| { type: 'mark-out' }
|
||||
| { type: 'go-to-clip-start' }
|
||||
| { type: 'go-to-clip-end' }
|
||||
| { type: 'delete-clip' };
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
- Dark theme matching the app's existing style (dark background, light text, hover highlights).
|
||||
- Subtle border/shadow to float above the timeline.
|
||||
- Disabled items: dimmed text, no hover effect, not clickable.
|
||||
- Separators: thin horizontal lines using `var(--border)`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- `Timeline.svelte` adds an `oncontextmenu` handler on the canvas element.
|
||||
- The handler computes the time from the click X coordinate (using existing `computeClickTime`), seeks the playhead, and emits an event / sets state to show the context menu.
|
||||
- The context menu component is rendered conditionally in `Timeline.svelte` (or `App.svelte` if clipping is an issue).
|
||||
- Keyboard shortcut hints can optionally be shown in the menu items (e.g. "Mark In I") for discoverability.
|
||||
|
||||
---
|
||||
|
||||
## Version Bump
|
||||
|
||||
Execute `scripts/bump-version.sh` after incrementing `VERSION`.
|
||||
|
||||
Confirm all locations updated from `0.1.2` → `0.1.3`:
|
||||
- `package.json` — `version` field
|
||||
- `src-tauri/tauri.conf.json` — `version` field
|
||||
- `src-tauri/Cargo.toml` — `version` field
|
||||
- `grep`/`find` for remaining `0.1.2` references, fix any, and add to `scripts/bump-version.sh`
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- No changes to the backend (Rust). All three features are purely frontend.
|
||||
- No changes to clip store logic — existing `markInPoint`, `markOutPoint`, `removeClip`, `selectClip` functions are reused as-is.
|
||||
- No new dependencies.
|
||||
- The context menu does NOT appear on the minimap — only on the main timeline canvas.
|
||||
@@ -0,0 +1,264 @@
|
||||
# v0.2.0 Player Mode Bug Fixes — Design Spec
|
||||
|
||||
**Date:** 2026-09-22
|
||||
**Scope:** 8 issues found during QA of the v0.2.0 Player mode implementation
|
||||
|
||||
---
|
||||
|
||||
## 1. QuickTime-Style Floating Controls Layout
|
||||
|
||||
### Problem
|
||||
|
||||
The current `PlayerControls` overlay is pinned full-width to the bottom edge of the video, obscuring the waveform timeline. It should look and behave like QuickTime Player: a compact, centered, rounded pill floating above the bottom edge.
|
||||
|
||||
### Design
|
||||
|
||||
**New layout structure:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔊━━━━ ⏪ ❚❚ ⏩ 1× CC ⧉ ⛶ │ controls row
|
||||
│ ──────────────────────────────━━━━━━━━━━━━──────────── │ seek bar
|
||||
│ 00:06 02:58 │ timestamps
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Controls row** (top):
|
||||
- Left group: mute button + volume slider
|
||||
- Center group: skip −10s, play/pause, skip +10s
|
||||
- Right group: speed selector, CC toggle, PiP, fullscreen
|
||||
|
||||
**Seek bar** (middle): full pill width, thin track that expands on hover, thumb visible on hover.
|
||||
|
||||
**Timestamps** (bottom): current time left-aligned, duration right-aligned.
|
||||
|
||||
**CSS changes to `.player-controls-overlay`:**
|
||||
- Remove: `bottom: 0; left: 0; right: 0`
|
||||
- Add: `bottom: 24px; left: 15%; right: 15%` (centered ~70% width, floating above bottom edge)
|
||||
- The overlay itself becomes the positioning container; pointer-events remain `none` on the overlay, `auto` on the panel.
|
||||
|
||||
**CSS changes to `.player-controls-panel`:**
|
||||
- Change: `border-radius: 12px 12px 0 0` → `border-radius: 12px` (rounded on all corners)
|
||||
|
||||
**Markup reorder:** Controls row first, seek bar second, timestamps third (currently: seek bar, controls row).
|
||||
|
||||
### Files Modified
|
||||
- `src/lib/components/PlayerControls.svelte` — markup reorder + CSS
|
||||
|
||||
---
|
||||
|
||||
## 2. Auto-Hide Reactive Loop Fix
|
||||
|
||||
### Problem
|
||||
|
||||
`controlsHideTimer` and `toolbarHideTimer` are declared with `$state`. The `$effect` that calls `resetControlsTimer()` reads `controlsHideTimer` (via `clearTimeout`), then writes a new timer ID. Svelte 5 tracks the read as a dependency, re-runs the effect, clears the just-set timer, sets a new one — infinite loop. The timeout callback never fires. Controls and toolbar never auto-hide.
|
||||
|
||||
### Fix
|
||||
|
||||
Change both timer variables from `$state` to plain `let`:
|
||||
|
||||
```typescript
|
||||
let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let toolbarHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
```
|
||||
|
||||
Nothing in the template reads these values. They are only used in imperative timer logic and do not need reactivity.
|
||||
|
||||
### Files Modified
|
||||
- `src/App.svelte` — 2 variable declarations
|
||||
|
||||
---
|
||||
|
||||
## 3. Controls Visible When Paused
|
||||
|
||||
### Problem
|
||||
|
||||
The current `$effect` forces `showPlayerControls = true` and `showToolbar = true` whenever `!session.isPlaying`. This means controls permanently show when paused. QuickTime hides controls when paused after the same timeout.
|
||||
|
||||
### Fix
|
||||
|
||||
Remove the special paused branch. Both playing and paused states use the same timer-based auto-hide. The `$effect` simplifies to:
|
||||
|
||||
```typescript
|
||||
$effect(() => {
|
||||
if (!isPlayerMode) return;
|
||||
// Explicitly read play state so Svelte tracks it as a dependency —
|
||||
// controls briefly show on every play/pause toggle
|
||||
void session.isPlaying;
|
||||
resetControlsTimer();
|
||||
});
|
||||
```
|
||||
|
||||
`resetControlsTimer` also changes — remove the `if (session.isPlaying)` guard so the timer always starts:
|
||||
|
||||
```typescript
|
||||
function resetControlsTimer() {
|
||||
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||
showPlayerControls = true;
|
||||
controlsHideTimer = setTimeout(() => {
|
||||
showPlayerControls = false;
|
||||
}, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
```
|
||||
|
||||
Same change for `resetToolbarTimer` — always start the timer.
|
||||
|
||||
Mouse movement resets the timer regardless of play state. The only time controls are forced visible without a timer is during the initial mode switch into Player mode (handled by `toggleMode()`).
|
||||
|
||||
### Files Modified
|
||||
- `src/App.svelte` — `$effect` body, `resetControlsTimer` / `resetToolbarTimer` logic
|
||||
|
||||
---
|
||||
|
||||
## 4. Status Bar Hidden in Player Mode
|
||||
|
||||
### Problem
|
||||
|
||||
`<StatusBar>` is rendered unconditionally outside any `{#if}` block. It remains visible at the bottom of the window in Player mode.
|
||||
|
||||
### Fix
|
||||
|
||||
Wrap in a conditional:
|
||||
|
||||
```svelte
|
||||
{#if !isPlayerMode}
|
||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
- `src/App.svelte` — template
|
||||
|
||||
---
|
||||
|
||||
## 5. JKL Shuttle Skips 1.0x
|
||||
|
||||
### Problem
|
||||
|
||||
Two sub-issues:
|
||||
1. `shuttleRate` in App.svelte and the actual video `playbackRate` are disconnected. The speed selector sets `videoEl.playbackRate` directly, but `shuttleRate` stays stale. Next JKL press uses the stale value.
|
||||
2. Step size of 0.5 doesn't align with speed selector values (0.5, 0.75, 1, 1.25, 1.5, 2). If the rate is ever on the 0.25-offset grid (0.75, 1.25), pressing J/L cycles between those values and 1.0 is unreachable.
|
||||
|
||||
### Fix
|
||||
|
||||
Change `adjustShuttle` to:
|
||||
- Read the current rate from `videoEl.playbackRate` (source of truth) instead of taking an external `shuttleRate` parameter
|
||||
- Use step size `0.25` to align with the speed selector grid
|
||||
- Return the new rate so callers can update display state
|
||||
|
||||
New signature:
|
||||
```typescript
|
||||
export function adjustShuttle(dir: 1 | -1): number {
|
||||
const videoEl = getVideo();
|
||||
if (!videoEl) return 1;
|
||||
const current = videoEl.playbackRate;
|
||||
const nextRate = Math.max(0.25, Math.min(4, current + dir * 0.25));
|
||||
videoEl.playbackRate = nextRate;
|
||||
if (videoEl.paused) void videoEl.play();
|
||||
return nextRate;
|
||||
}
|
||||
```
|
||||
|
||||
App.svelte call sites update from `adjustShuttle(-1, shuttleRate)` to `adjustShuttle(-1)`. The returned value updates `shuttleRate` for any display that reads it.
|
||||
|
||||
PlayerControls syncs `currentRate` from the live video rate. The existing 500ms polling `$effect` (for PiP/fullscreen state) is extended to also sync `currentRate = getPlaybackRate()`.
|
||||
|
||||
### Files Modified
|
||||
- `src/lib/transport/playback.ts` — `adjustShuttle` signature + body
|
||||
- `src/App.svelte` — call sites
|
||||
- `src/lib/components/PlayerControls.svelte` — add `currentRate` to polling effect
|
||||
- `tests/lib/transport/playback.test.ts` — update test for new signature
|
||||
|
||||
---
|
||||
|
||||
## 6. Speed Selector Immediately Closes
|
||||
|
||||
### Problem
|
||||
|
||||
SpeedSelector uses `<svelte:window onclick={handleWindowClick}>` to detect click-outside. The same click that toggles `showSpeedSelector` to `true` bubbles to the window, where `handleWindowClick` sees the target is outside `.speed-selector` and calls `onClose()`. The popup renders for one frame then immediately closes.
|
||||
|
||||
### Fix
|
||||
|
||||
Add `stopPropagation` on the toggle button click in PlayerControls:
|
||||
|
||||
```svelte
|
||||
onclick={(e) => { e.stopPropagation(); showSpeedSelector = !showSpeedSelector; }}
|
||||
```
|
||||
|
||||
This prevents the opening click from reaching the window-level close handler.
|
||||
|
||||
### Files Modified
|
||||
- `src/lib/components/PlayerControls.svelte` — toggle button onclick
|
||||
|
||||
---
|
||||
|
||||
## 7. Fullscreen Broken in WKWebView
|
||||
|
||||
### Problem
|
||||
|
||||
`toggleFullscreen()` calls `videoEl.requestFullscreen()`, which doesn't work in Tauri's WKWebView. The standard Fullscreen API on child elements is not supported in WKWebView.
|
||||
|
||||
### Fix
|
||||
|
||||
Target `document.documentElement` instead of the video element:
|
||||
|
||||
```typescript
|
||||
export function toggleFullscreen(): void {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(console.error);
|
||||
} else {
|
||||
const el = document.documentElement;
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().catch(console.error);
|
||||
} else if ((el as any).webkitRequestFullscreen) {
|
||||
(el as any).webkitRequestFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This makes the entire webview fullscreen. In Player mode the video already fills the viewport, so the result is the same as video-only fullscreen. The `webkitRequestFullscreen` fallback covers older WebKit versions.
|
||||
|
||||
### Files Modified
|
||||
- `src/lib/transport/playback.ts` — `toggleFullscreen` body
|
||||
- `tests/lib/transport/playback.test.ts` — update mocks to use `document.documentElement`
|
||||
|
||||
---
|
||||
|
||||
## 8. Shift+, / Shift+. Produce Wrong Key Values
|
||||
|
||||
### Problem
|
||||
|
||||
On US keyboards, `Shift+,` produces `<` and `Shift+.` produces `>`. The keydown handler matches on `,` and `.`, which don't match when Shift is held. Keyframe skip shortcuts never fire.
|
||||
|
||||
### Fix
|
||||
|
||||
Add explicit cases for `<` and `>`:
|
||||
|
||||
```typescript
|
||||
case '<':
|
||||
e.preventDefault();
|
||||
dispatchTransport('keyframe-back');
|
||||
break;
|
||||
case '>':
|
||||
e.preventDefault();
|
||||
dispatchTransport('keyframe-forward');
|
||||
break;
|
||||
```
|
||||
|
||||
These go after the existing `,` and `.` cases (which handle frame-step and already check `e.shiftKey` — but that path is unreachable). The `,`/`.` cases can drop the `e.shiftKey` ternary since those keys only fire without Shift.
|
||||
|
||||
Updated `,`/`.` cases become:
|
||||
```typescript
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
dispatchTransport('frame-back');
|
||||
break;
|
||||
case '.':
|
||||
e.preventDefault();
|
||||
dispatchTransport('frame-forward');
|
||||
break;
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
- `src/App.svelte` — keydown handler
|
||||
331
docs/superpowers/specs/2026-09-22-v020-player-mode-design.md
Normal file
331
docs/superpowers/specs/2026-09-22-v020-player-mode-design.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# v0.2.0 — Player Mode Design Spec
|
||||
|
||||
**Date:** 2026-09-22
|
||||
**Version target:** 0.2.0
|
||||
**Scope:** Add a dedicated "Player" mode alongside the existing "Clipper" mode, transforming the app into a clean, minimal video player when clipping isn't needed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The app currently operates exclusively as a video clipper. This spec adds a second mode — **Player** — that strips away clipping UI and presents a focused video playback experience (think QuickTime / VLC). Users toggle between modes with a toolbar button or keyboard shortcut; the switch preserves all session state (playhead position, loaded video, clips, waveform data).
|
||||
|
||||
**Architecture approach:** Single `App.svelte` with conditional rendering. The `<video>` element inside `VideoPlayer.svelte` is never destroyed during mode switches — only the surrounding chrome changes. New Player-specific components (`PlayerControls`, `PlayerTimeline`, `SpeedSelector`) stay focused and small.
|
||||
|
||||
---
|
||||
|
||||
## 2. State & Preferences
|
||||
|
||||
### New preference field
|
||||
|
||||
Add `appMode: 'clipper' | 'player'` to the `Preferences` interface in `preferences.svelte.ts`. Default: `'clipper'`. Persisted via the Tauri store so it survives relaunches.
|
||||
|
||||
### New store helper
|
||||
|
||||
- `setAppMode(mode: 'clipper' | 'player')` — updates `preferences.appMode` and saves.
|
||||
|
||||
### Mode switching invariant
|
||||
|
||||
Toggling between modes **preserves all session state**: playhead position, loaded video, playback state (playing/paused), clips, waveform data, captions, volume, speed — everything. The `<video>` element itself never unmounts, so playback state is inherently preserved (same principle as the existing clip list layout toggle).
|
||||
|
||||
---
|
||||
|
||||
## 3. Keyboard Shortcuts
|
||||
|
||||
### New shortcuts
|
||||
|
||||
| Key | Action | Available in |
|
||||
|-----|--------|-------------|
|
||||
| `p` | Switch to Player mode | Both modes |
|
||||
| `c` | Switch to Clipper mode | Both modes |
|
||||
| `f` | Toggle fullscreen | Player mode only |
|
||||
|
||||
### Existing shortcuts by mode
|
||||
|
||||
| Key | Action | Clipper | Player |
|
||||
|-----|--------|---------|--------|
|
||||
| `Space` / `k` | Play/Pause | ✓ | ✓ |
|
||||
| `←` / `→` | Seek ±5s (±1s with Shift) | ✓ | ✓ |
|
||||
| `,` / `.` | Frame step back/forward (works when paused) | ✓ | ✓ |
|
||||
| `Shift+,` / `Shift+.` | Keyframe jump | ✓ | ✓ |
|
||||
| `j` / `l` | Shuttle speed down/up | ✓ | ✓ |
|
||||
| `i` / `o` | Mark in/out point | ✓ | No-op |
|
||||
| `Delete` / `Backspace` | Delete selected clip | ✓ | No-op |
|
||||
| `Cmd+E` | Export dialog | ✓ | No-op |
|
||||
|
||||
---
|
||||
|
||||
## 4. App Layout
|
||||
|
||||
### Clipper mode (unchanged)
|
||||
|
||||
Renders exactly as v0.1.x: toolbar → video player → transport controls → resizable timeline → clip list (bottom or sidebar).
|
||||
|
||||
### Player mode
|
||||
|
||||
The layout collapses to a single full-bleed video area with auto-hiding overlays:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Toolbar (auto-hides) │ ← slides down from top on cursor proximity
|
||||
├──────────────────────────────────┤
|
||||
│ │
|
||||
│ VideoPlayer │ ← fills all available space
|
||||
│ (floating controls overlay) │
|
||||
│ │
|
||||
├──────────────────────────────────┤
|
||||
│ PlayerTimeline (auto-hides) │ ← slides up from bottom on cursor proximity
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Hidden in Player mode:**
|
||||
- `TransportControls` bar (replaced by floating `PlayerControls`)
|
||||
- `ClipList` (both bottom and sidebar variants)
|
||||
- Timeline resize handle
|
||||
- Minimap
|
||||
- Timeline clip rendering
|
||||
- I/O mark buttons
|
||||
|
||||
**Visible in Player mode:**
|
||||
- `VideoPlayer.svelte` (unchanged, always mounted)
|
||||
- `StatusBar` at the bottom (stays visible in both modes)
|
||||
- `PlayerControls` (floating overlay)
|
||||
- `PlayerTimeline` (auto-hiding simplified timeline)
|
||||
|
||||
**CSS:** Player mode sets a different `grid-template` on `.content` that gives the video area `1fr` and collapses the lower/clip-list rows to `0`. The auto-hiding panels use `position: absolute` within the app shell.
|
||||
|
||||
---
|
||||
|
||||
## 5. Mode Toggle Button
|
||||
|
||||
- **Position:** In the toolbar, between the URL input and the existing layout toggle button.
|
||||
- **Appearance:** Text label showing current mode — `Clipper` or `Player` — styled as a small pill/badge button.
|
||||
- **Click:** Toggles between modes.
|
||||
- **Tooltip:** "Switch to Player (P)" or "Switch to Clipper (C)".
|
||||
|
||||
---
|
||||
|
||||
## 6. Floating Player Controls (`PlayerControls.svelte`)
|
||||
|
||||
An absolutely-positioned overlay rendered over the video area. Designed as a semi-transparent frosted-glass panel.
|
||||
|
||||
### Show/hide logic
|
||||
|
||||
- **Show** when: mouse moves anywhere over the video area, OR video is paused.
|
||||
- **Hide** when: mouse is still for 2.5 seconds AND video is playing.
|
||||
- **Transition:** fade in/out over ~200ms using CSS opacity + pointer-events.
|
||||
- **Fullscreen:** Same show/hide behavior applies when video is in fullscreen mode.
|
||||
|
||||
### Panel layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ▁▂▃▅▃▂▁▂▃▅▇▅▃▂▁▂▃▅▃▂▁ ●─────────────────── │ ← seek bar
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ▶❚❚ ◀◀ ▶▶ 🔊━━━━ 0:32 / 4:15 1.0× CC ⧉ ⛶ │ ← controls row
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Top row — Seek bar
|
||||
|
||||
- Full-width thin progress bar spanning the entire panel width.
|
||||
- Shows playback progress as a filled track (`var(--accent)`) against a dark unfilled track.
|
||||
- Hovering shows a time tooltip above the cursor position.
|
||||
- Click or drag to seek (reuses `seekTo()` from `playback.ts`).
|
||||
- Track height: 4px, expanding to 6px on hover (CSS transition).
|
||||
- Thumb: 12px circle, visible only on hover.
|
||||
|
||||
#### Bottom row — Controls
|
||||
|
||||
Three groups arranged with flexbox `space-between`:
|
||||
|
||||
**Left group (playback):**
|
||||
- Play/Pause — primary action button, `▶` / `❚❚`
|
||||
- Skip back 10s — `⏪` button, calls `seekBy(-10)`
|
||||
- Skip forward 10s — `⏩` button, calls `seekBy(10)`
|
||||
|
||||
**Center group (time):**
|
||||
- Time display: `0:32 / 4:15` in monospace font (`var(--font-mono)`), slightly dimmed
|
||||
|
||||
**Right group (settings):**
|
||||
- Speed — shows current rate (e.g. `1×`, `1.5×`). Click opens `SpeedSelector` popup. Sets `videoElement.playbackRate` directly.
|
||||
- CC — `CC` text button, highlighted when captions active. Click toggles captions. Right-click opens `CaptionSettingsPanel`.
|
||||
- PiP — `⧉` icon. Click enters/exits Picture-in-Picture. Active state: accent color.
|
||||
- Fullscreen — `⛶` icon. Click enters/exits fullscreen. Swaps to exit icon when active.
|
||||
|
||||
### Visual styling
|
||||
|
||||
- **Background:** `rgba(0, 0, 0, 0.75)` with `backdrop-filter: blur(8px)` (frosted glass)
|
||||
- **Border radius:** `12px` on top corners only (bottom flush with video edge)
|
||||
- **Padding:** `12px 16px`, `8px` gap between rows
|
||||
- **Buttons:** Borderless, icon-only, `color: var(--text-primary)`, hover: `color: var(--accent)`. ~32px tap targets.
|
||||
- **Pointer events:** Panel has `pointer-events: auto`; surrounding overlay has `pointer-events: none` so clicks pass through to the video.
|
||||
|
||||
### Video click/double-click behavior
|
||||
|
||||
- Single click on video (outside controls) → toggles play/pause
|
||||
- Double-click on video → toggles fullscreen
|
||||
|
||||
---
|
||||
|
||||
## 7. Speed Selector (`SpeedSelector.svelte`)
|
||||
|
||||
A small vertical popup menu triggered by clicking the speed button in `PlayerControls`.
|
||||
|
||||
- Options: 0.5×, 0.75×, 1×, 1.25×, 1.5×, 2×
|
||||
- Current rate has a checkmark indicator
|
||||
- Click an option to set rate, popup closes
|
||||
- Click outside popup to dismiss
|
||||
- Popup appears above the speed button, anchored to its position
|
||||
- Sets `videoElement.playbackRate` directly
|
||||
- JKL shuttle keys continue to work independently (they adjust `playbackRate` too)
|
||||
|
||||
---
|
||||
|
||||
## 8. Simplified Player Timeline (`PlayerTimeline.svelte`)
|
||||
|
||||
A lightweight timeline component for Player mode. Shows only waveform and timestamps — no clips, thumbnails, clip handles, or minimap.
|
||||
|
||||
### Auto-hide behavior
|
||||
|
||||
- **Show** when cursor is within ~80px of the bottom edge of the video area.
|
||||
- **Hide** when cursor moves away from bottom edge AND video is playing.
|
||||
- **Stays visible** when video is paused.
|
||||
- **Transition:** slides up from below + fades in over ~200ms.
|
||||
- **Relationship to floating controls:** The floating controls and the player timeline have overlapping but distinct triggers. The floating controls show on *any* mouse movement over the video area; the player timeline shows only on bottom-edge proximity (~80px). When the cursor is near the bottom, both are visible and they appear together as a cohesive unit. When the cursor moves elsewhere over the video, only the floating controls appear (not the timeline).
|
||||
|
||||
### Layout
|
||||
|
||||
- **Fixed height:** ~50px (not user-resizable)
|
||||
- **Position:** Bottom of the player area, below the floating controls panel
|
||||
- **Background:** Semi-transparent dark, matching floating controls (`rgba(0, 0, 0, 0.6)`, `backdrop-filter: blur(4px)`)
|
||||
|
||||
### Rendering
|
||||
|
||||
- **Waveform:** Reuses `drawWaveform()` from `waveformRenderer.ts`, rendered at full duration (always zoom level 1, no pan).
|
||||
- **Timestamps:** Reuses timestamp tick rendering from `renderer.ts`, simplified to major ticks only (no clip lanes).
|
||||
- **Playhead:** Vertical white line showing current position.
|
||||
- **Click-to-seek:** Clicking anywhere seeks to that position.
|
||||
- **No zoom/pan:** Always shows the full duration.
|
||||
|
||||
---
|
||||
|
||||
## 9. Toolbar Auto-Hide (Player Mode)
|
||||
|
||||
### Clipper mode
|
||||
|
||||
Toolbar always visible (no change).
|
||||
|
||||
### Player mode
|
||||
|
||||
- **Show** when cursor is within ~50px of the top edge of the window.
|
||||
- **Hide** when cursor moves away from top edge AND video is playing.
|
||||
- **Stays visible** when video is paused.
|
||||
- **Transition:** slides down from above + fades in (~200ms), matching the bottom HUD timing.
|
||||
- **Content:** Same as Clipper mode (URL input, mode toggle, about, preferences).
|
||||
|
||||
---
|
||||
|
||||
## 10. Picture-in-Picture
|
||||
|
||||
- **API:** `HTMLVideoElement.requestPictureInPicture()` — uses the native macOS PiP window via WKWebView.
|
||||
- **Scope:** Player mode only. No PiP button in Clipper mode.
|
||||
- **Button:** `⧉` icon in `PlayerControls`. Active state shown with accent color.
|
||||
- **Events:** Listens for `enterpictureinpicture` and `leavepictureinpicture` on the video element to track state.
|
||||
- **Exiting PiP:** Returns focus to the app window.
|
||||
|
||||
### New playback helpers
|
||||
|
||||
Add to `playback.ts`:
|
||||
- `requestPiP()` — calls `videoElement.requestPictureInPicture()`
|
||||
- `exitPiP()` — calls `document.exitPictureInPicture()`
|
||||
- `isPiPActive(): boolean` — checks `document.pictureInPictureElement`
|
||||
|
||||
---
|
||||
|
||||
## 11. Fullscreen
|
||||
|
||||
- **API:** `videoElement.requestFullscreen()` with fallback to `videoElement.webkitEnterFullscreen()` for WKWebView compatibility. WKWebView's webkit-prefixed API fullscreens the video natively (similar to Safari's behavior).
|
||||
- **Scope:** Player mode only. Keyboard shortcut `f`.
|
||||
- **Button:** `⛶` icon in `PlayerControls`. Swaps to exit-fullscreen icon when active.
|
||||
- **Events:** Listens for `fullscreenchange` to track state.
|
||||
- **Controls in fullscreen:** The floating `PlayerControls` overlay remains functional in fullscreen mode with the same auto-hide behavior.
|
||||
|
||||
### New playback helpers
|
||||
|
||||
Add to `playback.ts`:
|
||||
- `toggleFullscreen()` — enters or exits fullscreen on the video element
|
||||
- `isFullscreenActive(): boolean` — checks `document.fullscreenElement`
|
||||
|
||||
---
|
||||
|
||||
## 12. Transitions (FLIP Animations)
|
||||
|
||||
### Clipper ↔ Player mode switch
|
||||
|
||||
1. **Video player area** — FLIP animation: the video container smoothly expands to fill the full area (Clipper→Player) or shrinks back to its grid cell (Player→Clipper). Manual FLIP: snapshot bounding rect before mode change, apply mode, snapshot new rect, animate from old→new using CSS `transform`.
|
||||
|
||||
2. **Transport controls / Clip list** — fade + slide out downward when switching to Player, fade + slide back in when returning to Clipper. Duration ~250ms, eased.
|
||||
|
||||
3. **Floating controls** — fade in after video expansion completes (staggered ~100ms delay).
|
||||
|
||||
4. **Toolbar** — slides up and fades out (Clipper→Player), slides back down (Player→Clipper).
|
||||
|
||||
### Implementation approach
|
||||
|
||||
- CSS custom properties + `transition` for grid layout changes (grid rows/columns animate in modern browsers).
|
||||
- Svelte `transition:` directives (`fly`, `fade`, `slide`) for elements entering/leaving the DOM.
|
||||
- Manual FLIP on the video container for the expansion/contraction.
|
||||
- Total transition budget: ~300ms.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Mid-transition mode switch: new transition takes over from current interpolated position (CSS transitions handle this naturally).
|
||||
- No video loaded: transition still runs (placeholder area expands/contracts).
|
||||
|
||||
---
|
||||
|
||||
## 13. New & Modified Files
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/components/PlayerControls.svelte` | Floating controls overlay (seek bar, play/pause, volume, speed, CC, PiP, fullscreen, time display) |
|
||||
| `src/lib/components/PlayerTimeline.svelte` | Simplified auto-hiding timeline (waveform + timestamps only) |
|
||||
| `src/lib/components/SpeedSelector.svelte` | Speed popup menu used by PlayerControls |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/lib/stores/preferences.svelte.ts` | Add `appMode` field, `setAppMode()`, persist/load |
|
||||
| `src/App.svelte` | Mode toggle button, conditional layout, auto-hide toolbar, `p`/`c`/`f` shortcuts, FLIP transitions |
|
||||
| `src/lib/components/VideoPlayer.svelte` | PiP/fullscreen event listeners, expose PiP/fullscreen state, conditional CC toggle rendering (Clipper only; Player delegates to PlayerControls) |
|
||||
| `src/lib/transport/playback.ts` | Add `requestPiP()`, `exitPiP()`, `isPiPActive()`, `toggleFullscreen()`, `isFullscreenActive()`, `setPlaybackRate()` |
|
||||
| `src/app.css` | CSS variables/classes for player mode transitions, auto-hide panels |
|
||||
|
||||
### Unchanged files
|
||||
|
||||
- `Timeline.svelte` — used only in Clipper mode
|
||||
- `ClipList.svelte` — hidden in Player mode
|
||||
- `TransportControls.svelte` — hidden in Player mode
|
||||
- All Rust backend code — no backend changes
|
||||
- `StatusBar.svelte` — visible in both modes
|
||||
|
||||
### Testing
|
||||
|
||||
- Existing tests unaffected (timeline interactions, clips store, time utilities).
|
||||
- New tests: PlayerControls show/hide logic, speed selector state, mode switching preserves session state.
|
||||
|
||||
---
|
||||
|
||||
## 14. Version Bump
|
||||
|
||||
Bump version from `0.1.3` to `0.2.0` in:
|
||||
- `package.json`
|
||||
- `src-tauri/tauri.conf.json`
|
||||
- `src-tauri/Cargo.toml`
|
||||
- `VERSION` file
|
||||
|
||||
Use the existing `scripts/bump-version.sh` if it handles all locations, otherwise update manually.
|
||||
@@ -0,0 +1,159 @@
|
||||
# v0.2.1 — Polish, Icons, Cut Clip & Shortcuts Reference
|
||||
|
||||
## Overview
|
||||
|
||||
Incremental polish release building on v0.2.0 Player mode. Six changes: controls position tweak, Lucide icon migration, blur animation fix, mode toggle shortcuts, cut-clip feature, and keyboard shortcuts reference in the About dialog.
|
||||
|
||||
---
|
||||
|
||||
## 1. Floating Controls Position & Blur Fix
|
||||
|
||||
### Position
|
||||
|
||||
Raise `.player-controls-overlay` from `bottom: 48px` to `bottom: 64px` (+16px).
|
||||
|
||||
### Blur Pop-in Fix
|
||||
|
||||
**Problem:** `backdrop-filter: blur(8px)` is on `.player-controls-panel` while the parent `.player-controls-overlay` transitions `opacity: 0 → 1`. WebKit doesn't smoothly composite `backdrop-filter` through an ancestor `opacity` transition — the blur snaps on once opacity crosses a rendering threshold.
|
||||
|
||||
**Fix:** Move the blur background into a `::before` pseudo-element on `.player-controls-panel`. The pseudo-element handles `background` + `backdrop-filter` and has its own `opacity` transition driven by the `.visible` class on the overlay ancestor. The panel itself becomes `position: relative` with no background/backdrop-filter of its own.
|
||||
|
||||
```css
|
||||
.player-controls-panel {
|
||||
position: relative;
|
||||
/* background and backdrop-filter removed */
|
||||
}
|
||||
|
||||
.player-controls-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 12px;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.player-controls-overlay.visible .player-controls-panel::before {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Lucide Icon Migration
|
||||
|
||||
Install `lucide-svelte` as a dependency. Replace all emoji and text-glyph icons with Lucide SVG components across `PlayerControls.svelte`, `TransportControls.svelte`, and the `App.svelte` toolbar.
|
||||
|
||||
### Icon Mapping
|
||||
|
||||
| Current | Lucide Component | Location |
|
||||
|---|---|---|
|
||||
| 🔇 / 🔉 / 🔊 | `VolumeX` / `Volume1` / `Volume2` | PlayerControls, TransportControls |
|
||||
| ⏪ | `SkipBack` | PlayerControls |
|
||||
| ❚❚ / ▶ | `Pause` / `Play` | PlayerControls, TransportControls |
|
||||
| ⏩ | `SkipForward` | PlayerControls |
|
||||
| CC (text) | `Captions` | PlayerControls |
|
||||
| ⧉ | `PictureInPicture2` | PlayerControls |
|
||||
| ⛶ / ⤓ | `Maximize` / `Minimize` | PlayerControls |
|
||||
| ⚙ | `Settings` | App toolbar |
|
||||
| ℹ | `Info` | App toolbar |
|
||||
| ⬒ / ⬓ | `PanelLeft` / `PanelBottom` | App toolbar |
|
||||
| ✂️ (new) | `Scissors` | PlayerControls (cut clip button) |
|
||||
| ◄K / K► | `ChevronFirst` / `ChevronLast` | TransportControls |
|
||||
| ◄\| / \|► | `StepBack` / `StepForward` | TransportControls |
|
||||
|
||||
Icons render inline as SVGs. Size: 16–18px to match existing button dimensions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mode Toggle Shortcuts
|
||||
|
||||
Both `C` and `P` keys become bidirectional toggles. Pressing either key switches from the current mode to the other mode.
|
||||
|
||||
Implementation: both the `'c'/'C'` and `'p'/'P'` cases in `handleGlobalKeydown` call `toggleMode()` instead of hard-coding a target mode via `setAppMode()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cut Clip Feature
|
||||
|
||||
### Button
|
||||
|
||||
A scissors icon (`Scissors` from Lucide) in the `PlayerControls` right control group, positioned between the speed selector and the CC button.
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Left-click / `X` key:** Create a 10-second clip starting at the current playhead position (`currentTime` → `currentTime + 10`). Clamp end to `session.duration`. Switch to Clipper mode. Playhead stays at its current position.
|
||||
- **Right-click / `Z` key:** Create a 10-second clip ending at the current playhead position (`currentTime - 10` → `currentTime`). Clamp start to `0`. Switch to Clipper mode. Playhead stays at its current position.
|
||||
|
||||
### Implementation
|
||||
|
||||
- Add a `cutClip(position: 'at' | 'before')` helper. It calls `addClip(start, end)` from the clips store, then `setAppMode('clipper')`.
|
||||
- Pass an `onCutClip` callback prop to `PlayerControls`. The button's `onclick` calls `onCutClip('at')`, `oncontextmenu` prevents default and calls `onCutClip('before')`.
|
||||
- In `handleGlobalKeydown`: `X`/`x` → `cutClip('at')`, `Z`/`z` → `cutClip('before')`. Only active in Player mode.
|
||||
- **Tooltip:** "Cut clip at playhead (X) · Right-click: 10s before (Z)"
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- No video loaded (`session.duration === 0`): no-op.
|
||||
- Playhead < 10s from start: `Z`/right-click clamps start to 0.
|
||||
- Playhead < 10s from end: `X`/left-click clamps end to duration.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tabbed About Dialog with Keyboard Shortcuts
|
||||
|
||||
### Tab Structure
|
||||
|
||||
Two tabs: **About** (default) and **Shortcuts**.
|
||||
|
||||
### About Tab
|
||||
|
||||
Identical to current content (icon, app name, version, author, license, repo link, close button). Dialog stays at ~360px wide.
|
||||
|
||||
### Shortcuts Tab
|
||||
|
||||
Two-column layout (key on left, description on right) grouped by section headers. Dialog expands to ~520px wide with `max-height: 70vh` and vertical scroll. Width change transitions smoothly (`transition: max-width 0.2s ease`).
|
||||
|
||||
Tab switcher: text tabs at the top of the dialog, active tab gets accent-color underline.
|
||||
|
||||
### Complete Shortcut Reference
|
||||
|
||||
**Global (both modes):**
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Space` | Play / Pause |
|
||||
| `K` | Play / Pause |
|
||||
| `J` | Shuttle slower (−0.25×) |
|
||||
| `L` | Shuttle faster (+0.25×) |
|
||||
| `←` | Seek back 5s |
|
||||
| `→` | Seek forward 5s |
|
||||
| `Shift+←` | Seek back 1s |
|
||||
| `Shift+→` | Seek forward 1s |
|
||||
| `,` | Previous frame |
|
||||
| `.` | Next frame |
|
||||
| `<` (Shift+,) | Previous keyframe |
|
||||
| `>` (Shift+.) | Next keyframe |
|
||||
| `C` / `P` | Toggle mode |
|
||||
| `Cmd+/` | About |
|
||||
|
||||
**Player mode:**
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `F` | Toggle fullscreen |
|
||||
| `X` | Cut clip at playhead → Clipper |
|
||||
| `Z` | Cut clip 10s before playhead → Clipper |
|
||||
|
||||
**Clipper mode:**
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `I` | Set in-point |
|
||||
| `O` | Set out-point |
|
||||
| `Delete` / `Backspace` | Remove selected clip |
|
||||
| `Cmd+E` | Export |
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "gui-video-clipper",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gui-video-clipper",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.47.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
@@ -196,7 +197,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -207,7 +207,6 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -218,7 +217,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -228,20 +226,27 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.47.0.tgz",
|
||||
"integrity": "sha512-tpuE7JIK9FzYDWsJx6PxHj9ROyCpnDxZfvHu5q5yHGKS2CFNjzSyf1o9/Ypu2A27GLvkPGqZcIMrF6idM+eQZQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.150.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||
@@ -518,7 +523,6 @@
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
|
||||
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
@@ -926,7 +930,6 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
@@ -991,7 +994,6 @@
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -1037,7 +1039,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1057,7 +1058,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1093,7 +1093,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -1186,7 +1185,6 @@
|
||||
"version": "5.9.4",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
|
||||
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
@@ -1220,14 +1218,12 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esrap": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz",
|
||||
"integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
@@ -1359,7 +1355,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
@@ -1677,7 +1672,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
@@ -1701,7 +1695,6 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -1981,7 +1974,6 @@
|
||||
"version": "5.57.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz",
|
||||
"integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
@@ -2452,7 +2444,6 @@
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
||||
"integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gui-video-clipper",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.47.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
|
||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -1281,12 +1281,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gui-video-clipper"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gui-video-clipper"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
description = "A macOS GUI app for clipping online videos"
|
||||
authors = ["cottongin"]
|
||||
edition = "2021"
|
||||
@@ -32,6 +32,7 @@ tempfile = "3.27.0"
|
||||
dirs = "6.0.0"
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"store:allow-set",
|
||||
"store:allow-save",
|
||||
"store:allow-load",
|
||||
"process:allow-exit"
|
||||
"process:allow-exit",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen"
|
||||
]
|
||||
}
|
||||
|
||||
166
src-tauri/src/commands/cache.rs
Normal file
166
src-tauri/src/commands/cache.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
|
||||
use crate::services::cache_manager::{self, CacheManifest, CacheStats};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CachedAnalysis {
|
||||
pub waveform_tiers: Option<WaveformTiers>,
|
||||
pub keyframe_positions: Vec<f64>,
|
||||
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
|
||||
pub caption_file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_cached_analysis(url: String) -> Option<CachedAnalysis> {
|
||||
let url_clone = url.clone();
|
||||
let manifest = tokio::task::spawn_blocking(move || cache_manager::load_manifest(&url_clone))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
|
||||
let cache_dir = cache_manager::cache_dir_for_url(&url);
|
||||
|
||||
// Resolve relative paths in thumbnail spritesheets to absolute paths
|
||||
let spritesheets: Vec<ThumbnailSpritesheet> = manifest
|
||||
.thumbnail_spritesheets
|
||||
.into_iter()
|
||||
.map(|mut sheet| {
|
||||
let abs_path = cache_dir.join(&sheet.file_path);
|
||||
sheet.file_path = abs_path.to_string_lossy().to_string();
|
||||
sheet
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Verify thumbnail files actually exist on disk
|
||||
let thumbs_valid = if spritesheets.is_empty() {
|
||||
false
|
||||
} else {
|
||||
let first_dir = std::path::Path::new(&spritesheets[0].file_path);
|
||||
first_dir.is_dir()
|
||||
};
|
||||
|
||||
// Resolve caption path to absolute
|
||||
let caption_path = manifest
|
||||
.caption_file_path
|
||||
.map(|rel| cache_dir.join(&rel).to_string_lossy().to_string())
|
||||
.filter(|p| std::path::Path::new(p).is_file());
|
||||
|
||||
Some(CachedAnalysis {
|
||||
waveform_tiers: manifest.waveform_tiers,
|
||||
keyframe_positions: manifest.keyframe_positions,
|
||||
thumbnail_spritesheets: if thumbs_valid {
|
||||
spritesheets
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
caption_file_path: caption_path,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_analysis_to_cache(
|
||||
url: String,
|
||||
title: String,
|
||||
duration: f64,
|
||||
fps: f64,
|
||||
waveform_tiers: Option<WaveformTiers>,
|
||||
keyframe_positions: Vec<f64>,
|
||||
thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
|
||||
caption_file_path: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let cache_dir = cache_manager::cache_dir_for_url(&url);
|
||||
|
||||
// Convert absolute spritesheet paths to relative
|
||||
let relative_sheets: Vec<ThumbnailSpritesheet> = thumbnail_spritesheets
|
||||
.into_iter()
|
||||
.map(|mut sheet| {
|
||||
if let Ok(rel) = std::path::Path::new(&sheet.file_path).strip_prefix(&cache_dir) {
|
||||
sheet.file_path = rel.to_string_lossy().to_string();
|
||||
}
|
||||
sheet
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Convert absolute caption path to relative (copy into cache if needed)
|
||||
let relative_caption = caption_file_path.and_then(|abs| {
|
||||
let abs_path = std::path::Path::new(&abs);
|
||||
// Already inside cache dir
|
||||
if let Ok(rel) = abs_path.strip_prefix(&cache_dir) {
|
||||
return Some(rel.to_string_lossy().to_string());
|
||||
}
|
||||
// Outside cache dir — copy it in
|
||||
if abs_path.is_file() {
|
||||
let captions_dir = cache_dir.join("captions");
|
||||
std::fs::create_dir_all(&captions_dir).ok();
|
||||
if let Some(filename) = abs_path.file_name() {
|
||||
let dest = captions_dir.join(filename);
|
||||
if std::fs::copy(abs_path, &dest).is_ok() {
|
||||
return Some(format!("captions/{}", filename.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
let now = format!(
|
||||
"{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
);
|
||||
|
||||
let manifest = CacheManifest {
|
||||
version: 1,
|
||||
source_url: url.clone(),
|
||||
title,
|
||||
duration,
|
||||
fps,
|
||||
created_at: now.clone(),
|
||||
last_accessed_at: now,
|
||||
waveform_tiers,
|
||||
keyframe_positions,
|
||||
thumbnail_spritesheets: relative_sheets,
|
||||
caption_file_path: relative_caption,
|
||||
};
|
||||
|
||||
tokio::task::spawn_blocking(move || cache_manager::save_manifest(&url, &manifest))
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_video_cache(url: String) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || cache_manager::clear_cache_entry(&url))
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_all_cache() -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(cache_manager::clear_all_cache)
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_cache_stats() -> Result<CacheStats, String> {
|
||||
tokio::task::spawn_blocking(cache_manager::get_cache_stats)
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_thumbnail_cache_dir(url: String) -> String {
|
||||
let dir = cache_manager::thumbnail_cache_dir(&url);
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
dir.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_caption_cache_dir(url: String) -> String {
|
||||
let dir = cache_manager::caption_cache_dir(&url);
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
dir.to_string_lossy().to_string()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::models::{ExportConfig, ExportScope};
|
||||
use crate::services::clip_exporter;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -26,6 +27,30 @@ pub enum ExportEvent {
|
||||
},
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_screenshot(
|
||||
output_dir: String,
|
||||
video_title: String,
|
||||
timestamp_label: String,
|
||||
png_data: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
let expanded_dir = clip_exporter::expand_tilde_path(&output_dir);
|
||||
let dir_path = Path::new(&expanded_dir);
|
||||
if !dir_path.exists() {
|
||||
std::fs::create_dir_all(dir_path)
|
||||
.map_err(|e| format!("Failed to create output directory: {e}"))?;
|
||||
}
|
||||
let path = clip_exporter::generate_output_path(
|
||||
&expanded_dir,
|
||||
&video_title,
|
||||
&format!("{}-screenshot", timestamp_label),
|
||||
"png",
|
||||
);
|
||||
std::fs::write(&path, &png_data)
|
||||
.map_err(|e| format!("Failed to write screenshot: {e}"))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_clips(
|
||||
config: ExportConfig,
|
||||
|
||||
@@ -51,15 +51,19 @@ pub async fn extract_waveform_tiers(
|
||||
pub async fn extract_thumbnails(
|
||||
file_path: String,
|
||||
duration: f64,
|
||||
output_dir: Option<String>,
|
||||
) -> Result<Vec<ThumbnailSpritesheet>, String> {
|
||||
eprintln!(
|
||||
"[video-clipper:thumbnails] starting for '{}' (duration={})",
|
||||
file_path, duration
|
||||
);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let temp_dir = std::env::temp_dir()
|
||||
.join("video-clipper-thumbs")
|
||||
.join(uuid::Uuid::new_v4().to_string());
|
||||
let temp_dir = match output_dir {
|
||||
Some(dir) => std::path::PathBuf::from(dir),
|
||||
None => std::env::temp_dir()
|
||||
.join("video-clipper-thumbs")
|
||||
.join(uuid::Uuid::new_v4().to_string()),
|
||||
};
|
||||
let temp_dir_str = temp_dir.to_string_lossy().to_string();
|
||||
thumbnail_extractor::extract_thumbnails(&file_path, duration, &temp_dir_str)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod cache;
|
||||
pub mod dependencies;
|
||||
pub mod export;
|
||||
pub mod media_analysis;
|
||||
|
||||
@@ -5,8 +5,9 @@ mod services;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use commands::{dependencies, export, media_analysis, video};
|
||||
use tauri::Manager;
|
||||
use commands::{cache, dependencies, export, media_analysis, video};
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
struct MediaServerPort(u16);
|
||||
|
||||
@@ -100,14 +101,58 @@ pub fn run() {
|
||||
.setup(|app| {
|
||||
fix_path_env();
|
||||
|
||||
// Initialize cache manager with Tauri-resolved app data directory
|
||||
let app_data_dir = app.path().app_data_dir()
|
||||
.expect("Failed to resolve app data dir");
|
||||
std::fs::create_dir_all(&app_data_dir).ok();
|
||||
services::cache_manager::init(app_data_dir);
|
||||
|
||||
// Bind synchronously (no async needed), then spawn on Tauri's runtime
|
||||
let (port, listener) = services::media_server::bind_media_server()
|
||||
.expect("Failed to bind media server");
|
||||
eprintln!("[video-clipper] media server bound to port {port}");
|
||||
app.manage(MediaServerPort(port));
|
||||
services::media_server::spawn_media_server(listener);
|
||||
|
||||
// Custom macOS menu — open our Svelte About dialog instead of native
|
||||
let about_item = MenuItemBuilder::new("About GUI Video Clipper")
|
||||
.id("about")
|
||||
.build(app)?;
|
||||
|
||||
let app_menu = SubmenuBuilder::new(app, "GUI Video Clipper")
|
||||
.item(&about_item)
|
||||
.separator()
|
||||
.item(&PredefinedMenuItem::hide(app, None)?)
|
||||
.item(&PredefinedMenuItem::hide_others(app, None)?)
|
||||
.item(&PredefinedMenuItem::show_all(app, None)?)
|
||||
.separator()
|
||||
.quit()
|
||||
.build()?;
|
||||
|
||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
.redo()
|
||||
.separator()
|
||||
.cut()
|
||||
.copy()
|
||||
.paste()
|
||||
.select_all()
|
||||
.build()?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&app_menu)
|
||||
.item(&edit_menu)
|
||||
.build()?;
|
||||
|
||||
app.set_menu(menu)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_menu_event(|app, event| {
|
||||
if event.id().as_ref() == "about" {
|
||||
let _ = app.emit("open-about", ());
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
dependencies::check_dependencies,
|
||||
dependencies::install_dependency,
|
||||
@@ -123,6 +168,14 @@ pub fn run() {
|
||||
media_analysis::check_embedded_subtitles,
|
||||
media_analysis::list_system_fonts,
|
||||
export::export_clips,
|
||||
export::save_screenshot,
|
||||
cache::load_cached_analysis,
|
||||
cache::save_analysis_to_cache,
|
||||
cache::clear_video_cache,
|
||||
cache::clear_all_cache,
|
||||
cache::get_cache_stats,
|
||||
cache::get_thumbnail_cache_dir,
|
||||
cache::get_caption_cache_dir,
|
||||
get_media_server_port,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
221
src-tauri/src/services/cache_manager.rs
Normal file
221
src-tauri/src/services/cache_manager.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
// Future: used by project file save/load and cache Tauri commands.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Initialize the cache manager with the Tauri-resolved app data directory.
|
||||
/// Must be called once during app setup before any cache operations.
|
||||
pub fn init(app_data_dir: PathBuf) {
|
||||
APP_DATA_DIR.set(app_data_dir).ok();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CacheManifest {
|
||||
pub version: u32,
|
||||
pub source_url: String,
|
||||
pub title: String,
|
||||
pub duration: f64,
|
||||
pub fps: f64,
|
||||
pub created_at: String,
|
||||
pub last_accessed_at: String,
|
||||
pub waveform_tiers: Option<WaveformTiers>,
|
||||
pub keyframe_positions: Vec<f64>,
|
||||
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
|
||||
pub caption_file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CacheStats {
|
||||
pub entry_count: usize,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
/// First 16 hex characters of the SHA-256 hash of the URL.
|
||||
pub fn url_hash(url: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
result[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub fn cache_base_dir() -> PathBuf {
|
||||
APP_DATA_DIR
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("gui-video-clipper")
|
||||
})
|
||||
.join("cache")
|
||||
}
|
||||
|
||||
pub fn cache_dir_for_url(url: &str) -> PathBuf {
|
||||
cache_base_dir().join(url_hash(url))
|
||||
}
|
||||
|
||||
pub fn thumbnail_cache_dir(url: &str) -> PathBuf {
|
||||
cache_dir_for_url(url).join("thumbnails")
|
||||
}
|
||||
|
||||
pub fn caption_cache_dir(url: &str) -> PathBuf {
|
||||
cache_dir_for_url(url).join("captions")
|
||||
}
|
||||
|
||||
fn manifest_path(url: &str) -> PathBuf {
|
||||
cache_dir_for_url(url).join("manifest.json")
|
||||
}
|
||||
|
||||
fn now_iso8601() -> String {
|
||||
// Use a simple UTC timestamp without pulling in chrono
|
||||
let duration = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
// Format as ISO 8601 — good enough for cache timestamps
|
||||
format!("{secs}")
|
||||
}
|
||||
|
||||
pub fn load_manifest(url: &str) -> Option<CacheManifest> {
|
||||
let path = manifest_path(url);
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let data = std::fs::read_to_string(&path).ok()?;
|
||||
let mut manifest: CacheManifest = serde_json::from_str(&data).ok()?;
|
||||
// Update last-accessed timestamp
|
||||
manifest.last_accessed_at = now_iso8601();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&manifest) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
Some(manifest)
|
||||
}
|
||||
|
||||
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String> {
|
||||
let dir = cache_dir_for_url(url);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
let path = dir.join("manifest.json");
|
||||
let json = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|e| format!("Failed to serialize manifest: {e}"))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| format!("Failed to write manifest: {e}"))?;
|
||||
eprintln!("[video-clipper:cache] saved manifest to '{}'", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_cache_entry(url: &str) -> Result<(), String> {
|
||||
let dir = cache_dir_for_url(url);
|
||||
if dir.is_dir() {
|
||||
std::fs::remove_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to remove cache entry: {e}"))?;
|
||||
eprintln!("[video-clipper:cache] cleared entry at '{}'", dir.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_all_cache() -> Result<(), String> {
|
||||
let base = cache_base_dir();
|
||||
if base.is_dir() {
|
||||
std::fs::remove_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to remove cache dir: {e}"))?;
|
||||
eprintln!("[video-clipper:cache] cleared all cache at '{}'", base.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_cache_stats() -> Result<CacheStats, String> {
|
||||
let base = cache_base_dir();
|
||||
if !base.is_dir() {
|
||||
return Ok(CacheStats {
|
||||
entry_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut entry_count = 0usize;
|
||||
let mut total_bytes = 0u64;
|
||||
|
||||
let entries = std::fs::read_dir(&base)
|
||||
.map_err(|e| format!("Failed to read cache dir: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
entry_count += 1;
|
||||
total_bytes += dir_size(&path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CacheStats {
|
||||
entry_count,
|
||||
total_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn dir_size(path: &std::path::Path) -> u64 {
|
||||
let mut total = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_file() {
|
||||
total += std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
|
||||
} else if p.is_dir() {
|
||||
total += dir_size(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_url_hash_length() {
|
||||
let hash = url_hash("https://youtube.com/watch?v=abc123");
|
||||
assert_eq!(hash.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_hash_deterministic() {
|
||||
let url = "https://youtube.com/watch?v=abc123";
|
||||
assert_eq!(url_hash(url), url_hash(url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_hash_different_urls() {
|
||||
let a = url_hash("https://youtube.com/watch?v=abc");
|
||||
let b = url_hash("https://youtube.com/watch?v=xyz");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_dir_for_url_contains_hash() {
|
||||
let url = "https://youtube.com/watch?v=test";
|
||||
let dir = cache_dir_for_url(url);
|
||||
let hash = url_hash(url);
|
||||
assert!(dir.to_string_lossy().contains(&hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thumbnail_cache_dir_is_subdir() {
|
||||
let url = "https://example.com/video";
|
||||
let base = cache_dir_for_url(url);
|
||||
let thumbs = thumbnail_cache_dir(url);
|
||||
assert!(thumbs.starts_with(&base));
|
||||
assert!(thumbs.to_string_lossy().ends_with("thumbnails"));
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,18 @@ pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split(',').collect();
|
||||
// Format: media_type,pts_time,flags
|
||||
// We want lines where flags contain 'K' (keyframe)
|
||||
if parts.len() >= 3 && parts[2].contains('K') {
|
||||
parts[1].parse::<f64>().ok()
|
||||
let trimmed = line.trim().trim_end_matches(',');
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// New format (ffprobe 8.x): just a timestamp per line
|
||||
if let Ok(val) = trimmed.parse::<f64>() {
|
||||
return Some(val);
|
||||
}
|
||||
// Legacy format: section,pts_time,flags or section,pts_time
|
||||
let parts: Vec<&str> = trimmed.split(',').collect();
|
||||
if parts.len() >= 2 {
|
||||
parts[1].trim().parse::<f64>().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -23,7 +30,7 @@ pub fn extract_keyframes(file_path: &str) -> Result<Vec<f64>, String> {
|
||||
"-select_streams",
|
||||
"v",
|
||||
"-show_entries",
|
||||
"frame=pts_time,flags",
|
||||
"frame=pts_time",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"-skip_frame",
|
||||
@@ -49,8 +56,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_ffprobe_keyframes() {
|
||||
let output = "video,0.000000,K__\nvideo,0.500000,___\nvideo,1.000000,K__\nvideo,1.500000,___\nvideo,2.000000,K__\n";
|
||||
fn test_parse_new_format_plain_timestamps() {
|
||||
let output = "0.000000,\n1.000000\n2.000000,\n";
|
||||
let result = parse_ffprobe_output(output);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert!((result[0] - 0.0).abs() < 0.001);
|
||||
@@ -59,13 +66,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ffprobe_empty() {
|
||||
fn test_parse_legacy_format_with_flags() {
|
||||
let output = "video,0.000000,K__\nvideo,0.500000,___\nvideo,1.000000,K__\nvideo,1.500000,___\nvideo,2.000000,K__\n";
|
||||
let result = parse_ffprobe_output(output);
|
||||
assert_eq!(result.len(), 5);
|
||||
assert!((result[0] - 0.0).abs() < 0.001);
|
||||
assert!((result[2] - 1.0).abs() < 0.001);
|
||||
assert!((result[4] - 2.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_legacy_format_with_section_prefix() {
|
||||
let output = "frame,0.000000,\nframe,1.000000\n";
|
||||
let result = parse_ffprobe_output(output);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!((result[0] - 0.0).abs() < 0.001);
|
||||
assert!((result[1] - 1.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty() {
|
||||
assert!(parse_ffprobe_output("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ffprobe_no_keyframes() {
|
||||
let output = "video,0.500000,___\nvideo,1.500000,___\n";
|
||||
assert!(parse_ffprobe_output(output).is_empty());
|
||||
fn test_parse_whitespace_only() {
|
||||
assert!(parse_ffprobe_output(" \n \n").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod cache_manager;
|
||||
pub mod clip_exporter;
|
||||
pub mod dependency_manager;
|
||||
pub mod download_manager;
|
||||
|
||||
@@ -30,6 +30,7 @@ pub fn extract_thumbnails(
|
||||
let frames_pattern = format!("{output_dir}/frame_%06d.jpg");
|
||||
let status = Command::new(ffmpeg_bin())
|
||||
.args([
|
||||
"-y",
|
||||
"-i",
|
||||
file_path,
|
||||
"-vf",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "GUI Video Clipper",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"identifier": "xyz.cottongin.gui-video-clipper",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "GUI Video Clipper",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"width": 1100,
|
||||
"height": 750
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -27,7 +27,8 @@
|
||||
"/tmp/**",
|
||||
"/private/tmp/**",
|
||||
"/private/var/**",
|
||||
"/var/**"
|
||||
"/var/**",
|
||||
"$APPDATA/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
514
src/App.svelte
514
src/App.svelte
@@ -10,11 +10,29 @@
|
||||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
||||
import { loadPreferences } from '$lib/stores/preferences.svelte';
|
||||
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
||||
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
|
||||
import PlayerControls from '$lib/components/PlayerControls.svelte';
|
||||
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
|
||||
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.svelte';
|
||||
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
|
||||
import { computeQuickClipRange } from '$lib/utils/clipRange';
|
||||
import { captureFrame } from '$lib/utils/screenshot';
|
||||
import { exit } from '@tauri-apps/plugin-process';
|
||||
import {
|
||||
adjustShuttle,
|
||||
resetShuttleRate,
|
||||
toggleFullscreen,
|
||||
runTransportAction,
|
||||
type TransportKeyAction,
|
||||
} from '$lib/transport/playback';
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
import type { TransportKeyAction } from '$lib/transport/playback';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import {
|
||||
PanelLeft,
|
||||
PanelBottom,
|
||||
Info,
|
||||
Settings,
|
||||
} from '@lucide/svelte';
|
||||
|
||||
let showSetupWizard = $state(true);
|
||||
let showPreferences = $state(false);
|
||||
@@ -23,18 +41,50 @@
|
||||
let shuttleRate = $state(1);
|
||||
|
||||
let transportControls = $state<TransportControls | null>(null);
|
||||
let videoPlayerRef = $state<VideoPlayer | null>(null);
|
||||
|
||||
// Resizable split: timeline height in px (clip list gets the rest)
|
||||
let timelineHeight = $state(180);
|
||||
let isResizing = $state(false);
|
||||
let resizeStartY = $state(0);
|
||||
let resizeStartHeight = $state(0);
|
||||
let splitAreaEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
let isSidebarResizing = $state(false);
|
||||
let sidebarResizeStartX = $state(0);
|
||||
let sidebarResizeStartWidth = $state(0);
|
||||
let windowWidth = $state(window.innerWidth);
|
||||
|
||||
let isPlayerMode = $derived(preferences.appMode === 'player');
|
||||
|
||||
// Auto-hide state for Player mode
|
||||
let showPlayerControls = $state(true);
|
||||
let showToolbar = $state(true);
|
||||
let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let toolbarHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Caption state for PlayerControls
|
||||
let captionsEnabled = $state(true);
|
||||
|
||||
// Pending timeline zoom for cut-clip feature
|
||||
let pendingTimelineZoom = $state<{ start: number; end: number } | null>(null);
|
||||
|
||||
const CONTROLS_HIDE_DELAY = 2500;
|
||||
const TOOLBAR_PROXIMITY = 50;
|
||||
|
||||
let isLeft = $derived(preferences.clipListPosition === 'left');
|
||||
|
||||
$effect(() => {
|
||||
loadPreferences();
|
||||
});
|
||||
|
||||
// Listen for native macOS menu "About" action
|
||||
$effect(() => {
|
||||
const unlisten = listen('open-about', () => {
|
||||
showAboutDialog = true;
|
||||
});
|
||||
return () => { unlisten.then((fn) => fn()); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (session.status === 'resolving') {
|
||||
shuttleRate = 1;
|
||||
@@ -42,8 +92,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isPlayerMode) return;
|
||||
// Read play state so this re-runs on play/pause changes —
|
||||
// controls briefly show then auto-hide regardless of play state
|
||||
void session.isPlaying;
|
||||
resetControlsTimer();
|
||||
});
|
||||
|
||||
function dispatchTransport(action: TransportKeyAction) {
|
||||
transportControls?.handleKeyAction(action);
|
||||
if (transportControls) {
|
||||
transportControls.handleKeyAction(action);
|
||||
} else {
|
||||
runTransportAction(action, markInPoint, markOutPoint);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeStart(e: MouseEvent) {
|
||||
@@ -54,10 +116,9 @@
|
||||
}
|
||||
|
||||
function handleResizeMove(e: MouseEvent) {
|
||||
if (!isResizing || !splitAreaEl) return;
|
||||
if (!isResizing) return;
|
||||
const delta = e.clientY - resizeStartY;
|
||||
const totalHeight = splitAreaEl.clientHeight;
|
||||
const newHeight = Math.max(40, Math.min(totalHeight - 20, resizeStartHeight + delta));
|
||||
const newHeight = Math.max(40, Math.min(500, resizeStartHeight + delta));
|
||||
timelineHeight = newHeight;
|
||||
}
|
||||
|
||||
@@ -65,6 +126,108 @@
|
||||
isResizing = false;
|
||||
}
|
||||
|
||||
function toggleClipListPosition() {
|
||||
const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom';
|
||||
setClipListPosition(next);
|
||||
}
|
||||
|
||||
function handleSidebarResizeStart(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isSidebarResizing = true;
|
||||
sidebarResizeStartX = e.clientX;
|
||||
sidebarResizeStartWidth = preferences.clipListWidth;
|
||||
}
|
||||
|
||||
function handleSidebarResizeMove(e: MouseEvent) {
|
||||
if (!isSidebarResizing) return;
|
||||
const delta = e.clientX - sidebarResizeStartX;
|
||||
const maxWidth = Math.round(windowWidth * 0.4);
|
||||
const minWidth = Math.min(150, maxWidth);
|
||||
const newWidth = Math.max(minWidth, Math.min(maxWidth, sidebarResizeStartWidth + delta));
|
||||
preferences.clipListWidth = newWidth;
|
||||
}
|
||||
|
||||
function handleSidebarResizeEnd() {
|
||||
if (isSidebarResizing) {
|
||||
isSidebarResizing = false;
|
||||
setClipListWidth(preferences.clipListWidth);
|
||||
}
|
||||
}
|
||||
|
||||
function resetControlsTimer() {
|
||||
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||
showPlayerControls = true;
|
||||
controlsHideTimer = setTimeout(() => {
|
||||
showPlayerControls = false;
|
||||
}, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function resetToolbarTimer() {
|
||||
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
|
||||
showToolbar = true;
|
||||
toolbarHideTimer = setTimeout(() => {
|
||||
showToolbar = false;
|
||||
}, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
|
||||
function handlePlayerMouseMove(e: MouseEvent) {
|
||||
if (!isPlayerMode) return;
|
||||
|
||||
// Controls: show on any mouse movement over the video area
|
||||
resetControlsTimer();
|
||||
|
||||
// Toolbar: show when near top edge
|
||||
if (e.clientY < TOOLBAR_PROXIMITY) {
|
||||
resetToolbarTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
const next = preferences.appMode === 'clipper' ? 'player' : 'clipper';
|
||||
setAppMode(next);
|
||||
// When entering Player mode, show controls initially
|
||||
if (next === 'player') {
|
||||
showPlayerControls = true;
|
||||
showToolbar = true;
|
||||
resetControlsTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function cutClip(position: 'at' | 'before') {
|
||||
const range = computeQuickClipRange(session.currentTime, session.duration, position);
|
||||
if (!range) return;
|
||||
addClip(range.start, range.end);
|
||||
pendingTimelineZoom = { start: range.start, end: range.end };
|
||||
setAppMode('clipper');
|
||||
}
|
||||
|
||||
async function captureScreenshot(withCaptions: boolean) {
|
||||
if (session.status !== 'ready') {
|
||||
console.warn('[screenshot] No video ready, skipping');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(`[screenshot] Capturing frame (captions: ${withCaptions})...`);
|
||||
const cues = videoPlayerRef?.getParsedCues() ?? [];
|
||||
const path = await captureFrame({
|
||||
currentTime: session.currentTime,
|
||||
videoTitle: session.title,
|
||||
outputDirectory: preferences.outputDirectory,
|
||||
withCaptions,
|
||||
captionsEnabled,
|
||||
captionSettings: preferences.captionSettings,
|
||||
parsedCues: cues,
|
||||
});
|
||||
console.log(`[screenshot] Saved: ${path}`);
|
||||
} catch (err) {
|
||||
console.error('[screenshot] Failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleCaptions() {
|
||||
captionsEnabled = !captionsEnabled;
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||
@@ -76,11 +239,19 @@
|
||||
break;
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
dispatchTransport(e.shiftKey ? 'keyframe-back' : 'frame-back');
|
||||
dispatchTransport('frame-back');
|
||||
break;
|
||||
case '.':
|
||||
e.preventDefault();
|
||||
dispatchTransport(e.shiftKey ? 'keyframe-forward' : 'frame-forward');
|
||||
dispatchTransport('frame-forward');
|
||||
break;
|
||||
case '<':
|
||||
e.preventDefault();
|
||||
dispatchTransport('keyframe-back');
|
||||
break;
|
||||
case '>':
|
||||
e.preventDefault();
|
||||
dispatchTransport('keyframe-forward');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
@@ -90,20 +261,52 @@
|
||||
e.preventDefault();
|
||||
dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5');
|
||||
break;
|
||||
case 'p':
|
||||
case 'P':
|
||||
case 'c':
|
||||
case 'C':
|
||||
e.preventDefault();
|
||||
toggleMode();
|
||||
break;
|
||||
case 'x':
|
||||
case 'X':
|
||||
if (isPlayerMode) {
|
||||
e.preventDefault();
|
||||
cutClip('at');
|
||||
}
|
||||
break;
|
||||
case 'z':
|
||||
case 'Z':
|
||||
if (isPlayerMode) {
|
||||
e.preventDefault();
|
||||
cutClip('before');
|
||||
}
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (isPlayerMode) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
}
|
||||
break;
|
||||
case 'i':
|
||||
case 'I':
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-in');
|
||||
if (!isPlayerMode) {
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-in');
|
||||
}
|
||||
break;
|
||||
case 'o':
|
||||
case 'O':
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-out');
|
||||
if (!isPlayerMode) {
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-out');
|
||||
}
|
||||
break;
|
||||
case 'j':
|
||||
case 'J':
|
||||
e.preventDefault();
|
||||
shuttleRate = adjustShuttle(-1, shuttleRate);
|
||||
shuttleRate = adjustShuttle(-1);
|
||||
break;
|
||||
case 'k':
|
||||
case 'K':
|
||||
@@ -113,13 +316,15 @@
|
||||
case 'l':
|
||||
case 'L':
|
||||
e.preventDefault();
|
||||
shuttleRate = adjustShuttle(1, shuttleRate);
|
||||
shuttleRate = adjustShuttle(1);
|
||||
break;
|
||||
case 'Delete':
|
||||
case 'Backspace': {
|
||||
e.preventDefault();
|
||||
const selectedId = getSelectedClipId();
|
||||
if (selectedId) removeClip(selectedId);
|
||||
if (!isPlayerMode) {
|
||||
e.preventDefault();
|
||||
const selectedId = getSelectedClipId();
|
||||
if (selectedId) removeClip(selectedId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'a':
|
||||
@@ -128,11 +333,21 @@
|
||||
}
|
||||
break;
|
||||
case 'e':
|
||||
if (e.metaKey) {
|
||||
if (e.metaKey && !isPlayerMode) {
|
||||
e.preventDefault();
|
||||
showExportDialog = true;
|
||||
}
|
||||
break;
|
||||
case 'q':
|
||||
case 'Q':
|
||||
e.preventDefault();
|
||||
exit(0);
|
||||
break;
|
||||
case 's':
|
||||
case 'S':
|
||||
e.preventDefault();
|
||||
captureScreenshot(!e.shiftKey);
|
||||
break;
|
||||
case '/':
|
||||
if (e.metaKey) {
|
||||
e.preventDefault();
|
||||
@@ -147,8 +362,18 @@
|
||||
|
||||
<svelte:window
|
||||
onkeydown={handleGlobalKeydown}
|
||||
onmousemove={handleResizeMove}
|
||||
onmouseup={handleResizeEnd}
|
||||
onmousemove={(e) => {
|
||||
handleResizeMove(e);
|
||||
handleSidebarResizeMove(e);
|
||||
handlePlayerMouseMove(e);
|
||||
}}
|
||||
onmouseup={() => {
|
||||
handleResizeEnd();
|
||||
handleSidebarResizeEnd();
|
||||
}}
|
||||
onresize={() => {
|
||||
windowWidth = window.innerWidth;
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if showSetupWizard}
|
||||
@@ -171,48 +396,129 @@
|
||||
<ProcessingModal />
|
||||
{/if}
|
||||
|
||||
<div class="app-shell">
|
||||
<header class="toolbar">
|
||||
<div class="app-shell" class:player-mode={isPlayerMode}>
|
||||
<header class="toolbar" class:auto-hide={isPlayerMode} class:toolbar-visible={isPlayerMode && showToolbar} class:toolbar-hidden={isPlayerMode && !showToolbar}>
|
||||
<UrlInput />
|
||||
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
|
||||
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
onclick={toggleMode}
|
||||
title={isPlayerMode ? 'Switch to Clipper (C)' : 'Switch to Player (P)'}
|
||||
>
|
||||
{isPlayerMode ? 'Player' : 'Clipper'}
|
||||
</button>
|
||||
{#if !isPlayerMode}
|
||||
<button
|
||||
class="layout-btn"
|
||||
onclick={toggleClipListPosition}
|
||||
title={preferences.clipListPosition === 'bottom'
|
||||
? 'Move clip list to sidebar'
|
||||
: 'Move clip list to bottom'}
|
||||
>
|
||||
{#if preferences.clipListPosition === 'bottom'}<PanelLeft size={16} />{:else}<PanelBottom size={16} />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About"><Info size={16} /></button>
|
||||
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences"><Settings size={16} /></button>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<VideoPlayer />
|
||||
<TransportControls bind:this={transportControls} />
|
||||
<div class="timeline-clip-area" bind:this={splitAreaEl}>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
<main class="content" class:player-mode={isPlayerMode} class:layout-left={!isPlayerMode && isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
||||
{#if !isPlayerMode}
|
||||
<!-- Sidebar clip list: visible only in left mode -->
|
||||
<div class="sidebar-area" class:hidden={!isLeft}>
|
||||
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
||||
</div>
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
class="sidebar-handle"
|
||||
class:hidden={!isLeft}
|
||||
class:active={isSidebarResizing}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize timeline and clip list"
|
||||
aria-valuenow={timelineHeight}
|
||||
aria-valuemin={40}
|
||||
aria-valuemax={500}
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize clip list sidebar"
|
||||
aria-valuenow={preferences.clipListWidth}
|
||||
aria-valuemin={150}
|
||||
aria-valuemax={Math.round(windowWidth * 0.4)}
|
||||
tabindex="0"
|
||||
onmousedown={handleResizeStart}
|
||||
onmousedown={handleSidebarResizeStart}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const maxWidth = Math.round(windowWidth * 0.4);
|
||||
const minWidth = Math.min(150, maxWidth);
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
setClipListWidth(
|
||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
|
||||
);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||
setClipListWidth(
|
||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
|
||||
);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="cliplist-pane">
|
||||
{/if}
|
||||
|
||||
<!-- Video area: always mounted, never destroyed -->
|
||||
<div class="video-area" class:player-video={isPlayerMode}>
|
||||
<VideoPlayer bind:this={videoPlayerRef} bind:captionsEnabled playerControlsVisible={isPlayerMode && showPlayerControls} />
|
||||
{#if isPlayerMode}
|
||||
<PlayerControls
|
||||
visible={showPlayerControls}
|
||||
{captionsEnabled}
|
||||
hasCaptions={session.hasCaptions || false}
|
||||
onToggleCaptions={handleToggleCaptions}
|
||||
onOpenCaptionSettings={() => {}}
|
||||
onCutClip={cutClip}
|
||||
/>
|
||||
<PlayerTimeline visible={showPlayerControls} />
|
||||
{:else}
|
||||
<div transition:fade={{ duration: 200 }}>
|
||||
<TransportControls bind:this={transportControls} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isPlayerMode}
|
||||
<!-- Timeline section: always full width -->
|
||||
<div class="lower-section" transition:fly={{ y: 50, duration: 250 }}>
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize timeline"
|
||||
aria-valuenow={timelineHeight}
|
||||
aria-valuemin={40}
|
||||
aria-valuemax={500}
|
||||
tabindex="0"
|
||||
onmousedown={handleResizeStart}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline
|
||||
zoomRegion={pendingTimelineZoom}
|
||||
onZoomApplied={() => { pendingTimelineZoom = null; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom clip list: visible only in bottom mode -->
|
||||
<div class="bottom-cliplist" class:hidden={isLeft} transition:fly={{ y: 30, duration: 250 }}>
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||
{#if !isPlayerMode}
|
||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -231,20 +537,78 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Grid layout: bottom mode (default) --- */
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"video"
|
||||
"lower"
|
||||
"bottom-clips";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-clip-area {
|
||||
.video-area {
|
||||
grid-area: video;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lower-section {
|
||||
grid-area: lower;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.bottom-cliplist {
|
||||
grid-area: bottom-clips;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar elements hidden in bottom mode */
|
||||
.sidebar-area {
|
||||
grid-area: sidebar;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-handle {
|
||||
grid-area: handle;
|
||||
width: 5px;
|
||||
background: var(--border);
|
||||
cursor: ew-resize;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-handle:hover,
|
||||
.sidebar-handle.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- Grid layout: left mode --- */
|
||||
.content.layout-left {
|
||||
grid-template-areas:
|
||||
"sidebar handle video"
|
||||
"lower lower lower"
|
||||
"bottom-clips bottom-clips bottom-clips";
|
||||
grid-template-columns: var(--sidebar-width, 220px) 5px 1fr;
|
||||
grid-template-rows: 1fr auto 0;
|
||||
}
|
||||
|
||||
.timeline-pane {
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
@@ -264,12 +628,54 @@
|
||||
background: var(--accent, #89b4fa);
|
||||
}
|
||||
|
||||
.cliplist-pane {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
/* --- Mode toggle button --- */
|
||||
.mode-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* --- Player mode layout --- */
|
||||
.content.player-mode {
|
||||
grid-template-areas: "video";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.player-video {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Toolbar auto-hide --- */
|
||||
.toolbar.auto-hide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toolbar.toolbar-hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbar.toolbar-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layout-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.about-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
|
||||
@@ -37,6 +37,15 @@ body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.content {
|
||||
transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
/* Player mode: app shell becomes relative for overlay positioning */
|
||||
.app-shell.player-mode {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
|
||||
60
src/lib/bindings/cache.ts
Normal file
60
src/lib/bindings/cache.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { WaveformTiers, ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
|
||||
|
||||
export interface CachedAnalysis {
|
||||
waveformTiers: WaveformTiers | null;
|
||||
keyframePositions: number[];
|
||||
thumbnailSpritesheets: ThumbnailSpritesheet[];
|
||||
captionFilePath: string | null;
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
entryCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export async function loadCachedAnalysis(url: string): Promise<CachedAnalysis | null> {
|
||||
return invoke<CachedAnalysis | null>('load_cached_analysis', { url });
|
||||
}
|
||||
|
||||
export async function saveAnalysisToCache(
|
||||
url: string,
|
||||
title: string,
|
||||
duration: number,
|
||||
fps: number,
|
||||
waveformTiers: WaveformTiers | null,
|
||||
keyframePositions: number[],
|
||||
thumbnailSpritesheets: ThumbnailSpritesheet[],
|
||||
captionFilePath: string | null
|
||||
): Promise<void> {
|
||||
return invoke<void>('save_analysis_to_cache', {
|
||||
url,
|
||||
title,
|
||||
duration,
|
||||
fps,
|
||||
waveformTiers,
|
||||
keyframePositions,
|
||||
thumbnailSpritesheets,
|
||||
captionFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearVideoCache(url: string): Promise<void> {
|
||||
return invoke<void>('clear_video_cache', { url });
|
||||
}
|
||||
|
||||
export async function clearAllCache(): Promise<void> {
|
||||
return invoke<void>('clear_all_cache');
|
||||
}
|
||||
|
||||
export async function getCacheStats(): Promise<CacheStats> {
|
||||
return invoke<CacheStats>('get_cache_stats');
|
||||
}
|
||||
|
||||
export async function getThumbnailCacheDir(url: string): Promise<string> {
|
||||
return invoke<string>('get_thumbnail_cache_dir', { url });
|
||||
}
|
||||
|
||||
export async function getCaptionCacheDir(url: string): Promise<string> {
|
||||
return invoke<string>('get_caption_cache_dir', { url });
|
||||
}
|
||||
@@ -81,3 +81,17 @@ export async function exportClips(
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveScreenshot(
|
||||
outputDir: string,
|
||||
videoTitle: string,
|
||||
timestampLabel: string,
|
||||
pngData: number[],
|
||||
): Promise<string> {
|
||||
return invoke('save_screenshot', {
|
||||
outputDir,
|
||||
videoTitle,
|
||||
timestampLabel,
|
||||
pngData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,11 +41,13 @@ export async function extractWaveformTiers(
|
||||
|
||||
export async function extractThumbnails(
|
||||
filePath: string,
|
||||
duration: number
|
||||
duration: number,
|
||||
outputDir?: string
|
||||
): Promise<ThumbnailSpritesheet[]> {
|
||||
return invoke<ThumbnailSpritesheet[]>('extract_thumbnails', {
|
||||
filePath,
|
||||
duration,
|
||||
outputDir: outputDir ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,46 @@
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
let version = $state('');
|
||||
let activeTab = $state<'about' | 'shortcuts'>('about');
|
||||
|
||||
$effect(() => {
|
||||
getVersion().then((v) => {
|
||||
version = v;
|
||||
});
|
||||
});
|
||||
|
||||
const globalShortcuts = [
|
||||
{ key: 'Space', action: 'Play / Pause' },
|
||||
{ key: 'K', action: 'Play / Pause' },
|
||||
{ key: 'J', action: 'Shuttle slower (−0.25×)' },
|
||||
{ key: 'L', action: 'Shuttle faster (+0.25×)' },
|
||||
{ key: '←', action: 'Seek back 5s' },
|
||||
{ key: '→', action: 'Seek forward 5s' },
|
||||
{ key: 'Shift+←', action: 'Seek back 1s' },
|
||||
{ key: 'Shift+→', action: 'Seek forward 1s' },
|
||||
{ key: ',', action: 'Previous frame' },
|
||||
{ key: '.', action: 'Next frame' },
|
||||
{ key: '< (Shift+,)', action: 'Previous keyframe' },
|
||||
{ key: '> (Shift+.)', action: 'Next keyframe' },
|
||||
{ key: 'C / P', action: 'Toggle mode' },
|
||||
{ key: 'S', action: 'Screenshot (with captions)' },
|
||||
{ key: 'Shift+S', action: 'Screenshot (without captions)' },
|
||||
{ key: 'Q', action: 'Quit' },
|
||||
{ key: '⌘/', action: 'About' },
|
||||
];
|
||||
|
||||
const playerShortcuts = [
|
||||
{ key: 'F', action: 'Toggle fullscreen' },
|
||||
{ key: 'X', action: 'Cut clip at playhead → Clipper' },
|
||||
{ key: 'Z', action: 'Cut clip 10s before playhead → Clipper' },
|
||||
];
|
||||
|
||||
const clipperShortcuts = [
|
||||
{ key: 'I', action: 'Set in-point' },
|
||||
{ key: 'O', action: 'Set out-point' },
|
||||
{ key: 'Delete / ⌫', action: 'Remove selected clip' },
|
||||
{ key: '⌘E', action: 'Export' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -26,26 +60,134 @@
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
class:wide={activeTab === 'shortcuts'}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img class="app-icon" src="/favicon.svg" alt="GUI Video Clipper icon" />
|
||||
<h2>GUI Video Clipper</h2>
|
||||
{#if version}
|
||||
<span class="version">v{version}</span>
|
||||
<!-- Tab bar -->
|
||||
<div class="tab-bar" role="tablist">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'about'}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'about'}
|
||||
onclick={() => { activeTab = 'about'; }}
|
||||
type="button"
|
||||
>About</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'shortcuts'}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'shortcuts'}
|
||||
onclick={() => { activeTab = 'shortcuts'; }}
|
||||
type="button"
|
||||
>Shortcuts</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'about'}
|
||||
<!-- About content -->
|
||||
<div class="about-content">
|
||||
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
||||
<stop offset="0%" stop-color="#fb923c"/>
|
||||
<stop offset="100%" stop-color="#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
||||
<g transform="translate(-3, 3)">
|
||||
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
||||
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
</g>
|
||||
<g transform="translate(3, -3)">
|
||||
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
|
||||
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
</g>
|
||||
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
|
||||
<g transform="rotate(20, 256, 256)">
|
||||
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
|
||||
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
|
||||
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
|
||||
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
|
||||
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h2>GUI Video Clipper</h2>
|
||||
{#if version}
|
||||
<span class="version">v{version}</span>
|
||||
{/if}
|
||||
<p class="author">by cottongin</p>
|
||||
<p class="license">MIT License</p>
|
||||
<a
|
||||
class="repo-link"
|
||||
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
code.cottongin.xyz/cottongin/gui-video-clipper
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Shortcuts content -->
|
||||
<div class="shortcuts-content">
|
||||
<section>
|
||||
<h3>Global</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each globalShortcuts as { key, action }}
|
||||
<tr>
|
||||
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||
<td class="action-cell">{action}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Player Mode</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each playerShortcuts as { key, action }}
|
||||
<tr>
|
||||
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||
<td class="action-cell">{action}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Clipper Mode</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each clipperShortcuts as { key, action }}
|
||||
<tr>
|
||||
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||
<td class="action-cell">{action}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="author">by cottongin</p>
|
||||
<p class="license">MIT License</p>
|
||||
<a
|
||||
class="repo-link"
|
||||
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
code.cottongin.xyz/cottongin/gui-video-clipper
|
||||
</a>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onClose}>Close</button>
|
||||
</div>
|
||||
@@ -67,7 +209,7 @@
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
padding: 24px 32px 32px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
@@ -75,6 +217,53 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: max-width 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog.wide {
|
||||
max-width: 520px;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
/* --- Tabs --- */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* --- About content --- */
|
||||
.about-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
@@ -117,6 +306,64 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* --- Shortcuts content --- */
|
||||
.shortcuts-content {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
overflow-y: auto;
|
||||
max-height: calc(70vh - 140px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shortcuts-content h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.shortcuts-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.shortcuts-content tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.shortcuts-content tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.key-cell {
|
||||
width: 40%;
|
||||
padding: 5px 8px 5px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
padding: 5px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* --- Actions --- */
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -4,22 +4,26 @@
|
||||
selectClip,
|
||||
removeClip,
|
||||
updateClip,
|
||||
type Clip,
|
||||
} from '$lib/stores/clips.svelte';
|
||||
import { formatTime, parseTime } from '$lib/utils/time';
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
import { seekTo } from '$lib/transport/playback';
|
||||
|
||||
let { onExport }: { onExport?: () => void } = $props();
|
||||
let {
|
||||
onExport,
|
||||
position = 'bottom',
|
||||
}: { onExport?: () => void; position?: 'bottom' | 'left' } = $props();
|
||||
|
||||
let clips = $derived(clipStore.clips);
|
||||
let selectedId = $derived(clipStore.selectedClipId);
|
||||
let isSidebar = $derived(position === 'left');
|
||||
|
||||
function handleSelect(id: string) {
|
||||
selectClip(id);
|
||||
const clip = clips.find((c) => c.id === id);
|
||||
if (clip) {
|
||||
session.currentTime = clip.startTime;
|
||||
const videoEl = document.querySelector('video');
|
||||
if (videoEl) videoEl.currentTime = clip.startTime;
|
||||
seekTo(clip.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +40,29 @@
|
||||
|
||||
function handleContainerClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
// Only deselect if the click landed on the container itself, not a child
|
||||
if (target.classList.contains('clip-list')) {
|
||||
selectClip(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayClip(clip: Clip) {
|
||||
seekTo(clip.startTime);
|
||||
const videoEl = document.querySelector('video');
|
||||
if (videoEl?.paused) videoEl.play();
|
||||
}
|
||||
|
||||
function handleGoToStart(clip: Clip) {
|
||||
seekTo(clip.startTime);
|
||||
}
|
||||
|
||||
function handleGoToEnd(clip: Clip) {
|
||||
seekTo(clip.endTime);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="clip-list"
|
||||
class:sidebar={isSidebar}
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
onclick={handleContainerClick}
|
||||
@@ -64,6 +82,7 @@
|
||||
<div
|
||||
class="clip-row"
|
||||
class:selected={clip.id === selectedId}
|
||||
class:sidebar={isSidebar}
|
||||
role="option"
|
||||
aria-selected={clip.id === selectedId}
|
||||
tabindex="0"
|
||||
@@ -75,42 +94,124 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="color-swatch" style="background: {clip.color}"></span>
|
||||
<input
|
||||
class="label-input"
|
||||
type="text"
|
||||
value={clip.label}
|
||||
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.startTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="arrow">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.endTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeClip(clip.id);
|
||||
}}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
{#if isSidebar}
|
||||
<!-- Sidebar (vertical card) layout -->
|
||||
<div class="clip-card">
|
||||
<div class="card-row">
|
||||
<span class="color-swatch" style="background: {clip.color}"></span>
|
||||
<input
|
||||
class="label-input sidebar-label"
|
||||
type="text"
|
||||
value={clip.label}
|
||||
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div class="card-row times">
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.startTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="arrow">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.endTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{#if clip.id === selectedId}
|
||||
<div class="clip-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
|
||||
title="Play clip"
|
||||
>▶</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
|
||||
title="Go to start"
|
||||
>|◀</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
|
||||
title="Go to end"
|
||||
>▶|</button>
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Bottom (wide row) layout -->
|
||||
<span class="color-swatch" style="background: {clip.color}"></span>
|
||||
<input
|
||||
class="label-input"
|
||||
type="text"
|
||||
value={clip.label}
|
||||
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.startTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="arrow">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.endTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{#if clip.id === selectedId}
|
||||
<div class="clip-actions inline">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
|
||||
title="Play clip"
|
||||
>▶</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
|
||||
title="Go to start"
|
||||
>|◀</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
|
||||
title="Go to end"
|
||||
>▶|</button>
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="action-buttons">
|
||||
<div class="list-actions">
|
||||
{#if selectedId}
|
||||
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
|
||||
{/if}
|
||||
@@ -132,6 +233,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clip-list.sidebar {
|
||||
border-top: none;
|
||||
height: 100%;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -146,7 +253,7 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.list-actions {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -154,7 +261,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
.list-actions button {
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
@@ -164,6 +271,7 @@
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* --- Bottom (wide) row layout --- */
|
||||
.clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -186,6 +294,32 @@
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* --- Sidebar (vertical card) layout --- */
|
||||
.clip-row.sidebar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.clip-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-row.times {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Shared elements --- */
|
||||
.color-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@@ -202,6 +336,12 @@
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.label-input.sidebar-label {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label-input:focus {
|
||||
background: var(--bg-primary);
|
||||
outline: 1px solid var(--accent);
|
||||
@@ -230,6 +370,44 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- Clip action buttons --- */
|
||||
.clip-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.clip-actions.inline {
|
||||
padding-top: 0;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: var(--danger);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Legacy delete button for unselected clips in bottom mode */
|
||||
.delete-btn {
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
|
||||
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>
|
||||
@@ -6,6 +6,8 @@
|
||||
setCookieSource,
|
||||
type CookieSource,
|
||||
} from '$lib/stores/preferences.svelte';
|
||||
import { getCacheStats, clearAllCache } from '$lib/bindings/cache';
|
||||
import type { CacheStats } from '$lib/bindings/cache';
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
@@ -13,6 +15,34 @@
|
||||
let cookieFilePath = $state(
|
||||
preferences.cookieSource.type === 'file' ? preferences.cookieSource.path : ''
|
||||
);
|
||||
let cacheStats = $state<CacheStats | null>(null);
|
||||
let clearingCache = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
getCacheStats().then((stats) => {
|
||||
cacheStats = stats;
|
||||
});
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const val = bytes / Math.pow(1024, i);
|
||||
return `${val.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
async function handleClearAllCache() {
|
||||
clearingCache = true;
|
||||
try {
|
||||
await clearAllCache();
|
||||
cacheStats = { entryCount: 0, totalBytes: 0 };
|
||||
} catch (err) {
|
||||
console.error('Failed to clear cache:', err);
|
||||
} finally {
|
||||
clearingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickOutputDirectory() {
|
||||
const selected = await open({
|
||||
@@ -128,6 +158,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Cache</h3>
|
||||
{#if cacheStats}
|
||||
<p class="cache-info">
|
||||
{formatBytes(cacheStats.totalBytes)} used across {cacheStats.entryCount} video{cacheStats.entryCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="cache-info">Loading…</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleClearAllCache}
|
||||
disabled={clearingCache || (cacheStats?.entryCount === 0)}
|
||||
>
|
||||
{clearingCache ? 'Clearing…' : 'Clear All Cache'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onClose}>Done</button>
|
||||
</div>
|
||||
@@ -207,6 +254,12 @@
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.cache-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { session, upgradePreview } from '$lib/stores/videoSession.svelte';
|
||||
import { session, upgradePreview, reprocessCurrentVideo } from '$lib/stores/videoSession.svelte';
|
||||
|
||||
let { onOpenAbout }: { onOpenAbout?: () => void } = $props();
|
||||
|
||||
@@ -25,6 +25,23 @@
|
||||
let isPreviewComplete = $derived(session.previewStatus === 'complete');
|
||||
let isExportDownloading = $derived(session.exportStatus === 'downloading');
|
||||
let isExportComplete = $derived(session.exportStatus === 'complete');
|
||||
|
||||
let canReprocess = $derived(
|
||||
session.status === 'ready' &&
|
||||
session.processingStep === 'done' &&
|
||||
session.activeVideoPath !== null
|
||||
);
|
||||
|
||||
let reprocessing = $state(false);
|
||||
|
||||
async function handleReprocess() {
|
||||
reprocessing = true;
|
||||
try {
|
||||
await reprocessCurrentVideo();
|
||||
} finally {
|
||||
reprocessing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="status-bar">
|
||||
@@ -53,6 +70,16 @@
|
||||
{:else}
|
||||
<span class="status-text">Ready</span>
|
||||
{/if}
|
||||
{#if canReprocess && !isPreviewDownloading}
|
||||
<button
|
||||
class="reprocess-btn"
|
||||
onclick={handleReprocess}
|
||||
disabled={reprocessing}
|
||||
title="Reprocess video (re-extract waveform, thumbnails, keyframes)"
|
||||
>
|
||||
{reprocessing ? '⏳' : '↻'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if appVersion}
|
||||
<button class="version-btn" onclick={() => onOpenAbout?.()} title="About GUI Video Clipper">
|
||||
v{appVersion}
|
||||
@@ -108,6 +135,27 @@
|
||||
color: var(--bg-primary, #1e1e2e);
|
||||
}
|
||||
|
||||
.reprocess-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.reprocess-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reprocess-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.version-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
import TimelineContextMenu from '$lib/components/TimelineContextMenu.svelte';
|
||||
import type { ContextMenuAction } from '$lib/components/TimelineContextMenu.svelte';
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
import {
|
||||
clipStore,
|
||||
selectClip,
|
||||
updateClip,
|
||||
markInPoint,
|
||||
markOutPoint,
|
||||
removeClip,
|
||||
} from '$lib/stores/clips.svelte';
|
||||
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
|
||||
import { drawTimeline, type TimelineState, computeLanes } from '$lib/timeline/renderer';
|
||||
import {
|
||||
computeZoom,
|
||||
panBy,
|
||||
@@ -17,6 +22,14 @@
|
||||
|
||||
type ClipDragTarget = { clipId: string; edge: 'start' | 'end' };
|
||||
|
||||
let {
|
||||
zoomRegion,
|
||||
onZoomApplied,
|
||||
}: {
|
||||
zoomRegion?: { start: number; end: number } | null;
|
||||
onZoomApplied?: () => void;
|
||||
} = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||
let minimapCanvas = $state<HTMLCanvasElement | null>(null);
|
||||
let containerEl = $state<HTMLDivElement | null>(null);
|
||||
@@ -36,6 +49,12 @@
|
||||
let isMinimapDragging = $state(false);
|
||||
let minimapDragStartX = $state(0);
|
||||
let minimapDragStartVisibleStart = $state(0);
|
||||
let mouseX = $state<number | null>(null);
|
||||
let contextMenu = $state<{
|
||||
x: number;
|
||||
y: number;
|
||||
time: number;
|
||||
} | null>(null);
|
||||
|
||||
let isZoomed = $derived(timelineState.zoom > 1.01);
|
||||
|
||||
@@ -83,7 +102,9 @@
|
||||
clipStore.selectedClipId,
|
||||
clipStore.pendingInPoint,
|
||||
waveformData,
|
||||
session.thumbnailSpritesheets
|
||||
session.thumbnailSpritesheets,
|
||||
session.status === 'ready',
|
||||
mouseX
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -153,6 +174,7 @@
|
||||
void timelineState.width;
|
||||
void timelineState.height;
|
||||
void canvas;
|
||||
void mouseX;
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
@@ -175,6 +197,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (zoomRegion && session.duration > 0) {
|
||||
const clipLen = zoomRegion.end - zoomRegion.start;
|
||||
const pad = Math.max(clipLen * 0.2, 2);
|
||||
const vs = Math.max(0, zoomRegion.start - pad);
|
||||
const ve = Math.min(session.duration, zoomRegion.end + pad);
|
||||
timelineState.visibleStart = vs;
|
||||
timelineState.visibleEnd = ve;
|
||||
timelineState.zoom = session.duration / (ve - vs);
|
||||
onZoomApplied?.();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
@@ -231,11 +266,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const x = getCanvasX(e);
|
||||
const time = computeClickTime(x, timelineState, session.duration);
|
||||
seekTo(time);
|
||||
contextMenu = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
function handleContextMenuAction(action: ContextMenuAction) {
|
||||
if (!contextMenu) return;
|
||||
switch (action.type) {
|
||||
case 'mark-in':
|
||||
markInPoint(contextMenu.time);
|
||||
break;
|
||||
case 'mark-out':
|
||||
markOutPoint(contextMenu.time);
|
||||
break;
|
||||
case 'go-to-clip-start': {
|
||||
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
|
||||
if (clip) seekTo(clip.startTime);
|
||||
break;
|
||||
}
|
||||
case 'go-to-clip-end': {
|
||||
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
|
||||
if (clip) seekTo(clip.endTime);
|
||||
break;
|
||||
}
|
||||
case 'delete-clip':
|
||||
if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId);
|
||||
break;
|
||||
case 'play-clip': {
|
||||
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
|
||||
if (clip) {
|
||||
seekTo(clip.startTime);
|
||||
const videoEl = document.querySelector('video');
|
||||
if (videoEl?.paused) videoEl.play();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = action;
|
||||
void _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
contextMenu = null;
|
||||
const x = getCanvasX(e);
|
||||
const y = getCanvasY(e);
|
||||
|
||||
const hit = hitTestClip(x, y, timelineState, clipStore.clips);
|
||||
const hit = hitTestClip(x, y, timelineState, clipStore.clips, computeLanes(timelineState.height));
|
||||
if (hit) {
|
||||
if (hit.edge === 'body') {
|
||||
selectClip(hit.clipId);
|
||||
@@ -283,8 +369,18 @@
|
||||
dragTarget = null;
|
||||
}
|
||||
|
||||
function handleMouseTrack(e: MouseEvent) {
|
||||
mouseX = getCanvasX(e);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
handleMouseUp();
|
||||
mouseX = null;
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
contextMenu = null;
|
||||
|
||||
if (e.deltaX !== 0) {
|
||||
const result = panBy(timelineState, e.deltaX, session.duration);
|
||||
@@ -375,14 +471,31 @@
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
onmousedown={handleMouseDown}
|
||||
onmousemove={handleMouseMove}
|
||||
onmousemove={(e) => {
|
||||
handleMouseMove(e);
|
||||
handleMouseTrack(e);
|
||||
}}
|
||||
onmouseup={handleMouseUp}
|
||||
onmouseleave={handleMouseUp}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onwheel={handleWheel}
|
||||
oncontextmenu={handleContextMenu}
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<TimelineContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
time={contextMenu.time}
|
||||
selectedClipId={clipStore.selectedClipId}
|
||||
onAction={handleContextMenuAction}
|
||||
onClose={() => {
|
||||
contextMenu = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<svelte:window
|
||||
onmousemove={handleMinimapMouseMove}
|
||||
onmouseup={handleMinimapMouseUp}
|
||||
|
||||
215
src/lib/components/TimelineContextMenu.svelte
Normal file
215
src/lib/components/TimelineContextMenu.svelte
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { formatTime } from '$lib/utils/time';
|
||||
|
||||
export type ContextMenuAction =
|
||||
| { type: 'mark-in' }
|
||||
| { type: 'mark-out' }
|
||||
| { type: 'go-to-clip-start' }
|
||||
| { type: 'go-to-clip-end' }
|
||||
| { type: 'play-clip' }
|
||||
| { type: 'delete-clip' };
|
||||
|
||||
let {
|
||||
x,
|
||||
y,
|
||||
time,
|
||||
selectedClipId,
|
||||
onAction,
|
||||
onClose,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
time: number;
|
||||
selectedClipId: string | null;
|
||||
onAction: (action: ContextMenuAction) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let menuEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function handleAction(action: ContextMenuAction) {
|
||||
onAction(action);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let adjustedX = $state(0);
|
||||
let adjustedY = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const posX = x;
|
||||
const posY = y;
|
||||
if (!menuEl) {
|
||||
adjustedX = posX;
|
||||
adjustedY = posY;
|
||||
return;
|
||||
}
|
||||
const rect = menuEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let newX = posX;
|
||||
let newY = posY;
|
||||
|
||||
if (posX + rect.width > vw) {
|
||||
newX = posX - rect.width;
|
||||
}
|
||||
if (posY + rect.height > vh) {
|
||||
newY = posY - rect.height;
|
||||
}
|
||||
|
||||
adjustedX = Math.max(0, newX);
|
||||
adjustedY = Math.max(0, newY);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onmousedown={handleClickOutside}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="context-menu"
|
||||
bind:this={menuEl}
|
||||
style="left: {adjustedX}px; top: {adjustedY}px;"
|
||||
role="menu"
|
||||
>
|
||||
<div class="menu-header">{formatTime(time)}</div>
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitem"
|
||||
onclick={() => handleAction({ type: 'mark-in' })}
|
||||
>
|
||||
<span class="label">Mark In</span>
|
||||
<span class="shortcut">I</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitem"
|
||||
onclick={() => handleAction({ type: 'mark-out' })}
|
||||
>
|
||||
<span class="label">Mark Out</span>
|
||||
<span class="shortcut">O</span>
|
||||
</button>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<button
|
||||
class="menu-item"
|
||||
class:disabled={!selectedClipId}
|
||||
role="menuitem"
|
||||
disabled={!selectedClipId}
|
||||
onclick={() => handleAction({ type: 'go-to-clip-start' })}
|
||||
>
|
||||
<span class="label">Go to Clip Start</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
class:disabled={!selectedClipId}
|
||||
role="menuitem"
|
||||
disabled={!selectedClipId}
|
||||
onclick={() => handleAction({ type: 'go-to-clip-end' })}
|
||||
>
|
||||
<span class="label">Go to Clip End</span>
|
||||
</button>
|
||||
|
||||
{#if selectedClipId}
|
||||
<div class="separator"></div>
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitem"
|
||||
onclick={() => handleAction({ type: 'play-clip' })}
|
||||
>
|
||||
<span class="label">Play Clip</span>
|
||||
<span class="shortcut">▶</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item danger"
|
||||
role="menuitem"
|
||||
onclick={() => handleAction({ type: 'delete-clip' })}
|
||||
>
|
||||
<span class="label">Delete Clip</span>
|
||||
<span class="shortcut">⌫</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 4px 12px 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu-item:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.menu-item:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menu-item.danger:hover {
|
||||
background: var(--danger);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.menu-item:hover:not(:disabled) .shortcut {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,17 @@
|
||||
getMuted,
|
||||
type TransportKeyAction,
|
||||
} from '$lib/transport/playback';
|
||||
import {
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Pause,
|
||||
Play,
|
||||
VolumeX,
|
||||
Volume1,
|
||||
Volume2,
|
||||
} from '@lucide/svelte';
|
||||
|
||||
let hasKeyframes = $derived(session.keyframePositions.length > 0);
|
||||
|
||||
@@ -50,15 +61,15 @@
|
||||
title="Previous keyframe (Shift+,)"
|
||||
type="button"
|
||||
>
|
||||
◄K
|
||||
<ChevronFirst size={16} />
|
||||
</button>
|
||||
<button onclick={() => seekBy(-5)} title="Seek back 5s (←)" type="button">-5s</button>
|
||||
<button onclick={() => seekBy(-1)} title="Seek back 1s (Shift+←)" type="button">-1s</button>
|
||||
<button onclick={() => stepFrame(-1)} title="Previous frame (,)" type="button">◄|</button>
|
||||
<button onclick={() => stepFrame(-1)} title="Previous frame (,)" type="button"><SkipBack size={16} /></button>
|
||||
<button onclick={togglePlayPause} title="Play/Pause (Space / K)" type="button">
|
||||
{session.isPlaying ? '❚❚' : '▶'}
|
||||
{#if session.isPlaying}<Pause size={16} />{:else}<Play size={16} />{/if}
|
||||
</button>
|
||||
<button onclick={() => stepFrame(1)} title="Next frame (.)" type="button">|►</button>
|
||||
<button onclick={() => stepFrame(1)} title="Next frame (.)" type="button"><SkipForward size={16} /></button>
|
||||
<button onclick={() => seekBy(1)} title="Seek forward 1s (Shift+→)" type="button">+1s</button>
|
||||
<button onclick={() => seekBy(5)} title="Seek forward 5s (→)" type="button">+5s</button>
|
||||
<button
|
||||
@@ -67,7 +78,7 @@
|
||||
title="Next keyframe (Shift+.)"
|
||||
type="button"
|
||||
>
|
||||
K►
|
||||
<ChevronLast size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -85,11 +96,11 @@
|
||||
type="button"
|
||||
>
|
||||
{#if isMuted || volume === 0}
|
||||
🔇
|
||||
<VolumeX size={14} />
|
||||
{:else if volume < 0.5}
|
||||
🔉
|
||||
<Volume1 size={14} />
|
||||
{:else}
|
||||
🔊
|
||||
<Volume2 size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { session, upgradePreview, dismissUpgradeToast } from '$lib/stores/videoSession.svelte';
|
||||
import { setVideoElement } from '$lib/transport/playback';
|
||||
import {
|
||||
setVideoElement,
|
||||
togglePlayPause as playbackTogglePlayPause,
|
||||
toggleFullscreen,
|
||||
} from '$lib/transport/playback';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { parseVtt, getActiveCues, getActiveWords, type VttCue } from '$lib/utils/vttParser';
|
||||
import { getMediaServerPort } from '$lib/bindings/video';
|
||||
@@ -14,14 +18,23 @@
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
let {
|
||||
captionsEnabled = $bindable(true),
|
||||
playerControlsVisible = false,
|
||||
}: {
|
||||
captionsEnabled?: boolean;
|
||||
playerControlsVisible?: boolean;
|
||||
} = $props();
|
||||
|
||||
let videoElement = $state<HTMLVideoElement | null>(null);
|
||||
let videoError = $state<string | null>(null);
|
||||
let videoSrc = $state<string | null>(null);
|
||||
let captionsEnabled = $state(true);
|
||||
let showCaptionSettings = $state(false);
|
||||
let parsedCues = $state<VttCue[]>([]);
|
||||
let mediaServerPort = $state<number | null>(null);
|
||||
|
||||
let isPlayerMode = $derived(preferences.appMode === 'player');
|
||||
|
||||
// Fetch media server port on mount
|
||||
$effect(() => {
|
||||
getMediaServerPort().then((port) => {
|
||||
@@ -108,6 +121,24 @@
|
||||
showCaptionSettings = !showCaptionSettings;
|
||||
}
|
||||
|
||||
let clickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleVideoClickWithDelay(_e: MouseEvent) {
|
||||
if (!isPlayerMode) return;
|
||||
// Distinguish single click from double click
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = null;
|
||||
// Double click
|
||||
toggleFullscreen();
|
||||
} else {
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null;
|
||||
playbackTogglePlayPause();
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
|
||||
let lastTimeUpdate = 0;
|
||||
const TIME_UPDATE_INTERVAL = 66;
|
||||
|
||||
@@ -135,6 +166,10 @@
|
||||
|
||||
function handleLoadedData() {
|
||||
videoError = null;
|
||||
// Restore playhead position (e.g. after layout toggle remounts the video)
|
||||
if (videoElement && session.currentTime > 0) {
|
||||
videoElement.currentTime = session.currentTime;
|
||||
}
|
||||
if (videoElement) {
|
||||
console.log('[VideoPlayer] loadeddata:', {
|
||||
src: videoElement.src,
|
||||
@@ -180,6 +215,10 @@
|
||||
export function getVideoElement(): HTMLVideoElement | null {
|
||||
return videoElement;
|
||||
}
|
||||
|
||||
export function getParsedCues(): VttCue[] {
|
||||
return parsedCues;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="video-player">
|
||||
@@ -187,18 +226,20 @@
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={videoSrc}
|
||||
crossorigin="anonymous"
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
onplay={handlePlay}
|
||||
onpause={handlePause}
|
||||
onerror={handleError}
|
||||
onloadeddata={handleLoadedData}
|
||||
onclick={handleVideoClickWithDelay}
|
||||
preload="metadata"
|
||||
playsinline
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{#if activeCues.length > 0}
|
||||
<div class="caption-overlay" style={captionPosition}>
|
||||
<div class="caption-overlay" class:lifted={isPlayerMode && playerControlsVisible} style={captionPosition}>
|
||||
{#each activeCues as cue}
|
||||
<span
|
||||
class="caption-text"
|
||||
@@ -215,7 +256,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if parsedCues.length > 0}
|
||||
{#if !isPlayerMode && parsedCues.length > 0}
|
||||
<div class="cc-controls">
|
||||
<button
|
||||
class="cc-toggle"
|
||||
@@ -230,7 +271,7 @@
|
||||
>⚙</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCaptionSettings}
|
||||
{#if !isPlayerMode && showCaptionSettings}
|
||||
<CaptionSettingsPanel onClose={() => (showCaptionSettings = false)} />
|
||||
{/if}
|
||||
{#if session.showUpgradeToast}
|
||||
@@ -291,6 +332,11 @@
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
padding: 0 10%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.caption-overlay.lifted {
|
||||
transform: translateY(-160px);
|
||||
}
|
||||
|
||||
.caption-text {
|
||||
|
||||
@@ -47,12 +47,18 @@ export interface Preferences {
|
||||
outputDirectory: string;
|
||||
cookieSource: CookieSource;
|
||||
captionSettings: CaptionSettings;
|
||||
clipListPosition: 'bottom' | 'left';
|
||||
clipListWidth: number;
|
||||
appMode: 'clipper' | 'player';
|
||||
}
|
||||
|
||||
const DEFAULT_PREFERENCES: Preferences = {
|
||||
outputDirectory: '~/',
|
||||
cookieSource: { type: 'browser', browser: 'firefox' },
|
||||
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
|
||||
clipListPosition: 'bottom',
|
||||
clipListWidth: 220,
|
||||
appMode: 'clipper',
|
||||
};
|
||||
|
||||
let store: Store | null = null;
|
||||
@@ -73,6 +79,12 @@ export async function loadPreferences(): Promise<void> {
|
||||
if (savedDir) preferences.outputDirectory = savedDir;
|
||||
if (savedCookie) preferences.cookieSource = savedCookie;
|
||||
if (savedCaptions) preferences.captionSettings = { ...DEFAULT_CAPTION_SETTINGS, ...savedCaptions };
|
||||
const savedClipListPosition = await store.get<'bottom' | 'left'>('clipListPosition');
|
||||
const savedClipListWidth = await store.get<number>('clipListWidth');
|
||||
if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition;
|
||||
if (savedClipListWidth != null) preferences.clipListWidth = savedClipListWidth;
|
||||
const savedAppMode = await store.get<'clipper' | 'player'>('appMode');
|
||||
if (savedAppMode) preferences.appMode = savedAppMode;
|
||||
})();
|
||||
await loadPromise;
|
||||
}
|
||||
@@ -85,6 +97,9 @@ export async function savePreferences(): Promise<void> {
|
||||
await store.set('outputDirectory', preferences.outputDirectory);
|
||||
await store.set('cookieSource', preferences.cookieSource);
|
||||
await store.set('captionSettings', preferences.captionSettings);
|
||||
await store.set('clipListPosition', preferences.clipListPosition);
|
||||
await store.set('clipListWidth', preferences.clipListWidth);
|
||||
await store.set('appMode', preferences.appMode);
|
||||
await store.save();
|
||||
}
|
||||
|
||||
@@ -102,3 +117,18 @@ export async function setCaptionSettings(settings: Partial<CaptionSettings>): Pr
|
||||
Object.assign(preferences.captionSettings, settings);
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
export async function setClipListPosition(position: 'bottom' | 'left'): Promise<void> {
|
||||
preferences.clipListPosition = position;
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
export async function setClipListWidth(width: number): Promise<void> {
|
||||
preferences.clipListWidth = width;
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
export async function setAppMode(mode: 'clipper' | 'player'): Promise<void> {
|
||||
preferences.appMode = mode;
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from '$lib/bindings/mediaAnalysis';
|
||||
import { clearThumbnailCache } from '$lib/timeline/thumbnailRenderer';
|
||||
import { clearAll as clearAllClips } from '$lib/stores/clips.svelte';
|
||||
import {
|
||||
loadCachedAnalysis,
|
||||
saveAnalysisToCache,
|
||||
getThumbnailCacheDir,
|
||||
clearVideoCache,
|
||||
} from '$lib/bindings/cache';
|
||||
|
||||
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
|
||||
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
|
||||
@@ -159,7 +165,7 @@ export function dismissUpgradeToast() {
|
||||
session.showUpgradeToast = false;
|
||||
}
|
||||
|
||||
async function loadCaptions(filePath: string) {
|
||||
async function loadCaptionsWithCache(filePath: string) {
|
||||
session.processingStep = 'captions';
|
||||
|
||||
// Priority 1: Check for embedded subtitle streams in the file
|
||||
@@ -187,10 +193,38 @@ async function loadCaptions(filePath: string) {
|
||||
async function triggerPostDownloadProcessing(filePath: string) {
|
||||
const duration = session.duration;
|
||||
|
||||
// Run processing SEQUENTIALLY to avoid overloading the CPU with
|
||||
// concurrent ffmpeg subprocesses. Each one decodes the full file.
|
||||
// Order: waveform first (most useful for editing), then keyframes, then thumbnails.
|
||||
// Check cache first
|
||||
try {
|
||||
const cached = await loadCachedAnalysis(session.url);
|
||||
if (cached) {
|
||||
console.log('[triggerPostDownloadProcessing] cache hit for', session.url);
|
||||
if (cached.waveformTiers) {
|
||||
session.waveformTiers = cached.waveformTiers;
|
||||
}
|
||||
if (cached.keyframePositions.length > 0) {
|
||||
session.keyframePositions = cached.keyframePositions;
|
||||
}
|
||||
if (cached.thumbnailSpritesheets.length > 0) {
|
||||
session.thumbnailSpritesheets = cached.thumbnailSpritesheets;
|
||||
}
|
||||
if (cached.captionFilePath) {
|
||||
session.captionFilePath = cached.captionFilePath;
|
||||
} else {
|
||||
// Captions weren't cached — re-extract
|
||||
await loadCaptionsWithCache(filePath);
|
||||
}
|
||||
session.processingStep = 'done';
|
||||
if (session.exportStatus === 'complete' && session.exportFilePath
|
||||
&& session.activeVideoPath !== session.exportFilePath) {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Cache load failed, proceeding with extraction:', err);
|
||||
}
|
||||
|
||||
// Cache miss — run extraction sequentially
|
||||
session.processingStep = 'waveform';
|
||||
session.processingProgress = 0;
|
||||
try {
|
||||
@@ -214,19 +248,36 @@ async function triggerPostDownloadProcessing(filePath: string) {
|
||||
session.processingStep = 'thumbnails';
|
||||
session.processingProgress = 0;
|
||||
try {
|
||||
const sheets = await extractThumbnails(filePath, duration);
|
||||
const thumbDir = await getThumbnailCacheDir(session.url);
|
||||
const sheets = await extractThumbnails(filePath, duration, thumbDir);
|
||||
session.thumbnailSpritesheets = sheets;
|
||||
} catch (err) {
|
||||
console.error('Thumbnail extraction failed:', err);
|
||||
}
|
||||
|
||||
// Captions can run after analysis since they use yt-dlp, not ffmpeg
|
||||
await loadCaptions(filePath);
|
||||
// Captions — use cache dir for output
|
||||
await loadCaptionsWithCache(filePath);
|
||||
|
||||
session.processingStep = 'done';
|
||||
|
||||
// If export download already completed while we were processing, show upgrade toast
|
||||
if (session.exportStatus === 'complete' && session.exportFilePath) {
|
||||
// Save to cache
|
||||
try {
|
||||
await saveAnalysisToCache(
|
||||
session.url,
|
||||
session.title,
|
||||
session.duration,
|
||||
session.fps,
|
||||
session.waveformTiers,
|
||||
session.keyframePositions,
|
||||
session.thumbnailSpritesheets,
|
||||
session.captionFilePath
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to save analysis to cache:', err);
|
||||
}
|
||||
|
||||
if (session.exportStatus === 'complete' && session.exportFilePath
|
||||
&& session.activeVideoPath !== session.exportFilePath) {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
}
|
||||
@@ -262,6 +313,23 @@ export async function beginDownload() {
|
||||
|
||||
session.processingStep = 'downloading';
|
||||
|
||||
// --- Check export (HQ) cache first ---
|
||||
const exportCached = await checkCachedDownload(session.title, 'export').catch(() => null);
|
||||
if (exportCached) {
|
||||
// HQ file already on disk — use it directly, skip preview entirely
|
||||
session.exportFilePath = exportCached;
|
||||
session.exportProgress = 1.0;
|
||||
session.exportStatus = 'complete';
|
||||
session.previewFilePath = exportCached;
|
||||
session.previewProgress = 1.0;
|
||||
session.previewStatus = 'complete';
|
||||
session.activeVideoPath = exportCached;
|
||||
// Prefer preview file for analysis (much faster ffmpeg operations)
|
||||
const previewForProcessing = await checkCachedDownload(session.title, 'preview').catch(() => null);
|
||||
triggerPostDownloadProcessing(previewForProcessing || exportCached);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Preview download (low-res, fast) ---
|
||||
const previewCached = await checkCachedDownload(session.title, 'preview').catch(() => null);
|
||||
if (previewCached) {
|
||||
@@ -305,45 +373,66 @@ export async function beginDownload() {
|
||||
}
|
||||
|
||||
// --- Export download (best quality, background) ---
|
||||
const exportCached = await checkCachedDownload(session.title, 'export').catch(() => null);
|
||||
if (exportCached) {
|
||||
session.exportFilePath = exportCached;
|
||||
session.exportProgress = 1.0;
|
||||
session.exportStatus = 'complete';
|
||||
if ((session.processingStep as ProcessingStep) === 'done') {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
} else {
|
||||
session.exportStatus = 'downloading';
|
||||
session.exportProgress = 0;
|
||||
session.exportFilePath = null;
|
||||
// Only start if not already cached (we handled cached case above with early return)
|
||||
session.exportStatus = 'downloading';
|
||||
session.exportProgress = 0;
|
||||
session.exportFilePath = null;
|
||||
|
||||
// Fire-and-forget — runs in background
|
||||
runDownload(
|
||||
session.url,
|
||||
EXPORT_FORMAT,
|
||||
'export',
|
||||
(pct) => { session.exportProgress = pct; },
|
||||
(path) => { session.exportFilePath = path; },
|
||||
(success, path) => {
|
||||
if (success) {
|
||||
session.exportProgress = 1.0;
|
||||
session.exportFilePath = path;
|
||||
session.exportStatus = 'complete';
|
||||
if ((session.processingStep as ProcessingStep) === 'done') {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
} else {
|
||||
session.exportStatus = 'failed';
|
||||
runDownload(
|
||||
session.url,
|
||||
EXPORT_FORMAT,
|
||||
'export',
|
||||
(pct) => { session.exportProgress = pct; },
|
||||
(path) => { session.exportFilePath = path; },
|
||||
(success, path) => {
|
||||
if (success) {
|
||||
session.exportProgress = 1.0;
|
||||
session.exportFilePath = path;
|
||||
session.exportStatus = 'complete';
|
||||
if ((session.processingStep as ProcessingStep) === 'done') {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
},
|
||||
(message) => {
|
||||
} else {
|
||||
session.exportStatus = 'failed';
|
||||
console.error('Export download error:', message);
|
||||
}
|
||||
).catch((e) => {
|
||||
},
|
||||
(message) => {
|
||||
session.exportStatus = 'failed';
|
||||
console.error('Export download failed:', e);
|
||||
});
|
||||
}
|
||||
console.error('Export download error:', message);
|
||||
}
|
||||
).catch((e) => {
|
||||
session.exportStatus = 'failed';
|
||||
console.error('Export download failed:', e);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear cache for the current video and re-extract all analysis data. */
|
||||
export async function reprocessCurrentVideo() {
|
||||
if (!session.url || !session.activeVideoPath) return;
|
||||
|
||||
// Always use the low-res preview file for processing (never the HQ export)
|
||||
let processingPath: string | null = session.previewFilePath;
|
||||
if (!processingPath || processingPath === session.exportFilePath) {
|
||||
// Try to find a cached preview file on disk
|
||||
processingPath = await checkCachedDownload(session.title, 'preview').catch(() => null);
|
||||
}
|
||||
// Last resort: fall back to whatever is active (should rarely happen)
|
||||
const filePath = processingPath || session.activeVideoPath;
|
||||
|
||||
// Clear cache
|
||||
try {
|
||||
await clearVideoCache(session.url);
|
||||
} catch (err) {
|
||||
console.error('Failed to clear video cache:', err);
|
||||
}
|
||||
|
||||
// Reset analysis state
|
||||
clearThumbnailCache();
|
||||
session.keyframePositions = [];
|
||||
session.waveformTiers = null;
|
||||
session.thumbnailSpritesheets = [];
|
||||
session.captionFilePath = null;
|
||||
|
||||
// Re-run processing (will miss cache and extract fresh)
|
||||
await triggerPostDownloadProcessing(filePath);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Clip } from '$lib/stores/clips.svelte';
|
||||
import { timeToX, type TimelineState } from './renderer';
|
||||
import { timeToX, type TimelineState, type LaneLayout } from './renderer';
|
||||
|
||||
const CLIP_LANE_Y = 90;
|
||||
const CLIP_LANE_HEIGHT = 30;
|
||||
const HANDLE_WIDTH = 6;
|
||||
|
||||
export function drawClips(
|
||||
@@ -10,24 +8,28 @@ export function drawClips(
|
||||
state: TimelineState,
|
||||
clips: Clip[],
|
||||
selectedId: string | null,
|
||||
pendingInPoint: number | null
|
||||
pendingInPoint: number | null,
|
||||
lanes: LaneLayout
|
||||
): void {
|
||||
const clipY = lanes.clipY;
|
||||
const clipH = lanes.clipHeight;
|
||||
|
||||
for (const clip of clips) {
|
||||
const x1 = timeToX(clip.startTime, state);
|
||||
const x2 = timeToX(clip.endTime, state);
|
||||
const isSelected = clip.id === selectedId;
|
||||
|
||||
ctx.fillStyle = clip.color + '66';
|
||||
ctx.fillRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x1, clipY, x2 - x1, clipH);
|
||||
|
||||
ctx.strokeStyle = isSelected ? '#ffffff' : clip.color;
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
ctx.strokeRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
|
||||
ctx.strokeRect(x1, clipY, x2 - x1, clipH);
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.fillStyle = isSelected ? '#ffffff' : clip.color;
|
||||
ctx.fillRect(x1 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x2 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x1 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
|
||||
ctx.fillRect(x2 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
|
||||
|
||||
const labelWidth = x2 - x1;
|
||||
if (labelWidth > 40) {
|
||||
@@ -37,7 +39,7 @@ export function drawClips(
|
||||
ctx.fillText(
|
||||
clip.label,
|
||||
(x1 + x2) / 2,
|
||||
CLIP_LANE_Y + CLIP_LANE_HEIGHT / 2 + 4,
|
||||
clipY + clipH / 2 + 4,
|
||||
labelWidth - 10
|
||||
);
|
||||
}
|
||||
@@ -49,8 +51,8 @@ export function drawClips(
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, CLIP_LANE_Y);
|
||||
ctx.lineTo(x, CLIP_LANE_Y + CLIP_LANE_HEIGHT);
|
||||
ctx.moveTo(x, clipY);
|
||||
ctx.lineTo(x, clipY + clipH);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.lineWidth = 1;
|
||||
@@ -66,9 +68,10 @@ export function hitTestClip(
|
||||
x: number,
|
||||
y: number,
|
||||
state: TimelineState,
|
||||
clips: Clip[]
|
||||
clips: Clip[],
|
||||
lanes: LaneLayout
|
||||
): ClipHitResult | null {
|
||||
if (y < CLIP_LANE_Y || y > CLIP_LANE_Y + CLIP_LANE_HEIGHT) {
|
||||
if (y < lanes.clipY || y > lanes.clipY + lanes.clipHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,40 @@ export function xToTime(x: number, state: TimelineState): number {
|
||||
return state.visibleStart + (x / state.width) * range;
|
||||
}
|
||||
|
||||
const THUMB_LANE_HEIGHT = 50;
|
||||
const WAVEFORM_LANE_HEIGHT = 40;
|
||||
const CONTROLS_LANE_HEIGHT = 30;
|
||||
const TICK_HEIGHT = 10;
|
||||
const BASE_FONT_SIZE = 12;
|
||||
const MAGNIFY_BONUS = 10;
|
||||
const MAGNIFY_RADIUS = 100;
|
||||
|
||||
const CONTROLS_HEIGHT = 22;
|
||||
const CLIP_HEIGHT = 20;
|
||||
|
||||
export interface LaneLayout {
|
||||
thumbY: number;
|
||||
thumbHeight: number;
|
||||
waveformY: number;
|
||||
waveformHeight: number;
|
||||
clipY: number;
|
||||
clipHeight: number;
|
||||
controlsY: number;
|
||||
controlsHeight: number;
|
||||
}
|
||||
|
||||
export function computeLanes(totalHeight: number): LaneLayout {
|
||||
const available = Math.max(0, totalHeight - CONTROLS_HEIGHT - CLIP_HEIGHT);
|
||||
const thumbHeight = Math.max(20, Math.round(available * 0.55));
|
||||
const waveformHeight = Math.max(15, available - thumbHeight);
|
||||
return {
|
||||
thumbY: 0,
|
||||
thumbHeight,
|
||||
waveformY: thumbHeight,
|
||||
waveformHeight,
|
||||
clipY: thumbHeight + waveformHeight,
|
||||
clipHeight: CLIP_HEIGHT,
|
||||
controlsY: totalHeight - CONTROLS_HEIGHT,
|
||||
controlsHeight: CONTROLS_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
export function drawTimeline(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
@@ -37,7 +67,9 @@ export function drawTimeline(
|
||||
selectedClipId: string | null = null,
|
||||
pendingInPoint: number | null = null,
|
||||
waveform: WaveformData = { tiers: null },
|
||||
thumbnailSpritesheets: ThumbnailSpritesheet[] = []
|
||||
thumbnailSpritesheets: ThumbnailSpritesheet[] = [],
|
||||
hasVideo: boolean = false,
|
||||
mouseX: number | null = null
|
||||
): void {
|
||||
const { width, height } = state;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
@@ -45,21 +77,23 @@ export function drawTimeline(
|
||||
ctx.fillStyle = '#181825';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
drawTimeTicks(ctx, state, duration);
|
||||
const lanes = computeLanes(height);
|
||||
|
||||
drawTimeTicks(ctx, state, duration, mouseX, lanes);
|
||||
|
||||
if (thumbnailSpritesheets.length > 0) {
|
||||
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, 0, THUMB_LANE_HEIGHT);
|
||||
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, lanes.thumbY, lanes.thumbHeight);
|
||||
} else {
|
||||
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
|
||||
drawPlaceholderLane(ctx, state, lanes.thumbY, lanes.thumbHeight, 'Thumbnails', hasVideo);
|
||||
}
|
||||
|
||||
if (waveform.tiers) {
|
||||
drawWaveform(ctx, state, waveform, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
|
||||
drawWaveform(ctx, state, waveform, duration, lanes.waveformY, lanes.waveformHeight);
|
||||
} else {
|
||||
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
|
||||
drawPlaceholderLane(ctx, state, lanes.waveformY, lanes.waveformHeight, 'Waveform', hasVideo);
|
||||
}
|
||||
|
||||
drawClips(ctx, state, clips, selectedClipId, pendingInPoint);
|
||||
drawClips(ctx, state, clips, selectedClipId, pendingInPoint, lanes);
|
||||
|
||||
drawPlayhead(ctx, state, currentTime);
|
||||
}
|
||||
@@ -67,27 +101,66 @@ export function drawTimeline(
|
||||
function drawTimeTicks(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
duration: number
|
||||
duration: number,
|
||||
mouseX: number | null,
|
||||
lanes: LaneLayout
|
||||
): void {
|
||||
const range = state.visibleEnd - state.visibleStart;
|
||||
const tickInterval = getTickInterval(range, state.width);
|
||||
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
|
||||
|
||||
ctx.fillStyle = '#6c7086';
|
||||
ctx.font = '10px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const baselineY = lanes.controlsY + TICK_HEIGHT + 10;
|
||||
|
||||
// Draw minor ticks first (behind major ticks)
|
||||
const minorInterval = tickInterval / 4;
|
||||
const minorTickHeight = TICK_HEIGHT * 0.5;
|
||||
for (let t = startTick - tickInterval; t <= state.visibleEnd + minorInterval; t += minorInterval) {
|
||||
if (t < 0 || t > duration) continue;
|
||||
if (Math.abs(t % tickInterval) < 0.001 || Math.abs(t % tickInterval - tickInterval) < 0.001) continue;
|
||||
const x = timeToX(t, state);
|
||||
ctx.strokeStyle = '#35354a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, lanes.controlsY);
|
||||
ctx.lineTo(x, lanes.controlsY + minorTickHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw major ticks with labels
|
||||
for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
|
||||
if (t < 0 || t > duration) continue;
|
||||
const x = timeToX(t, state);
|
||||
|
||||
ctx.strokeStyle = '#45475a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT);
|
||||
ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT);
|
||||
ctx.moveTo(x, lanes.controlsY);
|
||||
ctx.lineTo(x, lanes.controlsY + TICK_HEIGHT);
|
||||
ctx.stroke();
|
||||
|
||||
let fontSize = BASE_FONT_SIZE;
|
||||
let scale = 0;
|
||||
if (mouseX !== null) {
|
||||
const distance = Math.abs(x - mouseX);
|
||||
if (distance < MAGNIFY_RADIUS) {
|
||||
scale = (1 + Math.cos((Math.PI * distance) / MAGNIFY_RADIUS)) / 2;
|
||||
fontSize = BASE_FONT_SIZE + MAGNIFY_BONUS * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolate color from muted (#6c7086) to bright (#cdd6f4)
|
||||
if (scale > 0) {
|
||||
const r = Math.round(108 + (205 - 108) * scale);
|
||||
const g = Math.round(112 + (214 - 112) * scale);
|
||||
const b = Math.round(134 + (244 - 134) * scale);
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
} else {
|
||||
ctx.fillStyle = '#6c7086';
|
||||
}
|
||||
|
||||
ctx.font = `${Math.round(fontSize)}px -apple-system, sans-serif`;
|
||||
|
||||
const label = formatTickLabel(t);
|
||||
ctx.fillText(label, x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 12);
|
||||
ctx.fillText(label, x, baselineY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,16 +187,19 @@ function drawPlaceholderLane(
|
||||
state: TimelineState,
|
||||
y: number,
|
||||
height: number,
|
||||
label: string
|
||||
label: string,
|
||||
showLabel: boolean
|
||||
): void {
|
||||
ctx.fillStyle = '#2a2a3e';
|
||||
ctx.fillRect(0, y, state.width, height);
|
||||
ctx.strokeStyle = '#353550';
|
||||
ctx.strokeRect(0, y, state.width, height);
|
||||
ctx.fillStyle = '#6c7086';
|
||||
ctx.font = '11px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
|
||||
if (showLabel) {
|
||||
ctx.fillStyle = '#6c7086';
|
||||
ctx.font = '11px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawPlayhead(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
let _videoEl: HTMLVideoElement | null = null;
|
||||
|
||||
@@ -84,11 +85,12 @@ export function resetShuttleRate() {
|
||||
}
|
||||
}
|
||||
|
||||
export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
|
||||
export function adjustShuttle(dir: 1 | -1): number {
|
||||
const videoEl = getVideo();
|
||||
if (!videoEl) return shuttleRate;
|
||||
if (!videoEl) return 1;
|
||||
|
||||
const nextRate = Math.max(0.25, Math.min(4, shuttleRate + dir * 0.5));
|
||||
const current = videoEl.playbackRate;
|
||||
const nextRate = Math.max(0.25, Math.min(4, current + dir * 0.25));
|
||||
videoEl.playbackRate = nextRate;
|
||||
if (videoEl.paused) {
|
||||
void videoEl.play();
|
||||
@@ -96,6 +98,54 @@ export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
|
||||
return nextRate;
|
||||
}
|
||||
|
||||
export function togglePiP(): void {
|
||||
if (document.pictureInPictureElement) {
|
||||
document.exitPictureInPicture().catch((e) => {
|
||||
console.error('Exit PiP failed:', e);
|
||||
});
|
||||
} else {
|
||||
const videoEl = getVideo();
|
||||
if (videoEl && document.pictureInPictureEnabled) {
|
||||
videoEl.requestPictureInPicture().catch((e) => {
|
||||
console.error('PiP request failed:', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isPiPActive(): boolean {
|
||||
return !!document.pictureInPictureElement;
|
||||
}
|
||||
|
||||
export async function toggleFullscreen(): Promise<void> {
|
||||
try {
|
||||
const win = getCurrentWindow();
|
||||
const isFs = await win.isFullscreen();
|
||||
await win.setFullscreen(!isFs);
|
||||
} catch (e) {
|
||||
console.error('Fullscreen toggle failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isFullscreenActive(): Promise<boolean> {
|
||||
try {
|
||||
return await getCurrentWindow().isFullscreen();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setPlaybackRate(rate: number): void {
|
||||
const videoEl = getVideo();
|
||||
if (videoEl) {
|
||||
videoEl.playbackRate = rate;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlaybackRate(): number {
|
||||
return getVideo()?.playbackRate ?? 1;
|
||||
}
|
||||
|
||||
export type TransportKeyAction =
|
||||
| 'play-pause'
|
||||
| 'frame-back'
|
||||
|
||||
32
src/lib/utils/clipRange.ts
Normal file
32
src/lib/utils/clipRange.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type QuickClipPosition = 'at' | 'before';
|
||||
|
||||
export function computeQuickClipRange(
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
position: QuickClipPosition,
|
||||
clipDuration = 10,
|
||||
): { start: number; end: number } | null {
|
||||
if (duration === 0) return null;
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
|
||||
switch (position) {
|
||||
case 'at':
|
||||
start = currentTime;
|
||||
end = Math.min(currentTime + clipDuration, duration);
|
||||
break;
|
||||
case 'before':
|
||||
start = Math.max(currentTime - clipDuration, 0);
|
||||
end = currentTime;
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = position;
|
||||
void _exhaustive;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (start >= end) return null;
|
||||
return { start, end };
|
||||
}
|
||||
212
src/lib/utils/screenshot.ts
Normal file
212
src/lib/utils/screenshot.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { CaptionSettings } from '$lib/stores/preferences.svelte';
|
||||
import type { VttCue } from '$lib/utils/vttParser';
|
||||
import { getActiveCues, getActiveWords } from '$lib/utils/vttParser';
|
||||
import { saveScreenshot } from '$lib/bindings/export';
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function renderCaptions(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cues: VttCue[],
|
||||
currentTime: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
settings: CaptionSettings,
|
||||
scale: number,
|
||||
): void {
|
||||
const activeCues = getActiveCues(cues, currentTime);
|
||||
if (activeCues.length === 0) return;
|
||||
|
||||
const fontSize = Math.round(settings.fontSize * scale);
|
||||
const fontWeight = settings.bold ? 'bold' : 'normal';
|
||||
const font = `${fontWeight} ${fontSize}px ${settings.fontFamily}`;
|
||||
ctx.font = font;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const lineHeight = fontSize * 1.4;
|
||||
const padding = Math.round(4 * scale);
|
||||
const hPadding = Math.round(10 * scale);
|
||||
const maxWidth = canvasWidth * 0.8;
|
||||
|
||||
const positionY =
|
||||
settings.position === 'top'
|
||||
? canvasHeight * 0.08
|
||||
: canvasHeight * 0.92 - activeCues.length * lineHeight;
|
||||
|
||||
const centerX = canvasWidth / 2;
|
||||
|
||||
for (let i = 0; i < activeCues.length; i++) {
|
||||
const cue = activeCues[i];
|
||||
const y = positionY + i * lineHeight;
|
||||
|
||||
if (settings.wordHighlight && cue.words) {
|
||||
const words = getActiveWords(cue, currentTime);
|
||||
const fullText = words.map((w) => w.text).join(' ');
|
||||
const textMetrics = ctx.measureText(fullText);
|
||||
const textWidth = Math.min(textMetrics.width, maxWidth);
|
||||
|
||||
if (settings.backgroundEnabled) {
|
||||
const { r, g, b } = hexToRgb(settings.backgroundColor);
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${settings.backgroundOpacity})`;
|
||||
ctx.fillRect(
|
||||
centerX - textWidth / 2 - hPadding,
|
||||
y - padding,
|
||||
textWidth + hPadding * 2,
|
||||
lineHeight + padding * 2,
|
||||
);
|
||||
}
|
||||
|
||||
let xOffset = centerX - textWidth / 2;
|
||||
for (const word of words) {
|
||||
const wordWithSpace = word.text + ' ';
|
||||
if (word.spoken) {
|
||||
ctx.fillStyle = settings.textColor;
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
if (settings.dimmedColorMode === 'custom') {
|
||||
ctx.fillStyle = settings.dimmedColor;
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
ctx.fillStyle = settings.textColor;
|
||||
ctx.globalAlpha = settings.dimmedOpacity;
|
||||
}
|
||||
}
|
||||
|
||||
applyTextEffects(ctx, wordWithSpace, xOffset, y, settings, scale);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(wordWithSpace, xOffset, y);
|
||||
xOffset += ctx.measureText(wordWithSpace).width;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
} else {
|
||||
const textMetrics = ctx.measureText(cue.text);
|
||||
const textWidth = Math.min(textMetrics.width, maxWidth);
|
||||
|
||||
if (settings.backgroundEnabled) {
|
||||
const { r, g, b } = hexToRgb(settings.backgroundColor);
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${settings.backgroundOpacity})`;
|
||||
ctx.fillRect(
|
||||
centerX - textWidth / 2 - hPadding,
|
||||
y - padding,
|
||||
textWidth + hPadding * 2,
|
||||
lineHeight + padding * 2,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.fillStyle = settings.textColor;
|
||||
applyTextEffects(ctx, cue.text, centerX, y, settings, scale);
|
||||
ctx.fillText(cue.text, centerX, y, maxWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyTextEffects(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_text: string,
|
||||
_x: number,
|
||||
_y: number,
|
||||
settings: CaptionSettings,
|
||||
scale: number,
|
||||
): void {
|
||||
if (settings.shadowEnabled) {
|
||||
const depth = settings.shadowDepth * scale;
|
||||
ctx.shadowColor = settings.shadowColor;
|
||||
ctx.shadowOffsetX = depth;
|
||||
ctx.shadowOffsetY = depth;
|
||||
ctx.shadowBlur = depth * 2;
|
||||
} else if (settings.textOutline) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.9)';
|
||||
ctx.shadowOffsetX = 1 * scale;
|
||||
ctx.shadowOffsetY = 1 * scale;
|
||||
ctx.shadowBlur = 2 * scale;
|
||||
} else {
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error('Canvas toBlob returned null'));
|
||||
},
|
||||
'image/png',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
currentTime: number;
|
||||
videoTitle: string;
|
||||
outputDirectory: string;
|
||||
withCaptions: boolean;
|
||||
captionsEnabled: boolean;
|
||||
captionSettings: CaptionSettings;
|
||||
parsedCues: VttCue[];
|
||||
}
|
||||
|
||||
export async function captureFrame(options: ScreenshotOptions): Promise<string> {
|
||||
const videoEl = document.querySelector('video');
|
||||
if (!videoEl) throw new Error('No video element found');
|
||||
if (videoEl.readyState < 2) throw new Error('Video not ready for capture');
|
||||
|
||||
const nativeWidth = videoEl.videoWidth;
|
||||
const nativeHeight = videoEl.videoHeight;
|
||||
if (nativeWidth === 0 || nativeHeight === 0) {
|
||||
throw new Error('Video has zero dimensions');
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = nativeWidth;
|
||||
canvas.height = nativeHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Failed to get canvas 2D context');
|
||||
|
||||
ctx.drawImage(videoEl, 0, 0, nativeWidth, nativeHeight);
|
||||
|
||||
if (
|
||||
options.withCaptions &&
|
||||
options.captionsEnabled &&
|
||||
options.parsedCues.length > 0
|
||||
) {
|
||||
const renderedHeight = videoEl.clientHeight || 600;
|
||||
const scale = nativeHeight / renderedHeight;
|
||||
renderCaptions(
|
||||
ctx,
|
||||
options.parsedCues,
|
||||
options.currentTime,
|
||||
nativeWidth,
|
||||
nativeHeight,
|
||||
options.captionSettings,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await canvasToBlob(canvas);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const pngData = Array.from(new Uint8Array(buffer));
|
||||
|
||||
const totalMs = Math.round(options.currentTime * 1000);
|
||||
const timestampLabel = `${totalMs}ms`;
|
||||
|
||||
const savedPath = await saveScreenshot(
|
||||
options.outputDirectory,
|
||||
options.videoTitle,
|
||||
timestampLabel,
|
||||
pngData,
|
||||
);
|
||||
|
||||
return savedPath;
|
||||
}
|
||||
@@ -14,6 +14,20 @@ export function formatTime(totalSeconds: number): string {
|
||||
return `${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
export function formatTimeParts(totalSeconds: number): { main: string; ms: string } {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
const millis = Math.round((totalSeconds % 1) * 1000);
|
||||
|
||||
const mm = String(minutes).padStart(2, '0');
|
||||
const ss = String(seconds).padStart(2, '0');
|
||||
const ms = String(millis).padStart(3, '0');
|
||||
|
||||
const main = hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
return { main, ms: `.${ms}` };
|
||||
}
|
||||
|
||||
export function parseTime(str: string): number | null {
|
||||
const hmsMatch = str.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
||||
if (hmsMatch) {
|
||||
|
||||
59
tests/lib/components/AboutDialog.test.ts
Normal file
59
tests/lib/components/AboutDialog.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/svelte';
|
||||
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||
|
||||
vi.mock('@tauri-apps/api/app', () => ({
|
||||
getVersion: vi.fn().mockResolvedValue('0.2.1'),
|
||||
}));
|
||||
|
||||
describe('AboutDialog', () => {
|
||||
it('renders the About tab by default', () => {
|
||||
const { getByText } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||
expect(getByText('by cottongin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows two tabs: About and Shortcuts', () => {
|
||||
const { getByRole } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
const tablist = getByRole('tablist');
|
||||
expect(tablist).toBeTruthy();
|
||||
expect(tablist.querySelectorAll('[role="tab"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('switches to Shortcuts tab on click', async () => {
|
||||
const { getByText, getAllByText } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
const shortcutsTab = getByText('Shortcuts');
|
||||
await fireEvent.click(shortcutsTab);
|
||||
|
||||
expect(getAllByText('Play / Pause').length).toBeGreaterThan(0);
|
||||
expect(getByText('Toggle mode')).toBeTruthy();
|
||||
expect(getByText('Player Mode')).toBeTruthy();
|
||||
expect(getByText('Clipper Mode')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switches back to About tab', async () => {
|
||||
const { getByText, getByRole } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
await fireEvent.click(getByRole('tab', { name: 'Shortcuts' }));
|
||||
await fireEvent.click(getByRole('tab', { name: 'About' }));
|
||||
|
||||
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||
expect(getByText('by cottongin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onClose when Close button is clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { getByText } = render(AboutDialog, {
|
||||
props: { onClose },
|
||||
});
|
||||
await fireEvent.click(getByText('Close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
35
tests/lib/components/SpeedSelector.test.ts
Normal file
35
tests/lib/components/SpeedSelector.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/svelte';
|
||||
import SpeedSelector from '$lib/components/SpeedSelector.svelte';
|
||||
|
||||
describe('SpeedSelector', () => {
|
||||
it('renders all speed options', () => {
|
||||
const { getByText } = render(SpeedSelector, {
|
||||
props: { currentRate: 1, onSelect: vi.fn(), onClose: vi.fn() },
|
||||
});
|
||||
expect(getByText('0.5×')).toBeTruthy();
|
||||
expect(getByText('0.75×')).toBeTruthy();
|
||||
expect(getByText('1×')).toBeTruthy();
|
||||
expect(getByText('1.25×')).toBeTruthy();
|
||||
expect(getByText('1.5×')).toBeTruthy();
|
||||
expect(getByText('2×')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('highlights the current rate', () => {
|
||||
const { container } = render(SpeedSelector, {
|
||||
props: { currentRate: 1.5, onSelect: vi.fn(), onClose: vi.fn() },
|
||||
});
|
||||
const activeBtn = container.querySelector('.speed-option.active');
|
||||
expect(activeBtn).toBeTruthy();
|
||||
expect(activeBtn!.textContent).toContain('1.5×');
|
||||
});
|
||||
|
||||
it('calls onSelect when an option is clicked', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { getByText } = render(SpeedSelector, {
|
||||
props: { currentRate: 1, onSelect, onClose: vi.fn() },
|
||||
});
|
||||
await fireEvent.click(getByText('2×'));
|
||||
expect(onSelect).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
38
tests/lib/stores/preferences.test.ts
Normal file
38
tests/lib/stores/preferences.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the Tauri store before importing the module under test
|
||||
vi.mock('@tauri-apps/plugin-store', () => {
|
||||
const mockStore = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return {
|
||||
load: vi.fn().mockResolvedValue(mockStore),
|
||||
Store: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('preferences store — appMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('defaults appMode to clipper', async () => {
|
||||
const { preferences } = await import('$lib/stores/preferences.svelte');
|
||||
expect(preferences.appMode).toBe('clipper');
|
||||
});
|
||||
|
||||
it('setAppMode updates appMode to player', async () => {
|
||||
const { preferences, setAppMode } = await import('$lib/stores/preferences.svelte');
|
||||
await setAppMode('player');
|
||||
expect(preferences.appMode).toBe('player');
|
||||
});
|
||||
|
||||
it('setAppMode updates appMode back to clipper', async () => {
|
||||
const { preferences, setAppMode } = await import('$lib/stores/preferences.svelte');
|
||||
await setAppMode('player');
|
||||
await setAppMode('clipper');
|
||||
expect(preferences.appMode).toBe('clipper');
|
||||
});
|
||||
});
|
||||
191
tests/lib/transport/playback.test.ts
Normal file
191
tests/lib/transport/playback.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the videoSession store (playback.ts imports it)
|
||||
vi.mock('$lib/stores/videoSession.svelte', () => ({
|
||||
session: {
|
||||
duration: 120,
|
||||
currentTime: 0,
|
||||
fps: 30,
|
||||
isPlaying: false,
|
||||
keyframePositions: [],
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Tauri window API for fullscreen
|
||||
const mockTauriWindow = {
|
||||
isFullscreen: vi.fn().mockResolvedValue(false),
|
||||
setFullscreen: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => mockTauriWindow,
|
||||
}));
|
||||
|
||||
import {
|
||||
setVideoElement,
|
||||
togglePiP,
|
||||
isPiPActive,
|
||||
toggleFullscreen,
|
||||
isFullscreenActive,
|
||||
setPlaybackRate,
|
||||
getPlaybackRate,
|
||||
adjustShuttle,
|
||||
} from '$lib/transport/playback';
|
||||
|
||||
function createMockVideoElement(): HTMLVideoElement {
|
||||
const el = document.createElement('video');
|
||||
// PiP
|
||||
(el as any).requestPictureInPicture = vi.fn().mockResolvedValue({});
|
||||
// Fullscreen
|
||||
el.requestFullscreen = vi.fn().mockResolvedValue(undefined);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('playback — PiP helpers', () => {
|
||||
let mockVideo: HTMLVideoElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVideo = createMockVideoElement();
|
||||
setVideoElement(mockVideo);
|
||||
// Reset PiP state
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('isPiPActive returns false when no PiP element', () => {
|
||||
expect(isPiPActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('togglePiP calls requestPictureInPicture', () => {
|
||||
togglePiP();
|
||||
expect((mockVideo as any).requestPictureInPicture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isPiPActive returns true when PiP element exists', () => {
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: mockVideo,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(isPiPActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback — fullscreen helpers', () => {
|
||||
beforeEach(() => {
|
||||
mockTauriWindow.isFullscreen.mockResolvedValue(false);
|
||||
mockTauriWindow.setFullscreen.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('isFullscreenActive returns false when not fullscreen', async () => {
|
||||
expect(await isFullscreenActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleFullscreen calls setFullscreen(true) when not fullscreen', async () => {
|
||||
mockTauriWindow.isFullscreen.mockResolvedValue(false);
|
||||
await toggleFullscreen();
|
||||
expect(mockTauriWindow.setFullscreen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('toggleFullscreen calls setFullscreen(false) when fullscreen', async () => {
|
||||
mockTauriWindow.isFullscreen.mockResolvedValue(true);
|
||||
await toggleFullscreen();
|
||||
expect(mockTauriWindow.setFullscreen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('isFullscreenActive returns true when fullscreen', async () => {
|
||||
mockTauriWindow.isFullscreen.mockResolvedValue(true);
|
||||
expect(await isFullscreenActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback — playback rate helpers', () => {
|
||||
let mockVideo: HTMLVideoElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVideo = createMockVideoElement();
|
||||
setVideoElement(mockVideo);
|
||||
});
|
||||
|
||||
it('getPlaybackRate returns 1 by default', () => {
|
||||
expect(getPlaybackRate()).toBe(1);
|
||||
});
|
||||
|
||||
it('setPlaybackRate changes the video playback rate', () => {
|
||||
setPlaybackRate(1.5);
|
||||
expect(mockVideo.playbackRate).toBe(1.5);
|
||||
});
|
||||
|
||||
it('getPlaybackRate returns the current rate', () => {
|
||||
setPlaybackRate(2);
|
||||
expect(getPlaybackRate()).toBe(2);
|
||||
});
|
||||
|
||||
it('getPlaybackRate returns 1 when no video element', () => {
|
||||
setVideoElement(null);
|
||||
expect(getPlaybackRate()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback — adjustShuttle', () => {
|
||||
let mockVideo: HTMLVideoElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVideo = createMockVideoElement();
|
||||
setVideoElement(mockVideo);
|
||||
mockVideo.playbackRate = 1;
|
||||
Object.defineProperty(mockVideo, 'paused', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
mockVideo.play = vi.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('increases rate by 0.25 when dir is 1', () => {
|
||||
const result = adjustShuttle(1);
|
||||
expect(result).toBe(1.25);
|
||||
expect(mockVideo.playbackRate).toBe(1.25);
|
||||
});
|
||||
|
||||
it('decreases rate by 0.25 when dir is -1', () => {
|
||||
const result = adjustShuttle(-1);
|
||||
expect(result).toBe(0.75);
|
||||
expect(mockVideo.playbackRate).toBe(0.75);
|
||||
});
|
||||
|
||||
it('reads current rate from video element, not external state', () => {
|
||||
mockVideo.playbackRate = 0.75;
|
||||
const result = adjustShuttle(1);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps to minimum 0.25', () => {
|
||||
mockVideo.playbackRate = 0.25;
|
||||
const result = adjustShuttle(-1);
|
||||
expect(result).toBe(0.25);
|
||||
});
|
||||
|
||||
it('clamps to maximum 4', () => {
|
||||
mockVideo.playbackRate = 4;
|
||||
const result = adjustShuttle(1);
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
it('starts playback if paused', () => {
|
||||
adjustShuttle(1);
|
||||
expect(mockVideo.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 1 when no video element', () => {
|
||||
setVideoElement(null);
|
||||
expect(adjustShuttle(1)).toBe(1);
|
||||
});
|
||||
});
|
||||
47
tests/lib/utils/clipRange.test.ts
Normal file
47
tests/lib/utils/clipRange.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeQuickClipRange } from '$lib/utils/clipRange';
|
||||
|
||||
describe('computeQuickClipRange', () => {
|
||||
it('returns null when duration is 0', () => {
|
||||
expect(computeQuickClipRange(5, 0, 'at')).toBeNull();
|
||||
});
|
||||
|
||||
it('"at" creates clip from currentTime to currentTime+10', () => {
|
||||
const result = computeQuickClipRange(30, 120, 'at');
|
||||
expect(result).toEqual({ start: 30, end: 40 });
|
||||
});
|
||||
|
||||
it('"before" creates clip from currentTime-10 to currentTime', () => {
|
||||
const result = computeQuickClipRange(30, 120, 'before');
|
||||
expect(result).toEqual({ start: 20, end: 30 });
|
||||
});
|
||||
|
||||
it('"at" clamps end to duration', () => {
|
||||
const result = computeQuickClipRange(115, 120, 'at');
|
||||
expect(result).toEqual({ start: 115, end: 120 });
|
||||
});
|
||||
|
||||
it('"before" clamps start to 0', () => {
|
||||
const result = computeQuickClipRange(3, 120, 'before');
|
||||
expect(result).toEqual({ start: 0, end: 3 });
|
||||
});
|
||||
|
||||
it('accepts custom clip duration', () => {
|
||||
const result = computeQuickClipRange(50, 120, 'at', 20);
|
||||
expect(result).toEqual({ start: 50, end: 70 });
|
||||
});
|
||||
|
||||
it('"at" at time 0 works', () => {
|
||||
const result = computeQuickClipRange(0, 120, 'at');
|
||||
expect(result).toEqual({ start: 0, end: 10 });
|
||||
});
|
||||
|
||||
it('"before" at exact duration works', () => {
|
||||
const result = computeQuickClipRange(120, 120, 'before');
|
||||
expect(result).toEqual({ start: 110, end: 120 });
|
||||
});
|
||||
|
||||
it('"at" returns null when currentTime equals duration', () => {
|
||||
expect(computeQuickClipRange(120, 120, 'at')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,19 @@ import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const libAlias = path.resolve(__dirname, 'src/lib');
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
plugins: [svelte(), svelteTesting()],
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: libAlias,
|
||||
},
|
||||
...(process.env.VITEST ? { conditions: ['browser'] } : {}),
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user