fix: address coderabbit comments (loading state + keyboard access)

- LaunchWindow: expose isLoading/error from useCameraDevices; show
  'Searching...' only while enumeration is in flight, 'Camera unavailable'
  on error, 'No camera found' when list is empty (fixes perpetual loading state)
- LaunchWindow: keep <select> always mounted (sr-only when collapsed) and
  expand panel on focus as well as hover; fixes keyboard inaccessibility for
  both mic and webcam selectors
- i18n: add webcam.noneFound and webcam.unavailable to en/es/zh-CN locales
This commit is contained in:
Etienne Lescot
2026-03-27 14:53:41 +01:00
parent eade28079d
commit 9762448929
4 changed files with 102 additions and 62 deletions
+93 -59
View File
@@ -97,7 +97,12 @@ export function LaunchWindow() {
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,
@@ -108,6 +113,8 @@ export function LaunchWindow() {
devices: cameraDevices,
selectedDeviceId: selectedCameraId,
setSelectedDeviceId: setSelectedCameraId,
isLoading: isCameraDevicesLoading,
error: cameraDevicesError,
} = useCameraDevices(webcamEnabled);
const selectedMicLabel =
@@ -257,46 +264,43 @@ export function LaunchWindow() {
{/* 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 ${!isMicHovered ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
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)}
style={{ width: isMicHovered ? "240px" : "140px", transition: "width 300ms ease" }}
onFocus={() => setIsMicFocused(true)}
onBlur={() => setIsMicFocused(false)}
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!isMicHovered ? (
{!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"
>
{micDevices.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"
/>
</>
)}
<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={`${isMicHovered ? "w-16" : "w-8"} h-2 transition-all duration-300`}
className={`${micExpanded ? "w-16" : "w-8"} h-2 transition-all duration-300`}
/>
</div>
)}
@@ -304,43 +308,73 @@ export function LaunchWindow() {
{/* 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 ${!isWebcamHovered ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
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)}
style={{ width: isWebcamHovered ? "240px" : "140px", transition: "width 300ms ease" }}
onFocus={() => setIsWebcamFocused(true)}
onBlur={() => setIsWebcamFocused(false)}
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!isWebcamHovered ? (
{!webcamExpanded && (
<div className="text-white/60 text-[10px] font-medium truncate">
{selectedCameraLabel}
</div>
) : cameraDevices.length > 0 ? (
<>
<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"
/>
</>
) : (
<span className="text-white/40 text-[10px] italic">{t("webcam.searching")}</span>
)}
{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 && (
<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>
+3 -1
View File
@@ -17,7 +17,9 @@
"enableWebcam": "Enable webcam",
"disableWebcam": "Disable webcam",
"defaultCamera": "Default Camera",
"searching": "Searching..."
"searching": "Searching...",
"noneFound": "No camera found",
"unavailable": "Camera unavailable"
},
"sourceSelector": {
"loading": "Loading sources...",
+3 -1
View File
@@ -17,7 +17,9 @@
"enableWebcam": "Activar cámara web",
"disableWebcam": "Desactivar cámara web",
"defaultCamera": "Cámara predeterminada",
"searching": "Buscando..."
"searching": "Buscando...",
"noneFound": "No se encontró cámara",
"unavailable": "Cámara no disponible"
},
"sourceSelector": {
"loading": "Cargando fuentes...",
+3 -1
View File
@@ -17,7 +17,9 @@
"enableWebcam": "启用摄像头",
"disableWebcam": "禁用摄像头",
"defaultCamera": "默认摄像头",
"searching": "正在搜索..."
"searching": "正在搜索...",
"noneFound": "未找到摄像头",
"unavailable": "摄像头不可用"
},
"sourceSelector": {
"loading": "正在加载源...",