fix: address PR review comments

- useCameraDevices: remove selectedDeviceId from useEffect deps (use ref instead)
- useCameraDevices: fall back to first available device when selected device is unplugged
- i18n: add missing keys (audio.defaultMicrophone, webcam.defaultCamera, webcam.searching) to en/es/zh-CN
- LaunchWindow: replace hardcoded strings with t() i18n calls
- tests: add afterEach(vi.resetAllMocks()), improve permission test assertions, add stale device fallback test
This commit is contained in:
Etienne Lescot
2026-03-27 14:39:19 +01:00
parent fed5a44b5a
commit eade28079d
6 changed files with 71 additions and 19 deletions
+3 -3
View File
@@ -112,10 +112,10 @@ export function LaunchWindow() {
const selectedMicLabel =
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
"Default Microphone";
t("audio.defaultMicrophone");
const selectedCameraLabel =
cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
"Default Camera";
t("webcam.defaultCamera");
const { level } = useAudioLevelMeter({
enabled: showMicControls,
@@ -340,7 +340,7 @@ export function LaunchWindow() {
/>
</>
) : (
<span className="text-white/40 text-[10px] italic">Searching...</span>
<span className="text-white/40 text-[10px] italic">{t("webcam.searching")}</span>
)}
</div>
</div>
+45 -6
View File
@@ -1,4 +1,4 @@
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCameraDevices } from "./useCameraDevices";
@@ -28,6 +28,11 @@ Object.defineProperty(global.navigator, "mediaDevices", {
describe("useCameraDevices", () => {
beforeEach(() => {
vi.clearAllMocks();
mockEnumerateDevices.mockResolvedValue(mockDevices);
});
afterEach(() => {
vi.resetAllMocks();
});
it("should list video input devices", async () => {
@@ -49,15 +54,49 @@ describe("useCameraDevices", () => {
});
});
it("should request permission if labels are empty", async () => {
mockEnumerateDevices.mockResolvedValueOnce([
{ kind: "videoinput", deviceId: "cam1", label: "", groupId: "group1" },
]);
it("should request permission if labels are empty and populate devices after", async () => {
mockEnumerateDevices
.mockResolvedValueOnce([
{ kind: "videoinput", deviceId: "cam1", label: "", groupId: "group1" },
])
.mockResolvedValueOnce(mockDevices);
renderHook(() => useCameraDevices(true));
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(mockGetUserMedia).toHaveBeenCalledWith({ video: true });
});
await waitFor(() => {
expect(result.current.devices[0]?.label).toBe("Camera 1");
});
});
it("should fall back to first available device when selected device is unplugged", async () => {
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam1");
});
// Simulate cam1 being unplugged — only cam2 remains
// loadDevices calls enumerateDevices twice, mock both to return only cam2
const cam2Only = [
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
];
mockEnumerateDevices.mockResolvedValueOnce(cam2Only).mockResolvedValueOnce(cam2Only);
// Trigger devicechange event via the registered handler
const devicechangeHandler = (
navigator.mediaDevices.addEventListener as ReturnType<typeof vi.fn>
).mock.calls[0]?.[1] as (() => void) | undefined;
await act(async () => {
devicechangeHandler?.();
});
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam2");
});
});
});
+8 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
export interface CameraDevice {
deviceId: string;
@@ -11,6 +11,8 @@ export function useCameraDevices(enabled: boolean = false) {
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedDeviceIdRef = useRef(selectedDeviceId);
selectedDeviceIdRef.current = selectedDeviceId;
useEffect(() => {
let mounted = true;
@@ -44,8 +46,10 @@ export function useCameraDevices(enabled: boolean = false) {
if (mounted) {
setDevices(videoInputs);
if (!selectedDeviceId && videoInputs.length > 0) {
setSelectedDeviceId(videoInputs[0].deviceId);
const currentId = selectedDeviceIdRef.current;
const stillAvailable = videoInputs.some((d) => d.deviceId === currentId);
if (!currentId || !stillAvailable) {
setSelectedDeviceId(videoInputs[0]?.deviceId ?? "");
}
setIsLoading(false);
}
@@ -64,7 +68,7 @@ export function useCameraDevices(enabled: boolean = false) {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", loadDevices);
};
}, [enabled, selectedDeviceId]);
}, [enabled]);
return { devices, selectedDeviceId, setSelectedDeviceId, isLoading, error };
}
+5 -2
View File
@@ -10,11 +10,14 @@
"enableSystemAudio": "Enable system audio",
"disableSystemAudio": "Disable system audio",
"enableMicrophone": "Enable microphone",
"disableMicrophone": "Disable microphone"
"disableMicrophone": "Disable microphone",
"defaultMicrophone": "Default Microphone"
},
"webcam": {
"enableWebcam": "Enable webcam",
"disableWebcam": "Disable webcam"
"disableWebcam": "Disable webcam",
"defaultCamera": "Default Camera",
"searching": "Searching..."
},
"sourceSelector": {
"loading": "Loading sources...",
+5 -2
View File
@@ -10,11 +10,14 @@
"enableSystemAudio": "Activar audio del sistema",
"disableSystemAudio": "Desactivar audio del sistema",
"enableMicrophone": "Activar micrófono",
"disableMicrophone": "Desactivar micrófono"
"disableMicrophone": "Desactivar micrófono",
"defaultMicrophone": "Micrófono predeterminado"
},
"webcam": {
"enableWebcam": "Activar cámara web",
"disableWebcam": "Desactivar cámara web"
"disableWebcam": "Desactivar cámara web",
"defaultCamera": "Cámara predeterminada",
"searching": "Buscando..."
},
"sourceSelector": {
"loading": "Cargando fuentes...",
+5 -2
View File
@@ -10,11 +10,14 @@
"enableSystemAudio": "启用系统音频",
"disableSystemAudio": "禁用系统音频",
"enableMicrophone": "启用麦克风",
"disableMicrophone": "禁用麦克风"
"disableMicrophone": "禁用麦克风",
"defaultMicrophone": "默认麦克风"
},
"webcam": {
"enableWebcam": "启用摄像头",
"disableWebcam": "禁用摄像头"
"disableWebcam": "禁用摄像头",
"defaultCamera": "默认摄像头",
"searching": "正在搜索..."
},
"sourceSelector": {
"loading": "正在加载源...",