mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user