Merge branch 'siddharthvaddem:main' into main
This commit is contained in:
@@ -21,6 +21,7 @@ import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { isMac as getIsMac } from "@/utils/platformUtils";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
@@ -86,23 +87,64 @@ export function LaunchWindow() {
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
||||
useMicrophoneDevices(microphoneEnabled);
|
||||
const showWebcamControls = webcamEnabled && !recording;
|
||||
|
||||
const [isMicHovered, setIsMicHovered] = useState(false);
|
||||
const [isMicFocused, setIsMicFocused] = useState(false);
|
||||
const micExpanded = isMicHovered || isMicFocused;
|
||||
|
||||
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
|
||||
const {
|
||||
devices: micDevices,
|
||||
selectedDeviceId: selectedMicId,
|
||||
setSelectedDeviceId: setSelectedMicId,
|
||||
} = useMicrophoneDevices(microphoneEnabled);
|
||||
const {
|
||||
devices: cameraDevices,
|
||||
selectedDeviceId: selectedCameraId,
|
||||
setSelectedDeviceId: setSelectedCameraId,
|
||||
isLoading: isCameraDevicesLoading,
|
||||
error: cameraDevicesError,
|
||||
} = useCameraDevices(webcamEnabled);
|
||||
|
||||
const selectedMicLabel =
|
||||
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
|
||||
t("audio.defaultMicrophone");
|
||||
const selectedCameraLabel = isCameraDevicesLoading
|
||||
? t("webcam.searching")
|
||||
: cameraDevicesError
|
||||
? t("webcam.unavailable")
|
||||
: cameraDevices.length === 0
|
||||
? t("webcam.noneFound")
|
||||
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
|
||||
t("webcam.defaultCamera");
|
||||
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
if (selectedMicId && selectedMicId !== "default") {
|
||||
setMicrophoneDeviceId(selectedMicId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
}, [selectedMicId, setMicrophoneDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCameraId) {
|
||||
setWebcamDeviceId(selectedCameraId);
|
||||
}
|
||||
}, [selectedCameraId, setWebcamDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
@@ -199,10 +241,10 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent relative">
|
||||
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
|
||||
{/* Language switcher — top-left, beside traffic lights */}
|
||||
<div
|
||||
className={`absolute top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
|
||||
className={`fixed top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
@@ -219,165 +261,260 @@ export function LaunchWindow() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{/* Mic controls panel */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)] rounded-2xl shadow-mic-panel animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
>
|
||||
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
setSelectedDeviceId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
}}
|
||||
className="w-full appearance-none bg-white/10 text-white text-xs rounded-full pl-3 pr-7 py-2 border border-white/20 outline-none truncate"
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
|
||||
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{(showMicControls || showWebcamControls) && (
|
||||
<div
|
||||
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
>
|
||||
{/* Mic selector */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
onMouseEnter={() => setIsMicHovered(true)}
|
||||
onMouseLeave={() => setIsMicHovered(false)}
|
||||
onFocus={() => setIsMicFocused(true)}
|
||||
onBlur={() => setIsMicFocused(false)}
|
||||
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{!micExpanded && (
|
||||
<div className="text-white/60 text-[10px] font-medium truncate">
|
||||
{selectedMicLabel}
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
value={microphoneDeviceId || selectedMicId}
|
||||
onChange={(e) => {
|
||||
setSelectedMicId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
}}
|
||||
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
|
||||
>
|
||||
{micDevices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId} className="bg-[#1c1c24]">
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{micExpanded && (
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AudioLevelMeter
|
||||
level={level}
|
||||
className={`${micExpanded ? "w-16" : "w-8"} h-2 transition-all duration-300`}
|
||||
/>
|
||||
</div>
|
||||
<AudioLevelMeter level={level} className="w-24 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main pill bar */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 isolate rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]">
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={
|
||||
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
|
||||
}
|
||||
>
|
||||
{systemAudioEnabled
|
||||
? getIcon("volumeOn", "text-green-400")
|
||||
: getIcon("volumeOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
|
||||
>
|
||||
{microphoneEnabled
|
||||
? getIcon("micOn", "text-green-400")
|
||||
: getIcon("micOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
|
||||
>
|
||||
{webcamEnabled
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
||||
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
|
||||
}`}
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", "text-red-400")}
|
||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||
{formatTimePadded(elapsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
{/* Webcam selector */}
|
||||
{showWebcamControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
onMouseEnter={() => setIsWebcamHovered(true)}
|
||||
onMouseLeave={() => setIsWebcamHovered(false)}
|
||||
onFocus={() => setIsWebcamFocused(true)}
|
||||
onBlur={() => setIsWebcamFocused(false)}
|
||||
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{!webcamExpanded && (
|
||||
<div className="text-white/60 text-[10px] font-medium truncate">
|
||||
{selectedCameraLabel}
|
||||
</div>
|
||||
)}
|
||||
{webcamExpanded &&
|
||||
(isCameraDevicesLoading ? (
|
||||
<span className="text-white/40 text-[10px] italic">
|
||||
{t("webcam.searching")}
|
||||
</span>
|
||||
) : cameraDevicesError ? (
|
||||
<span className="text-white/40 text-[10px] italic">
|
||||
{t("webcam.unavailable")}
|
||||
</span>
|
||||
) : cameraDevices.length === 0 ? (
|
||||
<span className="text-white/40 text-[10px] italic">
|
||||
{t("webcam.noneFound")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={webcamDeviceId || selectedCameraId}
|
||||
onChange={(e) => {
|
||||
setSelectedCameraId(e.target.value);
|
||||
setWebcamDeviceId(e.target.value);
|
||||
}}
|
||||
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{cameraDevices.map((device) => (
|
||||
<option
|
||||
key={device.deviceId}
|
||||
value={device.deviceId}
|
||||
className="bg-[#1c1c24]"
|
||||
>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{(!webcamExpanded || cameraDevices.length === 0) && (
|
||||
<select
|
||||
value={webcamDeviceId || selectedCameraId}
|
||||
onChange={(e) => {
|
||||
setSelectedCameraId(e.target.value);
|
||||
setWebcamDeviceId(e.target.value);
|
||||
}}
|
||||
className="sr-only"
|
||||
>
|
||||
{cameraDevices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
|
||||
<div
|
||||
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={
|
||||
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
|
||||
}
|
||||
>
|
||||
{systemAudioEnabled
|
||||
? getIcon("volumeOn", "text-green-400")
|
||||
: getIcon("volumeOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
|
||||
>
|
||||
{microphoneEnabled
|
||||
? getIcon("micOn", "text-green-400")
|
||||
: getIcon("micOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
|
||||
>
|
||||
{webcamEnabled
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
||||
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
|
||||
}`}
|
||||
onClick={toggleRecording}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", "text-red-400")}
|
||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||
{formatTimePadded(elapsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCameraDevices } from "./useCameraDevices";
|
||||
|
||||
// Mock navigator.mediaDevices
|
||||
const mockDevices = [
|
||||
{ kind: "videoinput", deviceId: "cam1", label: "Camera 1", groupId: "group1" },
|
||||
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
|
||||
{ kind: "audioinput", deviceId: "mic1", label: "Mic 1", groupId: "group2" },
|
||||
];
|
||||
|
||||
const mockGetUserMedia = vi.fn().mockResolvedValue({
|
||||
getTracks: () => [{ stop: vi.fn() }],
|
||||
});
|
||||
|
||||
const mockEnumerateDevices = vi.fn().mockResolvedValue(mockDevices);
|
||||
|
||||
Object.defineProperty(global.navigator, "mediaDevices", {
|
||||
value: {
|
||||
enumerateDevices: mockEnumerateDevices,
|
||||
getUserMedia: mockGetUserMedia,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
describe("useCameraDevices", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnumerateDevices.mockResolvedValue(mockDevices);
|
||||
mockGetUserMedia.mockResolvedValue({
|
||||
getTracks: () => [{ stop: vi.fn() }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should list video input devices", async () => {
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.devices).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current.devices[0].label).toBe("Camera 1");
|
||||
expect(result.current.devices[1].deviceId).toBe("cam2");
|
||||
});
|
||||
|
||||
it("should set first device as default", async () => {
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedDeviceId).toBe("cam1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should use device ID as fallback label when label is missing", async () => {
|
||||
mockEnumerateDevices.mockResolvedValueOnce([
|
||||
{ kind: "videoinput", deviceId: "cam1abc123456", label: "", groupId: "group1" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.devices[0]?.label).toBe("Camera cam1abc1");
|
||||
});
|
||||
|
||||
expect(mockGetUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set error state when enumeration fails", async () => {
|
||||
mockEnumerateDevices.mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("Permission denied");
|
||||
});
|
||||
|
||||
expect(result.current.devices).toHaveLength(0);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
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
|
||||
const cam2Only = [
|
||||
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
|
||||
];
|
||||
mockEnumerateDevices.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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface CameraDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export function useCameraDevices(enabled: boolean = false) {
|
||||
const [devices, setDevices] = useState<CameraDevice[]>([]);
|
||||
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(() => {
|
||||
if (!enabled) return;
|
||||
let mounted = true;
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Enumerate without requesting a second stream — the recorder handles
|
||||
// the real acquisition; unlabeled devices fall back to their device ID.
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoInputs = allDevices
|
||||
.filter((device) => device.kind === "videoinput")
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Camera ${device.deviceId.slice(0, 8)}`,
|
||||
groupId: device.groupId,
|
||||
}));
|
||||
|
||||
if (mounted) {
|
||||
setDevices(videoInputs);
|
||||
const currentId = selectedDeviceIdRef.current;
|
||||
const stillAvailable = videoInputs.some((d) => d.deviceId === currentId);
|
||||
if (!currentId || !stillAvailable) {
|
||||
setSelectedDeviceId(videoInputs[0]?.deviceId ?? "");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load cameras");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadDevices();
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", loadDevices);
|
||||
return () => {
|
||||
mounted = false;
|
||||
navigator.mediaDevices.removeEventListener("devicechange", loadDevices);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { devices, selectedDeviceId, setSelectedDeviceId, isLoading, error };
|
||||
}
|
||||
@@ -47,6 +47,8 @@ type UseScreenRecorderReturn = {
|
||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||
microphoneDeviceId: string | undefined;
|
||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||
webcamDeviceId: string | undefined;
|
||||
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
||||
systemAudioEnabled: boolean;
|
||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||
webcamEnabled: boolean;
|
||||
@@ -85,6 +87,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||
@@ -409,11 +412,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
try {
|
||||
webcamStream.current = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
},
|
||||
video: webcamDeviceId
|
||||
? {
|
||||
deviceId: { exact: webcamDeviceId },
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
}
|
||||
: {
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
},
|
||||
});
|
||||
} catch (cameraError) {
|
||||
console.warn("Failed to get webcam access:", cameraError);
|
||||
@@ -563,6 +573,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
|
||||
const stopPromises = [
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -598,6 +609,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
"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...",
|
||||
"noneFound": "No camera found",
|
||||
"unavailable": "Camera unavailable"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Loading sources...",
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
"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...",
|
||||
"noneFound": "No se encontró cámara",
|
||||
"unavailable": "Cámara no disponible"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Cargando fuentes...",
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
"enableSystemAudio": "启用系统音频",
|
||||
"disableSystemAudio": "禁用系统音频",
|
||||
"enableMicrophone": "启用麦克风",
|
||||
"disableMicrophone": "禁用麦克风"
|
||||
"disableMicrophone": "禁用麦克风",
|
||||
"defaultMicrophone": "默认麦克风"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "启用摄像头",
|
||||
"disableWebcam": "禁用摄像头"
|
||||
"disableWebcam": "禁用摄像头",
|
||||
"defaultCamera": "默认摄像头",
|
||||
"searching": "正在搜索...",
|
||||
"noneFound": "未找到摄像头",
|
||||
"unavailable": "摄像头不可用"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在加载源...",
|
||||
|
||||
Reference in New Issue
Block a user