import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useVoiceStore } from '../../stores/voiceStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { VoiceUserRow } from './VoiceUserRow'; /** * Permission state for the in-sheet camera preview. Mirrors the smaller * version of the state machine in `VideoSection.tsx` — but pre-join we treat * `denied` and `hard-blocked` the same (single retry path; user can still join * voice without preview). */ type CameraPermState = 'unknown' | 'granted' | 'prompt' | 'denied'; function errorName(err: unknown): string { return err instanceof Error ? err.name : ''; } interface MobileVoiceJoinSheetProps { channelId: string; channelName: string; spaceId: string; onClose: () => void; onJoin: (channelId: string, preMuted: boolean) => void; } export function MobileVoiceJoinSheet({ channelId, channelName, spaceId, onClose, onJoin, }: MobileVoiceJoinSheetProps) { const [preMuted, setPreMuted] = useState(false); const [visible, setVisible] = useState(false); const voiceUsers = useVoiceStore((s) => s.voiceUsers); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const speakingUserIds = useVoiceStore((s) => s.speakingUserIds); const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId); const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId); const members = useSpaceStore((s) => s.members); const channels = useSpaceStore((s) => s.channels); // ── Camera preview state (pre-join) ───────────────────────────────────── // Mirrors `VideoSection.tsx`'s explicit-gesture pattern. We never fire // getUserMedia on mount — only on user-tap of the "Enable preview" CTA or // dropdown re-select while preview is already active. The preview is hard- // bound to the sheet lifecycle: closing the sheet (any path: backdrop tap, // close button, Join, channel-switch) stops the stream and releases the LED. const [permState, setPermState] = useState('unknown'); const [previewActive, setPreviewActive] = useState(false); const [previewError, setPreviewError] = useState(null); const [cameraDevices, setCameraDevices] = useState([]); const [pickerOpen, setPickerOpen] = useState(false); const previewVideoRef = useRef(null); const previewStreamRef = useRef(null); const startGenRef = useRef(0); const mountedRef = useRef(true); const pickerAnchorRef = useRef(null); // Portaled-popup ref. The popup is rendered into document.body to escape the // sheet's `aspect-video overflow-hidden` parent (which clipped the top of the // list when the device list was long). Click-outside checks both the anchor // and this portaled popup so taps on list items don't dismiss it. const pickerPopupRef = useRef(null); const [pickerPopupRect, setPickerPopupRect] = useState<{ left: number; bottom: number } | null>(null); // Stop the active preview stream (releases the camera LED). macOS holds the // LED on for ~2s after release (hardware debounce). const stopPreview = useCallback(() => { if (previewStreamRef.current) { previewStreamRef.current.getTracks().forEach((t) => t.stop()); previewStreamRef.current = null; } const videoEl = previewVideoRef.current; if (videoEl && videoEl.srcObject instanceof MediaStream) { videoEl.srcObject = null; } }, []); // Trigger slide-up animation on mount useEffect(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { setVisible(true); }); }); }, []); // Mount-time camera permission probe. `permissions.query` does not light the // LED; getUserMedia is gated behind explicit user gestures only. If the // Permissions API is missing or rejects 'camera', we fall back to `prompt` // so the user can still trigger the preview manually. useEffect(() => { mountedRef.current = true; let cancelled = false; let status: PermissionStatus | null = null; let onChange: (() => void) | null = null; const apply = (state: PermissionState) => { if (cancelled || !mountedRef.current) return; if (state === 'granted') setPermState('granted'); else if (state === 'prompt') setPermState('prompt'); else setPermState('denied'); }; const run = async () => { if (!navigator.permissions || typeof navigator.permissions.query !== 'function') { if (!cancelled && mountedRef.current) setPermState('prompt'); return; } try { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const s = await navigator.permissions.query({ name: 'camera' as PermissionName }); status = s; apply(s.state); onChange = () => apply(s.state); s.addEventListener('change', onChange); } catch { if (!cancelled && mountedRef.current) setPermState('prompt'); } }; run(); return () => { cancelled = true; mountedRef.current = false; if (status && onChange) { try { status.removeEventListener('change', onChange); } catch { // best-effort cleanup } } }; }, []); // Enumerate cameras when permission is granted; refresh on devicechange. // Passive — does not light the LED. useEffect(() => { if (permState !== 'granted') return; let cancelled = false; const enumerate = async () => { try { const all = await navigator.mediaDevices.enumerateDevices(); if (cancelled) return; const seen = new Set(); const cams: MediaDeviceInfo[] = []; for (const d of all) { if (d.kind !== 'videoinput') continue; if (seen.has(d.deviceId)) continue; seen.add(d.deviceId); cams.push(d); } setCameraDevices(cams); } catch { if (!cancelled) setCameraDevices([]); } }; enumerate(); const onChange = () => { enumerate(); }; navigator.mediaDevices.addEventListener('devicechange', onChange); return () => { cancelled = true; navigator.mediaDevices.removeEventListener('devicechange', onChange); }; }, [permState]); // Hard cleanup: when the sheet unmounts (any close path), stop the stream // and release the camera. This is the single source of truth for "camera // off when sheet closes" — every close handler funnels through unmount via // the parent removing the component. useEffect(() => { return () => { startGenRef.current += 1; stopPreview(); }; }, [stopPreview]); // Tab-visibility cleanup: release LED when tab is hidden. Spec choice (same // as VideoSection): no auto-resume — user must re-tap to re-arm. useEffect(() => { const onVisibility = () => { if (document.visibilityState === 'hidden' && previewActive) { startGenRef.current += 1; stopPreview(); setPreviewActive(false); } }; document.addEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility); }, [previewActive, stopPreview]); // Camera-device picker click-outside (mousedown + touchstart). // Both the anchor (in-tile button) AND the portaled popup are excluded — the // popup is rendered into document.body to escape the sheet's overflow-hidden // parent, so it isn't a DOM descendant of the anchor. useEffect(() => { if (!pickerOpen) return; const onPointerDown = (e: MouseEvent | TouchEvent) => { const target = e.target as Node; const inAnchor = pickerAnchorRef.current?.contains(target) ?? false; const inPopup = pickerPopupRef.current?.contains(target) ?? false; if (!inAnchor && !inPopup) setPickerOpen(false); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('touchstart', onPointerDown, { passive: true }); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('touchstart', onPointerDown); }; }, [pickerOpen]); // When the picker opens, capture the anchor's screen position so we can // portal the popup to document.body just above it. Using `bottom` keeps the // popup pinned to the top of the anchor's frame (so it expands upward and // is naturally constrained by `max-height + overflow-y-auto`). useEffect(() => { if (!pickerOpen) { setPickerPopupRect(null); return; } const anchorBtn = pickerAnchorRef.current?.querySelector('button'); if (!anchorBtn) return; const update = () => { const r = anchorBtn.getBoundingClientRect(); setPickerPopupRect({ left: r.left, // distance from viewport bottom to anchor top, plus a small gap bottom: window.innerHeight - r.top + 4, }); }; update(); window.addEventListener('resize', update); window.addEventListener('scroll', update, true); return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true); }; }, [pickerOpen]); // When the user changes camera selection while preview is running, swap. useEffect(() => { if (permState !== 'granted') return; if (!previewActive) return; const gen = ++startGenRef.current; stopPreview(); const start = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true, }); if (gen !== startGenRef.current || !mountedRef.current) { stream.getTracks().forEach((t) => t.stop()); return; } previewStreamRef.current = stream; const videoEl = previewVideoRef.current; if (videoEl) { // iOS Safari: srcObject + autoPlay + muted + playsInline. The video // element below sets `autoPlay playsInline muted`, so this play() is // redundant on most browsers — but it makes Chrome desktop happy and // doesn't hurt iOS. (Captured in handoff Known Traps.) videoEl.srcObject = stream; videoEl.play().catch(() => {}); } setPreviewError(null); } catch (err) { if (gen !== startGenRef.current || !mountedRef.current) return; const name = errorName(err); if (name === 'NotAllowedError') { setPermState('denied'); setPreviewActive(false); } else if (name === 'NotReadableError') { setPreviewError('Camera is in use by another application.'); } else if (name === 'OverconstrainedError') { setPreviewError('Selected camera is unavailable.'); } else if (name === 'NotFoundError') { setPreviewError('No camera detected.'); } else { setPreviewError('Could not start camera preview.'); } } }; start(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cameraDeviceId, previewActive, permState]); const userIds = useMemo(() => voiceUsers.get(channelId) || [], [voiceUsers, channelId]); const userCount = userIds.length; const userCountLabel = userCount === 1 ? '1 Person in Voice' : `${userCount} People in Voice`; // Determine if switching channels const isSwitching = currentVoiceChannelId !== null && currentVoiceChannelId !== channelId; const currentChannelName = useMemo(() => { if (!currentVoiceChannelId) return ''; const ch = channels.find((c) => c.id === currentVoiceChannelId); return ch?.name || ''; }, [currentVoiceChannelId, channels]); const handleJoin = useCallback(() => { onJoin(channelId, preMuted); }, [channelId, preMuted, onJoin]); const handleBackdropClick = useCallback(() => { onClose(); }, [onClose]); // Explicit user gesture: open getUserMedia for the selected camera. const startPreviewFromUser = useCallback(async () => { setPreviewError(null); const gen = ++startGenRef.current; stopPreview(); setPreviewActive(true); try { const stream = await navigator.mediaDevices.getUserMedia({ video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true, }); if (gen !== startGenRef.current || !mountedRef.current) { stream.getTracks().forEach((t) => t.stop()); return; } previewStreamRef.current = stream; const videoEl = previewVideoRef.current; if (videoEl) { videoEl.srcObject = stream; videoEl.play().catch(() => {}); } if (mountedRef.current) { setPermState((prev) => (prev === 'prompt' || prev === 'denied' ? 'granted' : prev)); } } catch (err) { if (gen !== startGenRef.current || !mountedRef.current) return; const name = errorName(err); if (name === 'NotAllowedError') { setPermState('denied'); setPreviewActive(false); } else if (name === 'NotReadableError') { setPreviewError('Camera is in use by another application.'); } else if (name === 'OverconstrainedError') { setPreviewError('Selected camera is unavailable.'); } else if (name === 'NotFoundError') { setPreviewError('No camera detected.'); } else { setPreviewError('Could not start camera preview.'); } } }, [cameraDeviceId, stopPreview]); const stopPreviewFromUser = useCallback(() => { startGenRef.current += 1; stopPreview(); setPreviewActive(false); setPreviewError(null); }, [stopPreview]); // Derived: do we have multiple cameras to expose a picker for? const showCameraPicker = permState === 'granted' && cameraDevices.length > 1; const selectedCameraLabel = useMemo(() => { if (cameraDeviceId === null) return 'Auto'; const d = cameraDevices.find((c) => c.deviceId === cameraDeviceId); return d?.label || 'Selected camera'; }, [cameraDeviceId, cameraDevices]); return createPortal( <> {/* Backdrop */}
{/* Sheet container */}
{/* Drag handle */}
{/* Header */}

{channelName}

{/* User count */} {userCount > 0 && (

{userCountLabel}

)} {/* Channel switch warning */} {isSwitching && (
You'll leave {currentChannelName} and join{' '} {channelName}
)} {/* Camera preview tile (pre-join) */}
{/* User list */} {userCount > 0 && (
{userIds.map((userId) => { const member = members.find((m) => m.userId === userId); const displayName = member?.user.displayName ?? member?.user.username ?? userId; const avatar = member?.user.avatar ?? null; const avatarColor = member?.user.avatarColor; const wsStatus = voiceUserStates.get(userId); const isMuted = wsStatus?.isMuted ?? false; const isDeafened = wsStatus?.isDeafened ?? false; const isCameraOn = wsStatus?.isCameraOn ?? false; const isScreenSharing = wsStatus?.isScreenSharing ?? false; const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`); const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`); const isPermMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`); return (
); })}
)} {/* Empty state */} {userCount === 0 && (

No one is in this channel yet.

Be the first to join!

)} {/* Bottom action bar */}
{/* Mic toggle */} {/* Join Voice button */} {/* Close button */}
{/* Camera picker popup — portaled to document.body so it escapes the sheet's `aspect-video overflow-hidden` tile. Anchored to the trigger button via getBoundingClientRect (captured in the open effect above). `max-h` + `overflow-y-auto` + iOS scroll momentum keeps the list reachable even with many cameras; using `bottom` (not `top`) makes the popup expand upward from the anchor. */} {showCameraPicker && pickerOpen && pickerPopupRect && (
{cameraDevices.map((d, i) => ( ))}
)} , document.body, ); }