From 54944608340f0d5dfedc610676bb49d713bb5442 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:33:14 +0200 Subject: [PATCH] feat(video-section): scaffold component with permission-probe state machine --- .../modals/settingsPanels/VideoSection.tsx | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packages/web/src/components/modals/settingsPanels/VideoSection.tsx diff --git a/packages/web/src/components/modals/settingsPanels/VideoSection.tsx b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx new file mode 100644 index 00000000..1436c03f --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react'; +import { isElectron, getElectronAPI } from '../../../platform/platform'; + +type PermissionState = 'unknown' | 'probing' | 'granted' | 'initial-denied' | 'hard-blocked'; + +function errorName(err: unknown): string { + return err instanceof Error ? err.name : ''; +} + +function getHardBlockedCopy(): string { + if (!isElectron()) { + return "Camera blocked at browser level. Click the camera icon in your browser's address bar, reset the permission, then click Try again."; + } + const platform = getElectronAPI()?.platform; + if (platform === 'darwin') { + return 'Grant camera access in System Settings → Privacy & Security → Camera, then restart the app.'; + } + if (platform === 'win32') { + return 'Grant camera access in Settings → Privacy & Security → Camera, then restart the app.'; + } + // linux or unknown + return 'Camera permission was denied. Reset it in your browser/Chromium prompt and try again.'; +} + +export function VideoSection() { + const [permState, setPermState] = useState('unknown'); + // Tracks mount state so async probe handlers don't write to state after unmount. + const mountedRef = useRef(true); + + // Run the probe once on mount. + useEffect(() => { + mountedRef.current = true; + const probe = async () => { + setPermState('probing'); + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + // Stop immediately — we only needed to unlock labels and verify access. + stream.getTracks().forEach((t) => t.stop()); + if (mountedRef.current) setPermState('granted'); + } catch (err) { + if (!mountedRef.current) return; + if (errorName(err) === 'NotAllowedError') setPermState('initial-denied'); + else setPermState('granted'); // NotFoundError / NotReadableError — still allow dropdown render + } + }; + probe(); + return () => { + mountedRef.current = false; + }; + }, []); + + const handleTryAgain = async () => { + setPermState('probing'); + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + stream.getTracks().forEach((t) => t.stop()); + if (mountedRef.current) setPermState('granted'); + } catch (err) { + if (!mountedRef.current) return; + // Second denial → escalate to hard-blocked. + if (errorName(err) === 'NotAllowedError') setPermState('hard-blocked'); + else setPermState('initial-denied'); + } + }; + + if (permState === 'unknown' || permState === 'probing') { + return ( +
+
+ Video +
+
+ Checking camera access… +
+
+ ); + } + + if (permState === 'initial-denied' || permState === 'hard-blocked') { + return ( +
+
+ Video +
+
+
⚠ Camera access denied
+
+ {permState === 'hard-blocked' + ? getHardBlockedCopy() + : 'Grant camera permission to choose a camera.'} +
+ +
+
+ ); + } + + // permState === 'granted' — main UI added in T9–T12. + return ( +
+
+ Video +
+
+ (UI under construction — Task 9 adds dropdown, T10–T11 add preview, T12 adds lifecycle.) +
+
+ ); +}