2026-04-06 13:42:31 -07:00
< script lang = "ts" >
import { configStore } from "$lib/stores/config";
import { backendStore } from "$lib/stores/backend";
interface Props {
onClose: () => void;
}
let { onClose } : Props = $props();
// Local copies of config values for editing
let userName = $state("");
let audioDevice = $state("default");
let model = $state("base.en");
let language = $state("en");
let computeDevice = $state("auto");
let computeType = $state("default");
let enableRealtime = $state(false);
let realtimeModel = $state("tiny.en");
let realtimeProcessingPause = $state(0.1);
let sileroSensitivity = $state(0.4);
let webrtcSensitivity = $state(3);
let postSpeechSilence = $state(0.3);
let minRecordingLength = $state(0.5);
let minGapBetween = $state(0);
let continuousMode = $state(false);
let showTimestamps = $state(true);
let fadeSeconds = $state(10);
let maxLines = $state(100);
2026-04-07 16:40:52 -07:00
let fontSource = $state("System Font");
let fontFamily = $state("Courier");
let websafeFont = $state("Arial");
let googleFont = $state("Roboto");
2026-04-06 13:42:31 -07:00
let fontSize = $state(12);
let userColor = $state("#4CAF50");
let textColor = $state("#FFFFFF");
let backgroundColor = $state("#000000");
let syncEnabled = $state(false);
let syncUrl = $state("");
let syncRoom = $state("default");
let syncPassphrase = $state("");
let remoteMode = $state("local");
let remoteServerUrl = $state("");
2026-04-07 06:39:24 -07:00
let byokApiKey = $state("");
2026-04-06 13:42:31 -07:00
let managedEmail = $state("");
let managedPassword = $state("");
let autoCheckUpdates = $state(true);
2026-04-10 11:58:41 -07:00
let isCloudMode = $derived(remoteMode === "managed" || remoteMode === "byok");
// Room creation / join state
let shareCode = $state("");
let joinCode = $state("");
let roomCreating = $state(false);
let roomCreateMessage = $state("");
2026-04-07 06:40:59 -07:00
let saving = $state(false);
let saveMessage = $state("");
2026-04-06 13:42:31 -07:00
// Fetched device lists
let audioDevices = $state< { id : string ; name : string } []>([]);
let computeDevices = $state< { id : string ; name : string } []>([]);
// Model options
const modelOptions = [
"tiny",
"tiny.en",
"base",
"base.en",
"small",
"small.en",
"medium",
"medium.en",
"large-v1",
"large-v2",
"large-v3",
];
const computeTypeOptions = [
{ value : "default" , label : "Default" } ,
{ value : "int8" , label : "int8 (Fastest)" } ,
{ value : "float16" , label : "float16 (GPU)" } ,
{ value : "float32" , label : "float32 (Best Quality)" } ,
];
const webrtcOptions = [
{ value : 0 , label : "0 (Most Sensitive)" } ,
{ value : 1 , label : "1" } ,
{ value : 2 , label : "2" } ,
{ value : 3 , label : "3 (Least Sensitive)" } ,
];
// Load config values on mount
$effect(() => {
const cfg = configStore.config;
userName = cfg.user.name;
audioDevice = cfg.audio.input_device;
model = cfg.transcription.model;
language = cfg.transcription.language;
computeDevice = cfg.transcription.device;
computeType = cfg.transcription.compute_type;
enableRealtime = cfg.transcription.enable_realtime_transcription;
realtimeModel = cfg.transcription.realtime_model;
realtimeProcessingPause = cfg.transcription.realtime_processing_pause;
sileroSensitivity = cfg.transcription.silero_sensitivity;
webrtcSensitivity = cfg.transcription.webrtc_sensitivity;
postSpeechSilence = cfg.transcription.post_speech_silence_duration;
minRecordingLength = cfg.transcription.min_length_of_recording;
minGapBetween = cfg.transcription.min_gap_between_recordings;
continuousMode = cfg.transcription.continuous_mode;
showTimestamps = cfg.display.show_timestamps;
fadeSeconds = cfg.display.fade_after_seconds;
maxLines = cfg.display.max_lines;
2026-04-07 16:40:52 -07:00
fontSource = cfg.display.font_source ?? "System Font";
fontFamily = cfg.display.font_family ?? "Courier";
websafeFont = cfg.display.websafe_font ?? "Arial";
googleFont = cfg.display.google_font ?? "Roboto";
2026-04-06 13:42:31 -07:00
fontSize = cfg.display.font_size;
userColor = cfg.display.user_color;
textColor = cfg.display.text_color;
// Strip alpha from background color for the color picker (only supports 6-char hex)
const bgHex = cfg.display.background_color.replace("#", "");
backgroundColor = "#" + bgHex.substring(0, 6);
syncEnabled = cfg.server_sync.enabled;
syncUrl = cfg.server_sync.url;
syncRoom = cfg.server_sync.room;
syncPassphrase = cfg.server_sync.passphrase;
remoteMode = cfg.remote.mode;
remoteServerUrl = cfg.remote.server_url;
2026-04-07 06:39:24 -07:00
byokApiKey = cfg.remote.byok_api_key ?? "";
2026-04-06 13:42:31 -07:00
autoCheckUpdates = cfg.updates.auto_check;
});
// Fetch audio devices and compute devices on mount
$effect(() => {
fetchAudioDevices();
fetchComputeDevices();
});
async function fetchAudioDevices() {
try {
const data = await backendStore.apiGet< {
devices: { id : string ; name : string } [];
}>("/api/audio-devices");
audioDevices = data.devices ?? [];
} catch {
audioDevices = [];
}
}
async function fetchComputeDevices() {
try {
const data = await backendStore.apiGet< {
devices: { id : string ; name : string } [];
}>("/api/compute-devices");
computeDevices = data.devices ?? [];
} catch {
computeDevices = [
{ id : "auto" , name : "Auto" } ,
{ id : "cpu" , name : "CPU" } ,
{ id : "cuda" , name : "CUDA (GPU)" } ,
];
}
}
async function handleSave() {
const updates = {
user: {
name: userName,
},
audio: {
input_device: audioDevice,
},
transcription: {
model,
device: computeDevice,
language,
compute_type: computeType,
enable_realtime_transcription: enableRealtime,
realtime_model: realtimeModel,
realtime_processing_pause: realtimeProcessingPause,
silero_sensitivity: sileroSensitivity,
webrtc_sensitivity: webrtcSensitivity,
post_speech_silence_duration: postSpeechSilence,
min_length_of_recording: minRecordingLength,
min_gap_between_recordings: minGapBetween,
continuous_mode: continuousMode,
},
display: {
show_timestamps: showTimestamps,
fade_after_seconds: fadeSeconds,
max_lines: maxLines,
2026-04-07 16:40:52 -07:00
font_source: fontSource,
font_family: fontFamily,
websafe_font: websafeFont,
google_font: googleFont,
2026-04-06 13:42:31 -07:00
font_size: fontSize,
user_color: userColor,
text_color: textColor,
background_color: backgroundColor,
},
server_sync: {
enabled: syncEnabled,
url: syncUrl,
room: syncRoom,
passphrase: syncPassphrase,
},
remote: {
mode: remoteMode,
2026-04-10 19:12:50 -07:00
server_url: remoteServerUrl || MANAGED_SERVER_URL,
2026-04-07 06:39:24 -07:00
byok_api_key: byokApiKey,
2026-04-06 13:42:31 -07:00
},
updates: {
auto_check: autoCheckUpdates,
},
};
2026-04-07 06:40:59 -07:00
saving = true;
saveMessage = "";
2026-04-06 13:42:31 -07:00
try {
2026-04-07 06:40:59 -07:00
await configStore.updateConfig(updates);
saveMessage = "Settings saved!";
setTimeout(() => onClose(), 600);
2026-04-06 13:42:31 -07:00
} catch (err) {
console.error("Failed to save settings:", err);
2026-04-07 06:40:59 -07:00
saveMessage = `Error: ${ err } `;
saving = false;
2026-04-06 13:42:31 -07:00
}
}
function handleCancel() {
onClose();
}
async function handleCheckUpdates() {
try {
Add test suite (63 tests) and CI workflow, fix Settings API bugs
Test suite covering all three layers:
Python backend (25 tests):
- AppController: state machine, start/stop, callbacks, settings reload
- API server: REST endpoints, config CRUD, status, devices
- Config: dot-notation get/set, persistence, nested paths
- Main headless: ready event port format validation
Svelte frontend (14 tests via Vitest):
- Backend store: exported properties/methods, port derivation, URLs
- Config store: method names (fetchConfig not loadConfig), defaults
- Transcriptions store: add/clear/plaintext
- File extension regression: ensures $state runes only in .svelte.ts
Rust sidecar (24 tests via cargo test):
- Platform/arch detection, asset name construction
- Ready event deserialization (with extra fields tolerance)
- Path construction, version read/write, old version cleanup
- Zip extraction, SidecarManager lifecycle
CI workflow (.gitea/workflows/test.yml):
- Runs on push to main and PRs
- Three parallel jobs: Python, Frontend, Rust
Also fixes three bugs found during test planning:
- Settings: /api/check-updates -> GET /api/check-update
- Settings: /api/remote/login -> /api/login
- Settings: /api/remote/register -> /api/register
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:48:34 -07:00
await backendStore.apiGet("/api/check-update");
2026-04-06 13:42:31 -07:00
} catch (err) {
console.error("Failed to check for updates:", err);
}
}
2026-04-07 17:02:31 -07:00
async function handleChangeSidecar() {
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("reset_sidecar");
// Force a page reload which will re-trigger the setup flow
window.location.reload();
} catch (err) {
console.error("Failed to reset sidecar:", err);
saveMessage = `Error: ${ err } `;
}
}
2026-04-10 19:12:50 -07:00
const MANAGED_SERVER_URL = "https://transcribe.shadowdao.com";
2026-04-06 13:42:31 -07:00
async function handleManagedLogin() {
try {
Add test suite (63 tests) and CI workflow, fix Settings API bugs
Test suite covering all three layers:
Python backend (25 tests):
- AppController: state machine, start/stop, callbacks, settings reload
- API server: REST endpoints, config CRUD, status, devices
- Config: dot-notation get/set, persistence, nested paths
- Main headless: ready event port format validation
Svelte frontend (14 tests via Vitest):
- Backend store: exported properties/methods, port derivation, URLs
- Config store: method names (fetchConfig not loadConfig), defaults
- Transcriptions store: add/clear/plaintext
- File extension regression: ensures $state runes only in .svelte.ts
Rust sidecar (24 tests via cargo test):
- Platform/arch detection, asset name construction
- Ready event deserialization (with extra fields tolerance)
- Path construction, version read/write, old version cleanup
- Zip extraction, SidecarManager lifecycle
CI workflow (.gitea/workflows/test.yml):
- Runs on push to main and PRs
- Three parallel jobs: Python, Frontend, Rust
Also fixes three bugs found during test planning:
- Settings: /api/check-updates -> GET /api/check-update
- Settings: /api/remote/login -> /api/login
- Settings: /api/remote/register -> /api/register
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:48:34 -07:00
await backendStore.apiPost("/api/login", {
2026-04-06 13:42:31 -07:00
email: managedEmail,
password: managedPassword,
2026-04-10 19:12:50 -07:00
server_url: remoteServerUrl || MANAGED_SERVER_URL,
2026-04-06 13:42:31 -07:00
});
} catch (err) {
console.error("Login failed:", err);
}
}
2026-04-10 11:58:41 -07:00
const CAPTION_SERVER = "https://caption.shadowdao.com";
function generateRandomName(): string {
const adjectives = ['swift', 'bright', 'cosmic', 'electric', 'turbo', 'mega', 'ultra', 'super', 'hyper', 'alpha'];
const nouns = ['phoenix', 'dragon', 'tiger', 'falcon', 'comet', 'storm', 'blaze', 'thunder', 'frost', 'nebula'];
const num = Math.floor(Math.random() * 10000);
return `${ adjectives [ Math . floor ( Math . random () * adjectives . length )]} -${ nouns [ Math . floor ( Math . random () * nouns . length )]} -${ num } `;
}
function generateRandomPassphrase(): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 16 ; i ++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function encodeShareCode(url: string, room: string, passphrase: string): string {
return btoa(JSON.stringify({ url , room , passphrase } ));
}
function decodeShareCode(code: string): { url : string ; room : string ; passphrase : string } | null {
try {
const json = JSON.parse(atob(code.trim()));
if (json.url && json.room && json.passphrase) {
return json;
}
return null;
} catch {
return null;
}
}
async function handleCreateRoom() {
roomCreating = true;
roomCreateMessage = "";
shareCode = "";
const room = generateRandomName();
const passphrase = generateRandomPassphrase();
const serverSendUrl = `${ CAPTION_SERVER } /api/send`;
try {
const resp = await fetch(`${ CAPTION_SERVER } /api/create-room`, {
method: "POST",
headers: { "Content-Type" : "application/json" } ,
body: JSON.stringify({ room , passphrase } ),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error : "Request failed" } ));
roomCreateMessage = `Error: ${ err . error || resp . statusText } `;
return;
}
syncUrl = serverSendUrl;
syncRoom = room;
syncPassphrase = passphrase;
syncEnabled = true;
shareCode = encodeShareCode(serverSendUrl, room, passphrase);
roomCreateMessage = "Room created! Share the code below with others.";
} catch (err) {
roomCreateMessage = `Error: ${ err instanceof Error ? err.message : String ( err )} `;
} finally {
roomCreating = false;
}
}
function handleJoinRoom() {
const decoded = decodeShareCode(joinCode);
if (!decoded) {
roomCreateMessage = "Invalid share code. Please check and try again.";
return;
}
syncUrl = decoded.url;
syncRoom = decoded.room;
syncPassphrase = decoded.passphrase;
syncEnabled = true;
joinCode = "";
roomCreateMessage = "Room joined! Fields have been auto-filled.";
}
2026-04-10 12:26:21 -07:00
async function handleShareCurrentRoom() {
const code = encodeShareCode(syncUrl, syncRoom, syncPassphrase);
shareCode = code;
try {
await navigator.clipboard.writeText(code);
roomCreateMessage = "Share code copied to clipboard!";
} catch {
roomCreateMessage = "Share code generated. Copy it from the field below.";
}
}
2026-04-10 11:58:41 -07:00
async function copyShareCode() {
try {
await navigator.clipboard.writeText(shareCode);
roomCreateMessage = "Share code copied to clipboard!";
} catch {
roomCreateMessage = "Failed to copy. Please select and copy manually.";
}
}
2026-04-06 13:42:31 -07:00
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("settings-overlay")) {
handleCancel();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
handleCancel();
}
}
< / script >
< svelte:window onkeydown = { handleKeydown } / >
<!-- svelte - ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
< div class = "settings-overlay" role = "presentation" onclick = { handleOverlayClick } >
< div class = "settings-panel" >
< div class = "settings-header" >
< h2 > Settings< / h2 >
< button class = "close-btn" aria-label = "Close settings" onclick = { handleCancel } >
< svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
< line x1 = "18" y1 = "6" x2 = "6" y2 = "18" > < / line >
< line x1 = "6" y1 = "6" x2 = "18" y2 = "18" > < / line >
< / svg >
< / button >
< / div >
< div class = "settings-content" >
<!-- User Settings -->
< section class = "settings-section" >
< h3 > User Settings< / h3 >
< div class = "field" >
< label for = "user-name" > Display Name< / label >
< input id = "user-name" type = "text" bind:value = { userName } / >
< / div >
< / section >
<!-- Audio Settings -->
< section class = "settings-section" >
< h3 > Audio Settings< / h3 >
< div class = "field" >
< label for = "audio-device" > Audio Device< / label >
< select id = "audio-device" bind:value = { audioDevice } >
< option value = "default" > Default< / option >
{ #each audioDevices as device }
< option value = { device . id } > { device . name } </option >
{ /each }
< / select >
< / div >
< / section >
2026-04-10 11:58:41 -07:00
<!-- Remote Transcription (moved up for cloud - first UX) -->
< section class = "settings-section" >
< h3 > Transcription Mode< / h3 >
< div class = "radio-group" >
< label >
< input
type="radio"
name="remote-mode"
value="byok"
bind:group={ remoteMode }
/>
Cloud (Deepgram)
< / label >
< label >
< input
type="radio"
name="remote-mode"
value="managed"
bind:group={ remoteMode }
/>
Managed Service
< / label >
< label >
< input
type="radio"
name="remote-mode"
value="local"
bind:group={ remoteMode }
/>
Local (Whisper)
< / label >
< / div >
{ #if remoteMode === "byok" }
< div class = "field" >
< label for = "byok-key" > Deepgram API Key< / label >
< input
id="byok-key"
type="password"
bind:value={ byokApiKey }
placeholder="Enter your Deepgram API key"
/>
< p style = "font-size: 11px; color: var(--text-muted); margin-top: 4px;" >
Get a key at < a href = "https://console.deepgram.com" target = "_blank" rel = "noopener" style = "color: var(--accent-blue);" > console.deepgram.com< / a >
< / p >
< / div >
{ /if }
{ #if remoteMode === "managed" }
< div class = "managed-auth" >
< div class = "field" >
< label for = "managed-email" > Email< / label >
< input
id="managed-email"
type="email"
bind:value={ managedEmail }
placeholder="email@example.com"
/>
< / div >
< div class = "field" >
< label for = "managed-password" > Password< / label >
< input
id="managed-password"
type="password"
bind:value={ managedPassword }
/>
< / div >
< div class = "auth-buttons" >
< button onclick = { handleManagedLogin } > Login</button >
< / div >
2026-04-10 19:12:50 -07:00
< p style = "font-size: 11px; color: var(--text-muted); margin-top: 8px;" >
Don't have an account? < a href = "https://transcribe.shadowdao.com/register.html" target = "_blank" rel = "noopener" style = "color: var(--accent-blue);" > Sign up here< / a >
< / p >
2026-04-10 11:58:41 -07:00
< / div >
{ /if }
< / section >
{ #if ! isCloudMode }
<!-- Transcription Settings (local Whisper only) -->
2026-04-06 13:42:31 -07:00
< section class = "settings-section" >
< h3 > Transcription Settings< / h3 >
< div class = "field" >
< label for = "model" > Model< / label >
< select id = "model" bind:value = { model } >
{ #each modelOptions as opt }
< option value = { opt } > { opt } </option >
{ /each }
< / select >
< / div >
< div class = "field" >
< label for = "language" > Language< / label >
< input id = "language" type = "text" bind:value = { language } placeholder="en" />
< / div >
< div class = "field" >
< label for = "compute-device" > Compute Device< / label >
< select id = "compute-device" bind:value = { computeDevice } >
{ #each computeDevices as dev }
< option value = { dev . id } > { dev . name } </option >
{ /each }
< / select >
< / div >
< div class = "field" >
< label for = "compute-type" > Compute Type< / label >
< select id = "compute-type" bind:value = { computeType } >
{ #each computeTypeOptions as opt }
< option value = { opt . value } > { opt . label } </option >
{ /each }
< / select >
< / div >
< / section >
<!-- Realtime Preview -->
< section class = "settings-section" >
< h3 > Realtime Preview< / h3 >
< div class = "field-row" >
< label for = "enable-realtime" > Enable Realtime Preview< / label >
< input
id="enable-realtime"
type="checkbox"
bind:checked={ enableRealtime }
/>
< / div >
{ #if enableRealtime }
< div class = "field" >
< label for = "realtime-model" > Realtime Model< / label >
< select id = "realtime-model" bind:value = { realtimeModel } >
{ #each modelOptions as opt }
< option value = { opt } > { opt } </option >
{ /each }
< / select >
< / div >
< div class = "field" >
< label for = "realtime-pause"
>Processing Pause: { realtimeProcessingPause . toFixed ( 2 )} s< /label
>
< input
id="realtime-pause"
type="range"
min="0.01"
max="1.0"
step="0.01"
bind:value={ realtimeProcessingPause }
/>
< / div >
{ /if }
< / section >
<!-- VAD Settings -->
< section class = "settings-section" >
< h3 > VAD Settings< / h3 >
< div class = "field" >
< label for = "silero-sensitivity"
>Silero Sensitivity: { sileroSensitivity . toFixed ( 2 )} < /label
>
< input
id="silero-sensitivity"
type="range"
min="0.0"
max="1.0"
step="0.05"
bind:value={ sileroSensitivity }
/>
< / div >
< div class = "field" >
< label for = "webrtc-sensitivity" > WebRTC Sensitivity< / label >
< select id = "webrtc-sensitivity" bind:value = { webrtcSensitivity } >
{ #each webrtcOptions as opt }
< option value = { opt . value } > { opt . label } </option >
{ /each }
< / select >
< / div >
< / section >
<!-- Timing -->
< section class = "settings-section" >
< h3 > Timing< / h3 >
< div class = "field" >
< label for = "post-speech-silence"
>Post-Speech Silence: { postSpeechSilence . toFixed ( 2 )} s< /label
>
< input
id="post-speech-silence"
type="range"
min="0.1"
max="3.0"
step="0.1"
bind:value={ postSpeechSilence }
/>
< / div >
< div class = "field" >
< label for = "min-recording"
>Min Recording Length: { minRecordingLength . toFixed ( 2 )} s< /label
>
< input
id="min-recording"
type="range"
min="0.1"
max="5.0"
step="0.1"
bind:value={ minRecordingLength }
/>
< / div >
< div class = "field" >
< label for = "min-gap"
>Min Gap Between Recordings: { minGapBetween . toFixed ( 2 )} s< /label
>
< input
id="min-gap"
type="range"
min="0"
max="3.0"
step="0.1"
bind:value={ minGapBetween }
/>
< / div >
< div class = "field-row" >
< label for = "continuous-mode" > Continuous Mode< / label >
< input
id="continuous-mode"
type="checkbox"
bind:checked={ continuousMode }
/>
< / div >
< / section >
2026-04-10 11:58:41 -07:00
{ /if }
2026-04-06 13:42:31 -07:00
<!-- Display Settings -->
< section class = "settings-section" >
< h3 > Display Settings< / h3 >
< div class = "field-row" >
< label for = "show-timestamps" > Show Timestamps< / label >
< input
id="show-timestamps"
type="checkbox"
bind:checked={ showTimestamps }
/>
< / div >
< div class = "field" >
< label for = "fade-seconds"
>Fade After Seconds: { fadeSeconds } (0 = never)< /label
>
< input
id="fade-seconds"
type="range"
min="0"
max="60"
step="1"
bind:value={ fadeSeconds }
/>
< / div >
< div class = "field" >
< label for = "max-lines" > Max Lines: { maxLines } </ label >
< input
id="max-lines"
type="range"
min="10"
max="500"
step="10"
bind:value={ maxLines }
/>
< / div >
2026-04-07 16:40:52 -07:00
< div class = "field" >
< label for = "font-source" > Font Source< / label >
< select id = "font-source" bind:value = { fontSource } >
< option value = "System Font" > System Font< / option >
< option value = "Web-Safe" > Web-Safe< / option >
< option value = "Google Font" > Google Font< / option >
< / select >
< / div >
{ #if fontSource === "System Font" }
< div class = "field" >
< label for = "font-family" > System Font Family< / label >
< input id = "font-family" type = "text" bind:value = { fontFamily } placeholder="Courier" />
< / div >
{ /if }
{ #if fontSource === "Web-Safe" }
< div class = "field" >
< label for = "websafe-font" > Web-Safe Font< / label >
< select id = "websafe-font" bind:value = { websafeFont } >
< option value = "Arial" > Arial< / option >
< option value = "Arial Black" > Arial Black< / option >
< option value = "Comic Sans MS" > Comic Sans MS< / option >
< option value = "Courier New" > Courier New< / option >
< option value = "Georgia" > Georgia< / option >
< option value = "Impact" > Impact< / option >
< option value = "Lucida Console" > Lucida Console< / option >
< option value = "Lucida Sans Unicode" > Lucida Sans Unicode< / option >
< option value = "Palatino Linotype" > Palatino Linotype< / option >
< option value = "Tahoma" > Tahoma< / option >
< option value = "Times New Roman" > Times New Roman< / option >
< option value = "Trebuchet MS" > Trebuchet MS< / option >
< option value = "Verdana" > Verdana< / option >
< / select >
< / div >
{ /if }
{ #if fontSource === "Google Font" }
< div class = "field" >
< label for = "google-font" > Google Font< / label >
< select id = "google-font" bind:value = { googleFont } >
< optgroup label = "Sans Serif" >
< option value = "Roboto" > Roboto< / option >
< option value = "Open Sans" > Open Sans< / option >
< option value = "Lato" > Lato< / option >
< option value = "Montserrat" > Montserrat< / option >
< option value = "Poppins" > Poppins< / option >
< option value = "Nunito" > Nunito< / option >
< option value = "Raleway" > Raleway< / option >
< option value = "Ubuntu" > Ubuntu< / option >
< option value = "Rubik" > Rubik< / option >
< option value = "Work Sans" > Work Sans< / option >
< option value = "Inter" > Inter< / option >
< option value = "Outfit" > Outfit< / option >
< option value = "Quicksand" > Quicksand< / option >
< option value = "Comfortaa" > Comfortaa< / option >
< option value = "Varela Round" > Varela Round< / option >
< / optgroup >
< optgroup label = "Serif" >
< option value = "Playfair Display" > Playfair Display< / option >
< option value = "Merriweather" > Merriweather< / option >
< option value = "Lora" > Lora< / option >
< option value = "PT Serif" > PT Serif< / option >
< option value = "Crimson Text" > Crimson Text< / option >
< / optgroup >
< optgroup label = "Monospace" >
< option value = "Roboto Mono" > Roboto Mono< / option >
< option value = "Source Code Pro" > Source Code Pro< / option >
< option value = "Fira Code" > Fira Code< / option >
< option value = "JetBrains Mono" > JetBrains Mono< / option >
< option value = "IBM Plex Mono" > IBM Plex Mono< / option >
< / optgroup >
< optgroup label = "Display" >
< option value = "Bebas Neue" > Bebas Neue< / option >
< option value = "Oswald" > Oswald< / option >
< option value = "Righteous" > Righteous< / option >
< option value = "Bangers" > Bangers< / option >
< option value = "Permanent Marker" > Permanent Marker< / option >
< / optgroup >
< optgroup label = "Handwriting" >
< option value = "Pacifico" > Pacifico< / option >
< option value = "Lobster" > Lobster< / option >
< option value = "Dancing Script" > Dancing Script< / option >
< option value = "Caveat" > Caveat< / option >
< option value = "Satisfy" > Satisfy< / option >
< / optgroup >
< / select >
< p style = "font-size: 11px; color: var(--text-muted); margin-top: 4px;" >
Browse more at < a href = "https://fonts.google.com" target = "_blank" rel = "noopener" style = "color: var(--accent-blue);" > fonts.google.com< / a >
< / p >
< / div >
{ /if }
2026-04-06 13:42:31 -07:00
< div class = "field" >
< label for = "font-size" > Font Size: { fontSize } px</ label >
< input
id="font-size"
type="range"
min="8"
max="32"
step="1"
bind:value={ fontSize }
/>
< / div >
< / section >
<!-- Color Settings -->
< section class = "settings-section" >
< h3 > Color Settings< / h3 >
< div class = "field-row" >
< label for = "user-color" > User Color< / label >
< input id = "user-color" type = "color" bind:value = { userColor } / >
< / div >
< div class = "field-row" >
< label for = "text-color" > Text Color< / label >
< input id = "text-color" type = "color" bind:value = { textColor } / >
< / div >
< div class = "field-row" >
< label for = "bg-color" > Background Color< / label >
< input id = "bg-color" type = "color" bind:value = { backgroundColor } / >
< / div >
< / section >
2026-04-10 11:58:41 -07:00
<!-- Server Sync (Shared Captions) -->
2026-04-06 13:42:31 -07:00
< section class = "settings-section" >
2026-04-10 11:58:41 -07:00
< h3 > Shared Captions< / h3 >
2026-04-06 13:42:31 -07:00
< div class = "field-row" >
2026-04-10 11:58:41 -07:00
< label for = "sync-enabled" > Enable Shared Captions< / label >
2026-04-06 13:42:31 -07:00
< input
id="sync-enabled"
type="checkbox"
bind:checked={ syncEnabled }
/>
< / div >
{ #if syncEnabled }
2026-04-10 11:58:41 -07:00
< div class = "room-actions" >
2026-04-10 12:26:21 -07:00
< div class = "room-buttons-row" >
< button
onclick={ handleCreateRoom }
disabled={ roomCreating }
class="secondary"
>
{ roomCreating ? "Creating..." : "Create Room" }
< / button >
< button
onclick={ handleShareCurrentRoom }
disabled={ ! syncUrl . trim () || ! syncRoom . trim () || ! syncPassphrase . trim ()}
class="secondary"
>
Share Current Room
< / button >
< / div >
2026-04-10 11:58:41 -07:00
< div class = "join-row" >
< input
type="text"
bind:value={ joinCode }
placeholder="Paste share code to join"
class="join-input"
/>
< button onclick = { handleJoinRoom } disabled= { ! joinCode . trim ()} class = "secondary" >
Join
< / button >
< / div >
< / div >
{ #if roomCreateMessage }
< p class = "room-message" class:error = { roomCreateMessage . startsWith ( "Error" )} > { roomCreateMessage } </p >
{ /if }
{ #if shareCode }
< div class = "share-code-box" >
< label > Share Code< / label >
< div class = "share-code-row" >
< input type = "text" value = { shareCode } readonly class = "share-code-input" />
< button onclick = { copyShareCode } class="secondary" > Copy</ button >
< / div >
< / div >
{ /if }
2026-04-06 13:42:31 -07:00
< div class = "field" >
< label for = "sync-url" > Server URL< / label >
< input
id="sync-url"
type="url"
bind:value={ syncUrl }
2026-04-10 11:58:41 -07:00
placeholder="https://caption.shadowdao.com/api/send"
2026-04-06 13:42:31 -07:00
/>
< / div >
< div class = "field" >
< label for = "sync-room" > Room< / label >
< input id = "sync-room" type = "text" bind:value = { syncRoom } / >
< / div >
< div class = "field" >
< label for = "sync-passphrase" > Passphrase< / label >
< input
id="sync-passphrase"
type="password"
bind:value={ syncPassphrase }
/>
< / div >
{ /if }
< / section >
<!-- Updates -->
< section class = "settings-section" >
< h3 > Updates< / h3 >
< div class = "field-row" >
< label for = "auto-check-updates" > Auto-Check for Updates< / label >
< input
id="auto-check-updates"
type="checkbox"
bind:checked={ autoCheckUpdates }
/>
< / div >
< button onclick = { handleCheckUpdates } > Check Now </ button >
< / section >
2026-04-07 17:02:31 -07:00
<!-- Transcription Engine -->
< section class = "settings-section" >
< h3 > Transcription Engine< / h3 >
< p style = "font-size: 12px; color: var(--text-secondary); margin-bottom: 12px;" >
Switch between local (Whisper) and cloud (Deepgram) transcription engines.
This will stop the current engine, remove the downloaded files, and restart
with the new engine selection.
< / p >
< button class = "danger-btn" onclick = { handleChangeSidecar } > Change Transcription Engine </ button >
< / section >
2026-04-06 13:42:31 -07:00
< / div >
< div class = "settings-footer" >
2026-04-07 06:40:59 -07:00
{ #if saveMessage }
< span class = "save-message" class:error = { saveMessage . startsWith ( "Error" )} > { saveMessage } </span >
{ /if }
< button onclick = { handleCancel } disabled= { saving } > Cancel</ button >
< button class = "primary" onclick = { handleSave } disabled= { saving } >
{ saving ? "Saving..." : "Save" }
< / button >
2026-04-06 13:42:31 -07:00
< / div >
< / div >
< / div >
< style >
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.settings-panel {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 560px;
max-width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.settings-header h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.close-btn:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.settings-section {
margin-bottom: 24px;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-section h3 {
font-size: 14px;
font-weight: 600;
color: var(--accent-blue);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.field {
margin-bottom: 12px;
}
.field label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.field-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.field-row label {
font-size: 13px;
color: var(--text-primary);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
}
.managed-auth {
margin-top: 8px;
padding: 12px;
background-color: var(--bg-secondary);
border-radius: 8px;
}
.auth-buttons {
display: flex;
gap: 8px;
margin-top: 8px;
}
.settings-footer {
display: flex;
2026-04-07 06:40:59 -07:00
align-items: center;
2026-04-06 13:42:31 -07:00
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
2026-04-07 06:40:59 -07:00
.save-message {
margin-right: auto;
font-size: 13px;
color: #4CAF50;
}
.save-message.error {
color: #f44336;
}
2026-04-07 17:02:31 -07:00
2026-04-10 11:58:41 -07:00
.room-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
2026-04-10 12:26:21 -07:00
.room-buttons-row {
display: flex;
gap: 8px;
}
2026-04-10 11:58:41 -07:00
.join-row {
display: flex;
gap: 8px;
}
.join-input {
flex: 1;
}
.room-message {
font-size: 12px;
color: #4CAF50;
margin-bottom: 8px;
}
.room-message.error {
color: #f44336;
}
.share-code-box {
margin-bottom: 12px;
}
.share-code-box label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.share-code-row {
display: flex;
gap: 8px;
}
.share-code-input {
flex: 1;
font-size: 11px;
font-family: monospace;
}
.secondary {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.secondary:hover {
background: var(--bg-tertiary);
}
.secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
2026-04-07 17:02:31 -07:00
.danger-btn {
background: transparent;
border: 1px solid var(--accent-red, #f44336);
color: var(--accent-red, #f44336);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.danger-btn:hover {
background: rgba(244, 67, 54, 0.1);
}
2026-04-06 13:42:31 -07:00
< / style >