diff --git a/src/components/room/camera.tsx b/src/components/room/camera.tsx index eecf7d9..4377cba 100644 --- a/src/components/room/camera.tsx +++ b/src/components/room/camera.tsx @@ -1,4 +1,4 @@ -import { Video, VideoOff } from 'lucide-react'; +import { Loader2, Video, VideoOff } from 'lucide-react'; import MediaDeviceDropdown from './media-device-dropdown'; import { @@ -10,7 +10,7 @@ import { useMedia } from '@/hooks/use-media'; import MediaControlButton from './media-control-button'; const Camera = () => { - const { toggleCamera } = useMedia(); + const { toggleCamera, cameraPending } = useMedia(); const cameraOn = useCameraOn(); const cameraDeviceId = useCameraDeviceId(); @@ -22,8 +22,11 @@ const Camera = () => { isActive={cameraOn} onClick={toggleCamera} label={cameraOn ? 'Stop' : 'Start'} + disabled={cameraPending} > - {cameraOn ? ( + {cameraPending ? ( + + ) : cameraOn ? ( ) : ( diff --git a/src/components/room/join/join-controls.tsx b/src/components/room/join/join-controls.tsx index 86327e6..a0d3efd 100644 --- a/src/components/room/join/join-controls.tsx +++ b/src/components/room/join/join-controls.tsx @@ -5,11 +5,33 @@ import { useMedia } from '@/hooks/use-media'; import Mic from '../mic'; import Camera from '../camera'; import { useSettingsActions } from '@/store/conf/hooks'; +import { toast } from 'sonner'; const Controls = () => { const { requestCameraAndMicPermissions } = useMedia(); useEffect(() => { - requestCameraAndMicPermissions(); + const checkPermissions = async () => { + try { + const [cam, mic] = await Promise.all([ + navigator.permissions.query({ name: 'camera' as PermissionName }), + navigator.permissions.query({ name: 'microphone' as PermissionName }), + ]); + if (cam.state === 'denied' || mic.state === 'denied') { + const which = cam.state === 'denied' && mic.state === 'denied' + ? 'camera and microphone' + : cam.state === 'denied' ? 'camera' : 'microphone'; + toast.error(`${which.charAt(0).toUpperCase() + which.slice(1)} access blocked`, { + description: `Allow access to your ${which} in your browser's site settings, then reload.`, + duration: 10000, + }); + return; + } + } catch { + // permissions API not supported — fall through to getUserMedia + } + requestCameraAndMicPermissions(); + }; + checkPermissions(); }, [requestCameraAndMicPermissions]); const settingsAction = useSettingsActions(); diff --git a/src/components/room/media-control-button.tsx b/src/components/room/media-control-button.tsx index 4f161df..fac6a98 100644 --- a/src/components/room/media-control-button.tsx +++ b/src/components/room/media-control-button.tsx @@ -7,6 +7,7 @@ interface MediaControlButtonProps { children: React.ReactNode; className?: string; label?: string; + disabled?: boolean; } const MediaControlButton: React.FC = ({ isActive, @@ -14,11 +15,13 @@ const MediaControlButton: React.FC = ({ children, className, label, + disabled, }) => ( { - const { toggleMic } = useMedia(); + const { toggleMic, micPending } = useMedia(); const micOn = useMicOn(); const micDeviceId = useMicDeviceId(); const micDevices = useMicDevices(); return ( - - {micOn ? : } + + {micPending ? ( + + ) : micOn ? ( + + ) : ( + + )} { [mediaService] ); + const [micPending, setMicPending] = useState(false); + const [cameraPending, setCameraPending] = useState(false); + const requestMicPermission = useCallback(() => { + setMicPending(true); requestMediaPermissions({ audio: true, video: false }) .then(async () => { const devices = await navigator.mediaDevices.enumerateDevices(); @@ -401,31 +406,40 @@ export const useMedia = () => { }) .catch((err: MediaPermissionsError) => { const type = err?.type || 'DeviceNotFound'; - return alert(DEVICE_ERRORS[type]('microphone')); - }); + const { title, body } = DEVICE_ERRORS[type]('microphone'); + toast.error(title, { description: body }); + }) + .finally(() => setMicPending(false)); }, [micActions]); const requestCameraPermission = useCallback(() => { + setCameraPending(true); requestMediaPermissions({ audio: false, video: true }) .then(async () => { const devices = await navigator.mediaDevices.enumerateDevices(); - const audioInputDevices = devices.filter( - device => device.kind === 'audioinput' + const videoInputDevices = devices.filter( + device => device.kind === 'videoinput' ); - if (!audioInputDevices.length) throw 'Device not found'; - cameraActions.setDeviceId(audioInputDevices[0].deviceId); - cameraActions.setDevices(audioInputDevices); + if (!videoInputDevices.length) throw 'Device not found'; + cameraActions.setDeviceId(videoInputDevices[0].deviceId); + cameraActions.setDevices(videoInputDevices); }) .catch((err: MediaPermissionsError) => { const type = err?.type || 'DeviceNotFound'; - return alert(DEVICE_ERRORS[type]('camera')); - }); + const { title, body } = DEVICE_ERRORS[type]('camera'); + toast.error(title, { description: body }); + }) + .finally(() => setCameraPending(false)); }, [cameraActions]); const requestCameraAndMicPermissions = useCallback(() => { + setMicPending(true); + setCameraPending(true); requestMediaPermissions() .catch((err: MediaPermissionsError) => { - console.log(err); + const type = err?.type || 'Generic'; + const { title, body } = DEVICE_ERRORS[type]('camera'); + toast.error(title, { description: body }); }) .finally(async () => { try { @@ -436,25 +450,19 @@ export const useMedia = () => { const videoInputDevices = devices.filter( device => device.kind === 'videoinput' ); - // const audioOutputDevices = devices.filter( - // device => device.kind === 'audiooutput' - // ); - - // soundActions.setDeviceId( - // audioOutputDevices.length ? audioOutputDevices[0].deviceId : null - // ); cameraActions.setDeviceId( videoInputDevices.length ? videoInputDevices[0].deviceId : null ); micActions.setDeviceId( audioInputDevices.length ? audioInputDevices[0].deviceId : null ); - - // soundActions.setDevices(audioOutputDevices); cameraActions.setDevices(videoInputDevices); micActions.setDevices(audioInputDevices); } catch (error) { console.log(error); + } finally { + setMicPending(false); + setCameraPending(false); } }); }, [micActions, cameraActions]); @@ -462,6 +470,7 @@ export const useMedia = () => { const toggleCamera = useCallback(async () => { if (!mediaService) return console.log('Media service not intialised'); if (!cameraDeviceId) return requestCameraPermission(); + setCameraPending(true); try { if (cameraOn) { await stopUserMedia('camera'); @@ -471,6 +480,8 @@ export const useMedia = () => { cameraActions.toggle(); } catch (error) { console.log(error); + } finally { + setCameraPending(false); } }, [ cameraActions, @@ -481,9 +492,11 @@ export const useMedia = () => { startUserMedia, stopUserMedia, ]); + const toggleMic = useCallback(async () => { if (!mediaService) return console.log('Media service not intialised'); - if (!micDeviceId) return requestCameraPermission(); + if (!micDeviceId) return requestMicPermission(); + setMicPending(true); try { if (micOn) { await stopUserMedia('mic'); @@ -493,13 +506,15 @@ export const useMedia = () => { micActions.toggle(); } catch (error) { console.log(error); + } finally { + setMicPending(false); } }, [ micActions, micDeviceId, micOn, mediaService, - requestCameraPermission, + requestMicPermission, startUserMedia, stopUserMedia, ]); @@ -557,6 +572,8 @@ export const useMedia = () => { toggleCamera, toggleMic, toggleScreen, + micPending, + cameraPending, qualityManager, setConsumerQuality: qualityManager?.setConsumerQuality.bind(qualityManager), };