feat(captions): use font, shadow, dimmed color, bg toggle in preview captions
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user