From df0a204767feb40e0d14be52db88ba7d7c92d290 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Fri, 6 Mar 2026 14:32:19 -0800 Subject: [PATCH] CameraCaptureModal: full promised control --- .../useAttachmentSourceHandlers.ts | 21 +- .../components/camera/CameraCaptureModal.tsx | 296 ++++++++++++------ .../camera/useCameraCaptureDialog.tsx | 12 +- 3 files changed, 217 insertions(+), 112 deletions(-) diff --git a/src/common/attachment-drafts/attachment-sources/useAttachmentSourceHandlers.ts b/src/common/attachment-drafts/attachment-sources/useAttachmentSourceHandlers.ts index e7194a027..67dd38122 100644 --- a/src/common/attachment-drafts/attachment-sources/useAttachmentSourceHandlers.ts +++ b/src/common/attachment-drafts/attachment-sources/useAttachmentSourceHandlers.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import type { FileWithHandle } from 'browser-fs-access'; +import type { CameraLiveStream } from '~/common/components/camera/useCameraCapture'; import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore'; import { useCameraCaptureDialog } from '~/common/components/camera/useCameraCaptureDialog'; @@ -19,20 +20,30 @@ type _HandleWebLinks = (links: { url: string }[]) => void; /** - * Returns a handler that opens the camera capture dialog and appends the captured file. + * Returns a handler that opens the camera capture dialog and appends the captured files. */ -export function useAttachHandler_CameraOpen(attachAppendFile: AttachmentDraftsApi['attachAppendFile']) { +export function useAttachHandler_CameraOpen( + attachAppendFile: AttachmentDraftsApi['attachAppendFile'], + handleLiveStream?: (stream: CameraLiveStream) => void +): _HandleCameraOpen { // external state const { openCameraCapture } = useCameraCaptureDialog(); // -> showPromisedOverlay return React.useCallback<_HandleCameraOpen>(async () => { - const imageFile = await openCameraCapture(); - if (imageFile) + const result = await openCameraCapture({ allowMultiCapture: true, allowLiveFeed: !!handleLiveStream }); + if (!result) return; // user dismissed the dialog without capturing anything + + // append all captured images + for (const imageFile of result.images) void attachAppendFile('camera', imageFile); - }, [openCameraCapture, attachAppendFile]); + // handle live stream if provided + if (result.liveStream) + handleLiveStream?.(result.liveStream); + + }, [attachAppendFile, handleLiveStream, openCameraCapture]); } /** diff --git a/src/common/components/camera/CameraCaptureModal.tsx b/src/common/components/camera/CameraCaptureModal.tsx index 7a10b6cd2..98ec9ee83 100644 --- a/src/common/components/camera/CameraCaptureModal.tsx +++ b/src/common/components/camera/CameraCaptureModal.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; import type { SxProps } from '@mui/joy/styles/types'; -import { Box, Button, ButtonGroup, IconButton, Modal, ModalClose, Option, Select, Sheet, Tooltip, Typography } from '@mui/joy'; +import { Box, Button, ButtonGroup, IconButton, Modal, ModalClose, ModalSlotsAndSlotProps, Option, Select, SelectSlotsAndSlotProps, Sheet, Tooltip, Typography, } from '@mui/joy'; import AddRoundedIcon from '@mui/icons-material/AddRounded'; import CameraEnhanceIcon from '@mui/icons-material/CameraEnhance'; import CameraFrontIcon from '@mui/icons-material/CameraFront'; import CameraRearIcon from '@mui/icons-material/CameraRear'; import DownloadIcon from '@mui/icons-material/Download'; +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; import FlipCameraAndroidOutlinedIcon from '@mui/icons-material/FlipCameraAndroidOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; @@ -16,6 +17,7 @@ import { Is } from '~/common/util/pwaUtils'; import { animationBackgroundCameraFlash } from '~/common/util/animUtils'; import { downloadVideoFrame, renderVideoFrameAsFile } from '~/common/util/videoUtils'; +import type { CameraCaptureResult } from './useCameraCapture'; import { useCameraCapture } from './useCameraCapture'; @@ -25,66 +27,150 @@ const FLASH_DURATION_MS = 600; const ADD_COOLDOWN_MS = 300; -const captureButtonContainerSx: SxProps = { - display: 'flex', - gap: 1, - justifyContent: 'space-between', - alignItems: 'center', -}; - -const captureButtonGroupSx: SxProps = { - '--ButtonGroup-separatorColor': 'none !important', - // '--ButtonGroup-separatorSize': '2px', - borderRadius: '3rem', - // boxShadow: 'md', - boxShadow: '0 8px 12px -6px rgb(var(--joy-palette-neutral-darkChannel) / 50%)', -}; - -const captureButtonSx: SxProps = { - backgroundColor: 'neutral.solidHoverBg', - pl: 3.25, - pr: 4.5, - py: 1.5, - minWidth: { md: 200 }, - '&:hover': { - backgroundColor: 'neutral.plainHoverColor', +const _modalSlotProps: ModalSlotsAndSlotProps['slotProps'] = { + backdrop: { + sx: { + backdropFilter: 'none', // using none because this is heavy + // backdropFilter: 'blur(4px)', + // backgroundColor: 'rgba(11 13 14 / 0.75)', + backgroundColor: 'rgba(var(--joy-palette-neutral-darkChannel) / 0.67)', + }, }, -}; +} as const; -const addButtonSx: SxProps = { - pl: 2.5, - pr: 2, -}; +const _selectSlotProps: SelectSlotsAndSlotProps['slotProps'] = { + listbox: { + size: 'md' + }, +} as const; + +const _styles = { + + modal: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + + container: { + display: 'flex', flexDirection: 'column', m: 1, + borderRadius: 'md', overflow: 'hidden', + boxShadow: 'lg', + }, + + topBar: { + p: 1, + backgroundColor: 'neutral.800', + display: 'flex', + justifyContent: 'space-between', + }, + + topBarLeft: { + display: 'flex', + alignItems: 'center', + gap: 1, + }, + + cameraSelect: { + background: 'transparent', + }, + + videoContainer: { + position: 'relative', + backgroundColor: 'background.level3', + }, + + infoOverlay: { + position: 'absolute', inset: 0, zIndex: 1, + background: 'rgba(0,0,0,0.5)', color: 'white', + whiteSpace: 'pre', overflowY: 'scroll', + }, + + bottomBar: { + p: 1, + display: 'flex', + flexDirection: 'column', + gap: 1, + }, + + captureButtonContainer: { + display: 'flex', + gap: 1, + justifyContent: 'space-between', + alignItems: 'center', + }, + + captureButtonGroup: { + '--ButtonGroup-separatorColor': 'none !important', + // '--ButtonGroup-separatorSize': '2px', + borderRadius: '3rem', + // boxShadow: 'md', + boxShadow: '0 8px 12px -6px rgb(var(--joy-palette-neutral-darkChannel) / 50%)', + }, + + addButton: { + pl: 2.5, + pr: 2, + }, + + captureButton: { + backgroundColor: 'neutral.solidHoverBg', + pl: 3.25, + pr: 4.5, + py: 1.5, + minWidth: { md: 200 }, + '&:hover': { + backgroundColor: 'neutral.plainHoverColor', + }, + }, + + recButton: { + pl: 2, + pr: 2.5, + }, + +} as const satisfies Record; export function CameraCaptureModal(props: { - onCloseModal: () => void; - onAttachImage: (file: File) => void; - // onOCR: (ocrText: string) => void } + allowMultiCapture?: boolean; + allowLiveFeed?: boolean; + // allowOcr?: boolean; + onDone: (result: CameraCaptureResult | null) => void; }) { // state const [showInfo, setShowInfo] = React.useState(false); const [isFlashing, setIsFlashing] = React.useState(false); // For flash effect const [isAddButtonDisabled, setIsAddButtonDisabled] = React.useState(false); // Cooldown state + const [capturedCount, setCapturedCount] = React.useState(0); + const capturedImagesRef = React.useRef([]); // external state const { videoRef, - cameras, cameraIdx, setCameraIdx, - zoomControl, info, error, - resetVideo, + cameras, + cameraIdx, + setCameraIdx, + detachStream, + zoomControl, + info, + error, } = useCameraCapture(); // derived state - const { onCloseModal, onAttachImage } = props; + const { allowMultiCapture, allowLiveFeed, onDone } = props; - const stopAndClose = React.useCallback(() => { - resetVideo(); - onCloseModal(); - }, [onCloseModal, resetVideo]); + // single exit point: gather results and close (stream cleanup happens via effect on unmount) + const _resolveAndClose = React.useCallback((extraImage: undefined | File, includeLiveStream: boolean) => { + const images = capturedImagesRef.current; + if (extraImage) + images.push(extraImage); + const liveStream = includeLiveStream ? detachStream() ?? undefined : undefined; + onDone(images.length > 0 || liveStream ? { images, liveStream } : null); + }, [detachStream, onDone]); const handleFlashEffect = React.useCallback((cooldownMs: number) => { @@ -108,29 +194,40 @@ export function CameraCaptureModal(props: { try { // handleFlashEffect(0); // Trigger flash const file = await renderVideoFrameAsFile(videoRef.current, 'camera', 'image/jpeg', 0.95); - onAttachImage(file); - stopAndClose(); + // resolve adding this file and with no livestream + _resolveAndClose(file, false); } catch (error) { - console.error('Error capturing video frame:', error); + console.warn('[CameraCapture] Error capturing video frame:', error); } - }, [onAttachImage, stopAndClose, videoRef]); + }, [_resolveAndClose, videoRef]); const handleVideoAddClicked = React.useCallback(async () => { if (!videoRef.current) return; try { handleFlashEffect(ADD_COOLDOWN_MS); // Trigger flash and cooldown const file = await renderVideoFrameAsFile(videoRef.current, 'camera', 'image/jpeg', 0.95); - onAttachImage(file); + capturedImagesRef.current.push(file); + setCapturedCount(c => c + 1); } catch (error) { - console.error('Error capturing video frame:', error); + console.warn('[CameraCapture] Error capturing video frame:', error); } - }, [handleFlashEffect, onAttachImage, videoRef]); + }, [handleFlashEffect, videoRef]); const handleVideoDownloadClicked = React.useCallback(async () => { if (!videoRef.current) return; await downloadVideoFrame(videoRef.current, 'camera', 'image/jpeg', 0.98).catch(alert); }, [videoRef]); + const handleStartLiveFeedClicked = React.useCallback(() => { + // resolve with the detached livestream, and no extra images + _resolveAndClose(undefined, true); + }, [_resolveAndClose]); + + const handleCloseModal = React.useCallback(() => { + // resolve with no extra images, no livestream - baseline just closes + _resolveAndClose(undefined, false); + }, [_resolveAndClose]); + // Reduced set of cameras @@ -206,49 +303,60 @@ export function CameraCaptureModal(props: { }, [cameraIdx, displayCameras, setCameraIdx]); + const cameraButtons = React.useMemo(() => { + const btns: React.ReactNode[] = []; + if (allowMultiCapture) + btns.push( + + + {capturedCount ? {capturedCount} : null} + + , + ); + btns.push( + , + ); + if (allowLiveFeed && !capturedCount) + btns.push( + + + + + , + ); + return btns; + }, [allowLiveFeed, allowMultiCapture, cameraIdx, capturedCount, handleStartLiveFeedClicked, handleVideoAddClicked, handleVideoSnapClicked, isAddButtonDisabled]); + + return ( - + {/* Top bar */} - - + +