feat: v0.2.1 — Player controls polish, screenshot capture, quit shortcut
v0.2.0 introduced Player mode with floating controls, auto-hide UI,
PiP, fullscreen, JKL shuttle, speed selector, captions, and cut-clip.
v0.2.1 builds on that with extensive UI polish and new features:
Player Controls Polish:
- Enlarged center playback icons (Play 48, Rewind/FF 36) with filled SVGs
- Moved Cut Clip scissors into center group with hidden cut-before button
- Narrowed floating panel from 70% to 60% width
- Custom-styled volume slider matching seek bar (linear-gradient fill)
- Pure opacity fade animation (0.35s) replacing slide-in
- Tinted panel background (rgba(53,54,80,0.75)) visible on black canvas
- Drop shadow on center transport controls
- Scissors buttons dimmed via color (not opacity) with single-property transitions
- ScissorsLineDashed icon for cut-before variant
- Timestamps in single seek row (time | bar | duration) with sans-serif font
- Milliseconds shown at smaller size, revealed on hover
Screenshot Capture (S / Shift+S):
- Canvas compositing at native video resolution with optional caption burn-in
- Caption rendering matches CSS preview (font, background, outline, word highlight)
- New Rust save_screenshot command using existing filename pattern
- Output: {title} - {ms}ms-screenshot.png
- crossorigin=anonymous on video element for untainted canvas
Other:
- Q keyboard shortcut to quit via Tauri process plugin
- formatTimeParts() utility for split timestamp rendering
- Updated About dialog shortcuts list
- Version bump to 0.2.1
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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).
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gui-video-clipper",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -1281,7 +1281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gui-video-clipper"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"dirs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gui-video-clipper"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "A macOS GUI app for clipping online videos"
|
||||
authors = ["cottongin"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -168,6 +168,7 @@ pub fn run() {
|
||||
media_analysis::check_embedded_subtitles,
|
||||
media_analysis::list_system_fonts,
|
||||
export::export_clips,
|
||||
export::save_screenshot,
|
||||
cache::load_cached_analysis,
|
||||
cache::save_analysis_to_cache,
|
||||
cache::clear_video_cache,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "GUI Video Clipper",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"identifier": "xyz.cottongin.gui-video-clipper",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
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,
|
||||
@@ -39,6 +41,7 @@
|
||||
let shuttleRate = $state(1);
|
||||
|
||||
let transportControls = $state<TransportControls | null>(null);
|
||||
let videoPlayerRef = $state<VideoPlayer | null>(null);
|
||||
|
||||
// Resizable split: timeline height in px (clip list gets the rest)
|
||||
let timelineHeight = $state(180);
|
||||
@@ -198,6 +201,29 @@
|
||||
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;
|
||||
}
|
||||
@@ -312,6 +338,16 @@
|
||||
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();
|
||||
@@ -423,7 +459,7 @@
|
||||
|
||||
<!-- Video area: always mounted, never destroyed -->
|
||||
<div class="video-area" class:player-video={isPlayerMode}>
|
||||
<VideoPlayer bind:captionsEnabled playerControlsVisible={isPlayerMode && showPlayerControls} />
|
||||
<VideoPlayer bind:this={videoPlayerRef} bind:captionsEnabled playerControlsVisible={isPlayerMode && showPlayerControls} />
|
||||
{#if isPlayerMode}
|
||||
<PlayerControls
|
||||
visible={showPlayerControls}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
setPlaybackRate,
|
||||
getPlaybackRate,
|
||||
} from '$lib/transport/playback';
|
||||
import { formatTime } from '$lib/utils/time';
|
||||
import { formatTime, formatTimeParts } from '$lib/utils/time';
|
||||
import {
|
||||
VolumeX,
|
||||
Volume1,
|
||||
@@ -26,6 +26,7 @@
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
ScissorsLineDashed,
|
||||
Captions,
|
||||
PictureInPicture2,
|
||||
Maximize,
|
||||
@@ -65,6 +66,13 @@
|
||||
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);
|
||||
@@ -185,29 +193,42 @@
|
||||
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={24} /></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={27} />{:else}<Play size={27} />{/if}</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={24} /></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 -->
|
||||
@@ -228,14 +249,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{#if hasCaptions}
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
@@ -265,36 +278,34 @@
|
||||
</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)}
|
||||
<!-- 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}
|
||||
</div>
|
||||
|
||||
<!-- Timestamps (bottom) -->
|
||||
<div class="timestamps-row">
|
||||
<span class="time-display">{formatTime(session.currentTime)}</span>
|
||||
<span class="time-display">{formatTime(session.duration)}</span>
|
||||
{#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>
|
||||
@@ -305,8 +316,8 @@
|
||||
.player-controls-overlay {
|
||||
position: absolute;
|
||||
bottom: 84px;
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
@@ -322,42 +333,45 @@
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Blur background — visibility toggle, never opacity-transition */
|
||||
/* Blur background — fades in/out with content */
|
||||
.player-controls-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
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 — fade + slide animation */
|
||||
/* Content wrapper — pure opacity fade */
|
||||
.panel-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.player-controls-overlay.visible .panel-content {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* --- Seek bar --- */
|
||||
.seek-bar {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
@@ -420,13 +434,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.controls-group.left {
|
||||
@@ -436,6 +450,13 @@
|
||||
|
||||
.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 {
|
||||
@@ -470,29 +491,86 @@
|
||||
}
|
||||
|
||||
.ctrl-btn.play-btn {
|
||||
min-width: 48px;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
/* --- Volume --- */
|
||||
/* 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;
|
||||
accent-color: var(--accent, #89b4fa);
|
||||
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: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timestamps-row {
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* --- Speed wrapper --- */
|
||||
|
||||
@@ -215,6 +215,10 @@
|
||||
export function getVideoElement(): HTMLVideoElement | null {
|
||||
return videoElement;
|
||||
}
|
||||
|
||||
export function getParsedCues(): VttCue[] {
|
||||
return parsedCues;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="video-player">
|
||||
@@ -222,6 +226,7 @@
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={videoSrc}
|
||||
crossorigin="anonymous"
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
onplay={handlePlay}
|
||||
onpause={handlePause}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user