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:
2026-09-23 05:30:45 -04:00
parent 619fe59dbe
commit c1de18715e
18 changed files with 629 additions and 66 deletions

View File

@@ -1 +1 @@
0.2.0
0.2.1

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

@@ -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}

View File

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

View File

@@ -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' },
];

View File

@@ -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 --- */

View File

@@ -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
View File

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

View File

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