Files
gui-video-clipper/src/lib/components/PlayerControls.svelte
cottongin c1de18715e 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>
2026-09-23 05:30:45 -04:00

581 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>