feat(captions): expand settings panel with font, shadow, dimmed color, bg toggle controls
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
329
src/lib/components/CaptionSettingsPanel.svelte
Normal file
329
src/lib/components/CaptionSettingsPanel.svelte
Normal file
@@ -0,0 +1,329 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
preferences,
|
||||
setCaptionSettings,
|
||||
DEFAULT_CAPTION_SETTINGS,
|
||||
type CaptionSettings,
|
||||
} from '$lib/stores/preferences.svelte';
|
||||
import { listSystemFonts } from '$lib/bindings/mediaAnalysis';
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
let settings = $derived(preferences.captionSettings);
|
||||
let systemFonts = $state<string[]>(['Arial']);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
systemFonts = await listSystemFonts();
|
||||
} catch {
|
||||
systemFonts = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'Courier New', 'Impact'];
|
||||
}
|
||||
});
|
||||
|
||||
function update(partial: Partial<CaptionSettings>) {
|
||||
// Enforce mutual exclusivity
|
||||
if (partial.backgroundEnabled) {
|
||||
partial.shadowEnabled = false;
|
||||
} else if (partial.shadowEnabled) {
|
||||
partial.backgroundEnabled = false;
|
||||
}
|
||||
setCaptionSettings(partial);
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
setCaptionSettings({ ...DEFAULT_CAPTION_SETTINGS });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Caption Appearance</h3>
|
||||
<button class="close-btn" onclick={onClose} title="Close">x</button>
|
||||
</div>
|
||||
|
||||
<div class="scroll-area">
|
||||
<!-- Font Family -->
|
||||
<div class="control">
|
||||
<label>Font</label>
|
||||
<select
|
||||
value={settings.fontFamily}
|
||||
onchange={(e) => update({ fontFamily: (e.target as HTMLSelectElement).value })}
|
||||
>
|
||||
{#each systemFonts as font}
|
||||
<option value={font} style="font-family: {font}">{font}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Font Size -->
|
||||
<div class="control">
|
||||
<label>
|
||||
Font Size
|
||||
<span class="value">{settings.fontSize}px</span>
|
||||
</label>
|
||||
<input
|
||||
type="range" min="12" max="36" step="1"
|
||||
value={settings.fontSize}
|
||||
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text Color + Bold -->
|
||||
<div class="control row">
|
||||
<label>Text Color</label>
|
||||
<div class="row-controls">
|
||||
<input
|
||||
type="color" value={settings.textColor}
|
||||
oninput={(e) => update({ textColor: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
<label class="inline-check">
|
||||
<input type="checkbox" checked={settings.bold}
|
||||
onchange={(e) => update({ bold: (e.target as HTMLInputElement).checked })} />
|
||||
Bold
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dimmed Text Color -->
|
||||
<div class="control">
|
||||
<label>Dimmed Text</label>
|
||||
<div class="radio-row">
|
||||
<label>
|
||||
<input type="radio" name="dimmed-mode" value="auto"
|
||||
checked={settings.dimmedColorMode === 'auto'}
|
||||
onchange={() => update({ dimmedColorMode: 'auto' })} />
|
||||
Auto
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="dimmed-mode" value="custom"
|
||||
checked={settings.dimmedColorMode === 'custom'}
|
||||
onchange={() => update({ dimmedColorMode: 'custom' })} />
|
||||
Custom
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{#if settings.dimmedColorMode === 'auto'}
|
||||
<div class="control">
|
||||
<label>
|
||||
Dim Opacity
|
||||
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range" min="10" max="90" step="5"
|
||||
value={Math.round(settings.dimmedOpacity * 100)}
|
||||
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="control row">
|
||||
<label>Dimmed Color</label>
|
||||
<div class="row-controls">
|
||||
<input
|
||||
type="color" value={settings.dimmedColor}
|
||||
oninput={(e) => update({ dimmedColor: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Outline -->
|
||||
<div class="control row">
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.textOutline}
|
||||
onchange={(e) => update({ textOutline: (e.target as HTMLInputElement).checked })} />
|
||||
Outline
|
||||
</label>
|
||||
<div class="row-controls">
|
||||
<input
|
||||
type="color" value={settings.outlineColor}
|
||||
disabled={!settings.textOutline}
|
||||
class:disabled={!settings.textOutline}
|
||||
oninput={(e) => update({ outlineColor: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background (mutually exclusive with Shadow) -->
|
||||
<div class="control row">
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.backgroundEnabled}
|
||||
onchange={(e) => update({ backgroundEnabled: (e.target as HTMLInputElement).checked })} />
|
||||
Background
|
||||
</label>
|
||||
<div class="row-controls">
|
||||
<input
|
||||
type="color" value={settings.backgroundColor}
|
||||
disabled={!settings.backgroundEnabled}
|
||||
class:disabled={!settings.backgroundEnabled}
|
||||
oninput={(e) => update({ backgroundColor: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if settings.backgroundEnabled}
|
||||
<div class="control">
|
||||
<label>
|
||||
BG Opacity
|
||||
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range" min="0" max="100" step="5"
|
||||
value={Math.round(settings.backgroundOpacity * 100)}
|
||||
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Shadow (mutually exclusive with Background) -->
|
||||
<div class="control row">
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.shadowEnabled}
|
||||
onchange={(e) => update({ shadowEnabled: (e.target as HTMLInputElement).checked })} />
|
||||
Drop Shadow
|
||||
</label>
|
||||
<div class="row-controls">
|
||||
<input
|
||||
type="color" value={settings.shadowColor}
|
||||
disabled={!settings.shadowEnabled}
|
||||
class:disabled={!settings.shadowEnabled}
|
||||
oninput={(e) => update({ shadowColor: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if settings.shadowEnabled}
|
||||
<div class="control">
|
||||
<label>
|
||||
Shadow Depth
|
||||
<span class="value">{settings.shadowDepth}px</span>
|
||||
</label>
|
||||
<input
|
||||
type="range" min="1" max="5" step="1"
|
||||
value={settings.shadowDepth}
|
||||
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Position -->
|
||||
<div class="control">
|
||||
<label>Position</label>
|
||||
<div class="radio-row">
|
||||
<label>
|
||||
<input type="radio" name="caption-position" value="bottom"
|
||||
checked={settings.position === 'bottom'}
|
||||
onchange={() => update({ position: 'bottom' })} />
|
||||
Bottom
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="caption-position" value="top"
|
||||
checked={settings.position === 'top'}
|
||||
onchange={() => update({ position: 'top' })} />
|
||||
Top
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word Highlight -->
|
||||
<div class="control">
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.wordHighlight}
|
||||
onchange={(e) => update({ wordHighlight: (e.target as HTMLInputElement).checked })} />
|
||||
Word-by-word highlight
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<button class="reset-btn" onclick={resetToDefaults}>Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
position: absolute;
|
||||
bottom: 44px;
|
||||
right: 12px;
|
||||
background: var(--bg-primary, #1e1e2e);
|
||||
border: 1px solid var(--border, #353550);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
width: 320px;
|
||||
max-height: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 20;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
h3 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text-primary, #cdd6f4); }
|
||||
|
||||
.close-btn {
|
||||
background: none; border: none; color: var(--text-secondary, #6c7086);
|
||||
cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 3px;
|
||||
}
|
||||
.close-btn:hover { background: var(--bg-tertiary, #2a2a3e); color: var(--text-primary, #cdd6f4); }
|
||||
|
||||
.scroll-area {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.control { margin-bottom: 10px; }
|
||||
.control label {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: var(--text-secondary, #6c7086); margin-bottom: 4px;
|
||||
}
|
||||
.control.row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.control.row > label { margin-bottom: 0; }
|
||||
|
||||
.row-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.inline-check {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; color: var(--text-secondary, #6c7086);
|
||||
margin-bottom: 0 !important; cursor: pointer;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-left: auto; font-size: 11px;
|
||||
color: var(--text-primary, #cdd6f4); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%; padding: 4px 6px; font-size: 12px;
|
||||
background: var(--bg-secondary, #252535); color: var(--text-primary, #cdd6f4);
|
||||
border: 1px solid var(--border, #353550); border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type='range'] { width: 100%; accent-color: #89b4fa; }
|
||||
input[type='color'] {
|
||||
width: 32px; height: 24px; padding: 0;
|
||||
border: 1px solid var(--border, #353550); border-radius: 3px;
|
||||
cursor: pointer; background: none;
|
||||
}
|
||||
input[type='color'].disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
input[type='checkbox'] { accent-color: #89b4fa; }
|
||||
input[type='radio'] { accent-color: #89b4fa; }
|
||||
|
||||
.radio-row { display: flex; gap: 16px; }
|
||||
.radio-row label { font-size: 12px; color: var(--text-secondary, #6c7086); cursor: pointer; }
|
||||
|
||||
.panel-footer {
|
||||
margin-top: 8px; padding-top: 8px; flex-shrink: 0;
|
||||
border-top: 1px solid var(--border, #353550);
|
||||
}
|
||||
.reset-btn {
|
||||
font-size: 11px; color: var(--text-secondary, #6c7086);
|
||||
background: none; border: none; cursor: pointer; padding: 2px 0;
|
||||
}
|
||||
.reset-btn:hover { color: var(--text-primary, #cdd6f4); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user