feat(video-section): scaffold component with permission-probe state machine
This commit is contained in:
@@ -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<PermissionState>('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 (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
|
Video
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 text-sm text-txt-tertiary">
|
||||||
|
Checking camera access…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permState === 'initial-denied' || permState === 'hard-blocked') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
|
Video
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-2">
|
||||||
|
<div className="text-sm text-txt-primary">⚠ Camera access denied</div>
|
||||||
|
<div className="text-xs text-txt-tertiary">
|
||||||
|
{permState === 'hard-blocked'
|
||||||
|
? getHardBlockedCopy()
|
||||||
|
: 'Grant camera permission to choose a camera.'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleTryAgain}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// permState === 'granted' — main UI added in T9–T12.
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
|
Video
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 text-sm text-txt-tertiary italic">
|
||||||
|
(UI under construction — Task 9 adds dropdown, T10–T11 add preview, T12 adds lifecycle.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user