fix: settings bugs, accessibility, and camera stream leak

- KeybindsTab: fix stale savedVk closure — currentVk now updates on
  successful capture and clear, so timeout/error reverts correctly
- AccountTab: add 1–32 char username length validation
- VoiceAudioTab: fix orphaned camera previewStream on repeated tab
  visits — consolidated to startCameraPreview/stopCameraPreview
  helpers with registrar stopping old tracks before new ones
- VoiceAudioTab: camera preview errors now shown to user instead
  of silently swallowed; error cleared on retry
- All toggles: new createToggle() helper in helpers.ts with
  role="switch", tabindex="0", aria-checked, Enter/Space support
- Applied createToggle to AppearanceTab, NotificationsTab,
  VoiceAudioTab (5 toggle instances)
This commit is contained in:
jevb
2026-03-20 02:21:53 +01:00
parent 90b6244d2a
commit 98f60cc78e
8 changed files with 121 additions and 80 deletions
@@ -12,7 +12,6 @@ use windows::Win32::Security::Credentials::{
pub struct CredentialData {
pub username: String,
pub token: String,
/// Optional saved password (only present when user opted in).
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
@@ -47,17 +47,21 @@ export function buildAccountTab(
const usernameError = createElement("div", { style: "color:var(--red);font-size:13px;margin-top:4px" });
editForm.appendChild(usernameError);
const MAX_USERNAME_LEN = 32;
saveBtn.addEventListener("click", () => {
const newName = editInput.value.trim();
if (newName.length > 0) {
setText(usernameError, "");
void options.onUpdateProfile(newName).then(() => {
setText(usernameValue, newName);
editForm.style.display = "none";
}).catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
if (newName.length === 0 || newName.length > MAX_USERNAME_LEN) {
setText(usernameError, `Username must be 1\u2013${MAX_USERNAME_LEN} characters.`);
return;
}
setText(usernameError, "");
void options.onUpdateProfile(newName).then(() => {
setText(usernameValue, newName);
editForm.style.display = "none";
}).catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
}, { signal });
section.appendChild(editForm);
@@ -3,7 +3,7 @@
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref, applyTheme, THEMES } from "./helpers";
import { loadPref, savePref, applyTheme, THEMES, createToggle } from "./helpers";
import type { ThemeName } from "./helpers";
import { setTheme } from "@stores/ui.store";
@@ -57,15 +57,13 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
// Compact mode toggle
const compactRow = createElement("div", { class: "setting-row" });
const compactLabel = createElement("span", { class: "setting-label" }, "Compact Mode");
const compactToggle = createElement("div", {
class: currentCompact ? "toggle on" : "toggle",
const compactToggle = createToggle(currentCompact, {
signal,
onChange: (isNowCompact) => {
savePref("compactMode", isNowCompact);
document.documentElement.classList.toggle("compact-mode", isNowCompact);
},
});
compactToggle.addEventListener("click", () => {
const isNowCompact = !compactToggle.classList.contains("on");
compactToggle.classList.toggle("on", isNowCompact);
savePref("compactMode", isNowCompact);
document.documentElement.classList.toggle("compact-mode", isNowCompact);
}, { signal });
appendChildren(compactRow, compactLabel, compactToggle);
section.appendChild(compactRow);
@@ -15,15 +15,15 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
// ── Push to Talk ──────────────────────────────────────────
const pttRow = createElement("div", { class: "keybind-row" });
const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk");
const savedVk = loadPref<number>("pttVk", 0);
let currentVk = loadPref<number>("pttVk", 0);
const pttValue = createElement("span", {
class: "kbd",
style: "cursor: pointer; min-width: 80px; text-align: center;",
title: "Click to set keybind",
}, savedVk !== 0 ? vkName(savedVk) : "Not set");
}, currentVk !== 0 ? vkName(currentVk) : "Not set");
const pttClear = createElement("button", {
class: "ac-btn",
style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${savedVk !== 0 ? "" : "display: none;"}`,
style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${currentVk !== 0 ? "" : "display: none;"}`,
}, "Clear");
let capturing = false;
@@ -43,9 +43,10 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
pttValue.style.color = "";
if (vk === 0) {
// Timed out — restore previous value
setText(pttValue, savedVk !== 0 ? vkName(savedVk) : "Not set");
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
return;
}
currentVk = vk;
setText(pttValue, vkName(vk));
pttClear.style.display = "";
void updatePttKey(vk);
@@ -54,12 +55,13 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
setText(pttValue, savedVk !== 0 ? vkName(savedVk) : "Not set");
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
});
}, { signal });
pttClear.addEventListener("click", (e) => {
e.stopPropagation();
currentVk = 0;
setText(pttValue, "Not set");
pttClear.style.display = "none";
void updatePttKey(0);
@@ -3,7 +3,7 @@
*/
import { createElement, appendChildren } from "@lib/dom";
import { loadPref, savePref } from "./helpers";
import { loadPref, savePref, createToggle } from "./helpers";
export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
@@ -25,12 +25,10 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
toggle.addEventListener("click", () => {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
savePref(item.key, nowOn);
}, { signal });
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
});
appendChildren(row, info, toggle);
section.appendChild(row);
@@ -3,7 +3,7 @@
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref } from "./helpers";
import { loadPref, savePref, createToggle } from "./helpers";
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, updateSilenceSuppressionPref } from "@lib/voiceSession";
import { sensitivityToThreshold } from "@lib/vad";
@@ -40,6 +40,10 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
micAudioCtx = ctx;
micAnimFrame = frame;
}, (stream) => {
// Stop old camera tracks before registering new stream
if (cameraPreviewStream !== null && cameraPreviewStream !== stream) {
for (const track of cameraPreviewStream.getTracks()) track.stop();
}
cameraPreviewStream = stream;
});
}
@@ -109,8 +113,6 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
previewWrap.appendChild(previewVideo);
section.appendChild(previewWrap);
let previewStream: MediaStream | null = null;
// Populate devices asynchronously
void (async () => {
try {
@@ -159,50 +161,52 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
void switchOutputDevice(outputSelect.value);
}, { signal });
videoSelect.addEventListener("change", () => {
savePref("videoInputDevice", videoSelect.value);
if (previewStream !== null) {
for (const track of previewStream.getTracks()) track.stop();
previewStream = null;
}
function stopCameraPreview(): void {
registerCamera(null);
previewVideo.srcObject = null;
const selectedDevice = videoSelect.value;
}
let previewErrorEl: HTMLDivElement | null = null;
function clearPreviewError(): void {
if (previewErrorEl !== null) {
previewErrorEl.remove();
previewErrorEl = null;
}
}
function startCameraPreview(deviceId: string): void {
stopCameraPreview();
clearPreviewError();
void (async () => {
try {
const constraints: MediaStreamConstraints = {
video: selectedDevice
? { deviceId: { exact: selectedDevice }, width: { ideal: 320 }, height: { ideal: 180 } }
video: deviceId
? { deviceId: { exact: deviceId }, width: { ideal: 320 }, height: { ideal: 180 } }
: { width: { ideal: 320 }, height: { ideal: 180 } },
audio: false,
};
previewStream = await navigator.mediaDevices.getUserMedia(constraints);
registerCamera(previewStream);
previewVideo.srcObject = previewStream;
} catch { /* Camera unavailable */ }
const stream = await navigator.mediaDevices.getUserMedia(constraints);
registerCamera(stream);
previewVideo.srcObject = stream;
} catch (err) {
const msg = err instanceof Error ? err.message : "Camera unavailable";
previewErrorEl = createElement("div", { class: "setting-desc" }, msg) as HTMLDivElement;
previewWrap.appendChild(previewErrorEl);
}
})();
}
videoSelect.addEventListener("change", () => {
savePref("videoInputDevice", videoSelect.value);
startCameraPreview(videoSelect.value);
}, { signal });
// Start initial camera preview
void (async () => {
try {
const savedDevice = loadPref<string>("videoInputDevice", "");
const constraints: MediaStreamConstraints = {
video: savedDevice ? { deviceId: { exact: savedDevice }, width: { ideal: 320 }, height: { ideal: 180 } } : { width: { ideal: 320 }, height: { ideal: 180 } },
audio: false,
};
previewStream = await navigator.mediaDevices.getUserMedia(constraints);
registerCamera(previewStream);
previewVideo.srcObject = previewStream;
} catch { /* Camera unavailable */ }
})();
startCameraPreview(loadPref<string>("videoInputDevice", ""));
signal.addEventListener("abort", () => {
if (previewStream !== null) {
for (const track of previewStream.getTracks()) track.stop();
previewStream = null;
registerCamera(null);
}
previewVideo.srcObject = null;
stopCameraPreview();
});
// ── Mic level meter + sensitivity slider ──────────────────────────
@@ -317,20 +321,18 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
toggle.addEventListener("click", () => {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
savePref(item.key, nowOn);
if (item.key === "silenceSuppression") {
// Silence suppression takes effect on next VAD tick — no device switch needed
updateSilenceSuppressionPref();
} else {
// Re-acquire mic with new constraints if in an active voice session
const currentDevice = loadPref<string>("audioInputDevice", "");
void switchInputDevice(currentDevice);
}
}, { signal });
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => {
savePref(item.key, nowOn);
if (item.key === "silenceSuppression") {
updateSilenceSuppressionPref();
} else {
const currentDevice = loadPref<string>("audioInputDevice", "");
void switchInputDevice(currentDevice);
}
},
});
appendChildren(row, info, toggle);
section.appendChild(row);
@@ -2,6 +2,8 @@
* Shared helpers and constants for settings tabs.
*/
import { createElement } from "@lib/dom";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -33,6 +35,43 @@ export function savePref(key: string, value: unknown): void {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
}
// ---------------------------------------------------------------------------
// Accessible toggle creation
// ---------------------------------------------------------------------------
/**
* Create an accessible toggle switch element with proper ARIA attributes
* and keyboard support (Enter/Space to toggle).
*/
export function createToggle(
isOn: boolean,
opts: { signal: AbortSignal; onChange: (nowOn: boolean) => void },
): HTMLDivElement {
const toggle = createElement("div", {
class: isOn ? "toggle on" : "toggle",
role: "switch",
tabindex: "0",
"aria-checked": isOn ? "true" : "false",
});
function doToggle(): void {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
toggle.setAttribute("aria-checked", String(nowOn));
opts.onChange(nowOn);
}
toggle.addEventListener("click", doToggle, { signal: opts.signal });
toggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
doToggle();
}
}, { signal: opts.signal });
return toggle;
}
// ---------------------------------------------------------------------------
// Theme application
// ---------------------------------------------------------------------------
+1 -2
View File
@@ -65,12 +65,11 @@ export async function loadCredential(
if (result && typeof result === "object") {
const cred = result as Record<string, unknown>;
if (typeof cred.username === "string" && typeof cred.token === "string") {
const saved: SavedCredential = {
return {
username: cred.username,
token: cred.token,
...(typeof cred.password === "string" ? { password: cred.password } : {}),
};
return saved;
}
}
return null;