feat(captions): use font, shadow, dimmed color, bg toggle in preview captions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 10:35:22 -04:00
parent 5b0ca72c4c
commit 517f45e8fd

View File

@@ -1,12 +1,122 @@
<script lang="ts"> <script lang="ts">
import { session } from '$lib/stores/videoSession.svelte'; import { session, upgradePreview, dismissUpgradeToast } from '$lib/stores/videoSession.svelte';
import { setVideoElement } 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';
import CaptionSettingsPanel from '$lib/components/CaptionSettingsPanel.svelte';
function hexToRgb(hex: string): string {
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}`;
}
let videoElement = $state<HTMLVideoElement | null>(null); 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);
// Fetch media server port on mount
$effect(() => {
getMediaServerPort().then((port) => {
mediaServerPort = port;
});
});
// Active cues at current time
let activeCues = $derived(
captionsEnabled ? getActiveCues(parsedCues, session.currentTime) : []
);
let captionStyle = $derived.by(() => {
const s = preferences.captionSettings;
const bg = s.backgroundEnabled
? `rgba(${hexToRgb(s.backgroundColor)}, ${s.backgroundOpacity})`
: 'transparent';
const shadow = s.shadowEnabled
? `${s.shadowDepth}px ${s.shadowDepth}px ${s.shadowDepth * 2}px ${s.shadowColor}`
: s.textOutline
? '1px 1px 2px rgba(0,0,0,0.9), -1px -1px 2px rgba(0,0,0,0.9)'
: 'none';
return {
fontFamily: s.fontFamily,
fontSize: `${s.fontSize}px`,
fontWeight: s.bold ? 'bold' : 'normal',
color: s.textColor,
background: bg,
textShadow: shadow,
};
});
let captionPosition = $derived(
preferences.captionSettings.position === 'top' ? 'top: 8%' : 'bottom: 8%'
);
$effect(() => {
setVideoElement(videoElement);
return () => setVideoElement(null);
});
$effect(() => {
const filePath = session.activeVideoPath;
const status = session.previewStatus;
const port = mediaServerPort;
if (status === 'complete' && filePath && port) {
videoSrc = `http://127.0.0.1:${port}${encodeURI(filePath)}`;
} else {
videoSrc = null;
}
});
// Load and parse VTT caption file
$effect(() => {
const captionPath = session.captionFilePath;
const port = mediaServerPort;
if (captionPath && port) {
const url = `http://127.0.0.1:${port}${encodeURI(captionPath)}`;
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.text();
})
.then((vttText) => {
parsedCues = parseVtt(vttText);
console.log(`[VideoPlayer] parsed ${parsedCues.length} caption cues from ${captionPath}`);
})
.catch((err) => {
console.error('Failed to load caption file:', err);
parsedCues = [];
});
} else {
parsedCues = [];
}
});
function toggleCaptions() {
captionsEnabled = !captionsEnabled;
}
function toggleCaptionSettings() {
showCaptionSettings = !showCaptionSettings;
}
let lastTimeUpdate = 0;
const TIME_UPDATE_INTERVAL = 66;
function handleTimeUpdate() { function handleTimeUpdate() {
if (videoElement) { if (!videoElement) return;
session.currentTime = videoElement.currentTime; const now = performance.now();
} if (now - lastTimeUpdate < TIME_UPDATE_INTERVAL) return;
lastTimeUpdate = now;
session.currentTime = videoElement.currentTime;
} }
function handlePlay() { function handlePlay() {
@@ -17,6 +127,28 @@
session.isPlaying = false; session.isPlaying = false;
} }
function handleError() {
if (videoElement?.error) {
videoError = `Video error: ${videoElement.error.message || 'Failed to load source'}`;
}
}
function handleLoadedData() {
videoError = null;
if (videoElement) {
console.log('[VideoPlayer] loadeddata:', {
src: videoElement.src,
muted: videoElement.muted,
volume: videoElement.volume,
readyState: videoElement.readyState,
duration: videoElement.duration,
audioTracks: (videoElement as any).audioTracks?.length ?? 'N/A',
videoWidth: videoElement.videoWidth,
videoHeight: videoElement.videoHeight,
});
}
}
export function seek(time: number) { export function seek(time: number) {
if (videoElement) { if (videoElement) {
videoElement.currentTime = time; videoElement.currentTime = time;
@@ -25,7 +157,9 @@
} }
export function play() { export function play() {
videoElement?.play(); videoElement?.play().catch((e) => {
console.error('play() rejected:', e);
});
} }
export function pause() { export function pause() {
@@ -35,7 +169,9 @@
export function togglePlayPause() { export function togglePlayPause() {
if (!videoElement) return; if (!videoElement) return;
if (videoElement.paused) { if (videoElement.paused) {
videoElement.play(); videoElement.play().catch((e) => {
console.error('play() rejected:', e);
});
} else { } else {
videoElement.pause(); videoElement.pause();
} }
@@ -47,21 +183,78 @@
</script> </script>
<div class="video-player"> <div class="video-player">
{#if session.status === 'ready' && session.streamUrl} {#if session.status === 'ready' && session.previewStatus === 'complete' && videoSrc}
<video <video
bind:this={videoElement} bind:this={videoElement}
src={session.streamUrl} src={videoSrc}
ontimeupdate={handleTimeUpdate} ontimeupdate={handleTimeUpdate}
onplay={handlePlay} onplay={handlePlay}
onpause={handlePause} onpause={handlePause}
preload="auto" onerror={handleError}
> onloadeddata={handleLoadedData}
<track kind="captions" /> preload="metadata"
</video> playsinline
></video>
{#if activeCues.length > 0}
<div class="caption-overlay" style={captionPosition}>
{#each activeCues as cue}
<span
class="caption-text"
style="font-family: {captionStyle.fontFamily}; font-size: {captionStyle.fontSize}; font-weight: {captionStyle.fontWeight}; background: {captionStyle.background}; text-shadow: {captionStyle.textShadow};"
>{#if preferences.captionSettings.wordHighlight && cue.words}{#each getActiveWords(cue, session.currentTime) as word}<span
class="caption-word"
class:spoken={word.spoken}
style="color: {word.spoken
? captionStyle.color
: preferences.captionSettings.dimmedColorMode === 'custom'
? preferences.captionSettings.dimmedColor
: captionStyle.color}; opacity: {word.spoken ? 1 : (preferences.captionSettings.dimmedColorMode === 'custom' ? 1 : preferences.captionSettings.dimmedOpacity)};"
>{word.text}</span>{' '}{/each}{:else}<span style="color: {captionStyle.color};">{cue.text}</span>{/if}</span>
{/each}
</div>
{/if}
{#if parsedCues.length > 0}
<div class="cc-controls">
<button
class="cc-toggle"
class:active={captionsEnabled}
onclick={toggleCaptions}
title={captionsEnabled ? 'Hide captions' : 'Show captions'}
>CC</button>
<button
class="cc-settings-btn"
onclick={toggleCaptionSettings}
title="Caption settings"
>⚙</button>
</div>
{/if}
{#if showCaptionSettings}
<CaptionSettingsPanel onClose={() => (showCaptionSettings = false)} />
{/if}
{#if session.showUpgradeToast}
<div class="upgrade-toast">
<span>Higher quality preview available</span>
<button class="upgrade-btn" onclick={upgradePreview}>Upgrade</button>
<button class="dismiss-btn" onclick={dismissUpgradeToast}>✕</button>
</div>
{/if}
{#if videoError}
<div class="video-overlay-error">{videoError}</div>
{/if}
{:else if session.status === 'ready' && session.previewStatus === 'downloading'}
<div class="placeholder">
<div>Downloading preview…</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {Math.round(session.previewProgress * 100)}%"></div>
</div>
<div class="progress-text">{Math.round(session.previewProgress * 100)}%</div>
</div>
{:else if session.status === 'resolving'} {:else if session.status === 'resolving'}
<div class="placeholder">Loading video…</div> <div class="placeholder">Loading video…</div>
{:else if session.status === 'error'} {:else if session.status === 'error'}
<div class="placeholder error">{session.error ?? 'Failed to load video.'}</div> <div class="placeholder error">{session.error ?? 'Failed to load video.'}</div>
{:else if session.status === 'ready' && session.previewStatus === 'idle'}
<div class="placeholder">Preparing download…</div>
{:else} {:else}
<div class="placeholder">Paste a URL above to load a video</div> <div class="placeholder">Paste a URL above to load a video</div>
{/if} {/if}
@@ -74,8 +267,9 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #000; background: #000;
min-height: 200px; min-height: 0;
position: relative; position: relative;
overflow: hidden;
} }
video { video {
@@ -84,6 +278,77 @@
object-fit: contain; object-fit: contain;
} }
.caption-overlay {
position: absolute;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
pointer-events: none;
z-index: 5;
padding: 0 10%;
}
.caption-text {
display: inline-block;
padding: 4px 10px;
border-radius: 3px;
line-height: 1.4;
text-align: center;
white-space: pre-wrap;
max-width: 80%;
}
.caption-word {
transition: opacity 0.1s ease;
}
.caption-word.spoken {
opacity: 1;
}
.cc-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
gap: 4px;
z-index: 10;
}
.cc-toggle,
.cc-settings-btn {
background: rgba(0, 0, 0, 0.7);
color: #ccc;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
letter-spacing: 0.5px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.cc-toggle:hover,
.cc-settings-btn:hover {
background: rgba(0, 0, 0, 0.9);
color: #fff;
}
.cc-toggle.active {
background: rgba(255, 255, 255, 0.9);
color: #000;
border-color: rgba(255, 255, 255, 0.9);
}
.cc-settings-btn {
font-weight: 400;
font-size: 14px;
}
.placeholder { .placeholder {
color: var(--text-muted); color: var(--text-muted);
font-size: 16px; font-size: 16px;
@@ -92,4 +357,84 @@
.placeholder.error { .placeholder.error {
color: var(--danger); color: var(--danger);
} }
.video-overlay-error {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.8);
color: var(--danger);
font-size: 12px;
border-radius: 4px;
z-index: 20;
}
.progress-bar {
width: 200px;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
margin: 12px auto 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent, #89b4fa);
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 13px;
color: var(--text-secondary);
}
.upgrade-toast {
position: absolute;
top: 10px;
right: 10px;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-primary, #1e1e2e);
border: 1px solid var(--border, #353550);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
color: var(--text-primary, #cdd6f4);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 15;
}
.upgrade-btn {
background: #89b4fa;
color: #1e1e2e;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.upgrade-btn:hover {
background: #b4d0fb;
}
.dismiss-btn {
background: none;
border: none;
color: var(--text-secondary, #6c7086);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
line-height: 1;
}
.dismiss-btn:hover {
color: var(--text-primary, #cdd6f4);
}
</style> </style>