diff --git a/examples/02_gui.py b/examples/02_gui.py index 70eb07542..5e09ef8ab 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -81,7 +81,9 @@ def main() -> None: @gui_upload_button.on_upload def _(_) -> None: - print(gui_upload_button.value.name) + """Callback for when a file is uploaded.""" + file = gui_upload_button.value + print(file.name, len(file.content), "bytes") # Pre-generate a point cloud to send. point_positions = onp.random.uniform(low=-1.0, high=1.0, size=(5000, 3)) diff --git a/src/viser/client/src/components/UploadButton.tsx b/src/viser/client/src/components/UploadButton.tsx new file mode 100644 index 000000000..108b3288d --- /dev/null +++ b/src/viser/client/src/components/UploadButton.tsx @@ -0,0 +1,205 @@ +import { GuiAddUploadButtonMessage } from "../WebsocketMessages"; +import { computeRelativeLuminance } from "../ControlPanel/GuiState"; +import { GuiComponentContext } from "../ControlPanel/GuiComponentContext"; +import { v4 as uuid } from "uuid"; +import { Box, Image, Progress, useMantineTheme } from "@mantine/core"; + +import { Button } from "@mantine/core"; +import React, { useContext } from "react"; +import { makeThrottledMessageSender } from "../WebsocketFunctions"; +import { ViewerContext, ViewerContextContents } from "../App"; +import { pack } from "msgpackr"; +import { IconCheck } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; + +export default function UploadButtonComponent(conf: GuiAddUploadButtonMessage) { + // Handle GUI input types. + const viewer = useContext(ViewerContext)!; + const fileUploadRef = React.useRef(null); + const { isUploading, upload } = useFileUpload({ + viewer, + componentId: conf.id, + }); + const theme = useMantineTheme(); + + const inputColor = + computeRelativeLuminance(theme.fn.primaryColor()) > 50.0 + ? theme.colors.gray[9] + : theme.white; + const disabled = conf.disabled || isUploading; + return ( + + { + const input = e.target as HTMLInputElement; + if (!input.files) return; + upload(input.files[0]); + }} + /> + + + ); +} + +function useFileUpload({ + viewer, + componentId, +}: { + componentId: string; + viewer: ViewerContextContents; +}) { + const websocketRef = viewer.websocketRef; + const updateUploadState = viewer.useGui((state) => state.updateUploadState); + const uploadState = viewer.useGui( + (state) => state.uploadsInProgress[componentId], + ); + const totalBytes = uploadState?.totalBytes; + + // Cache total bytes string + const totalBytesString = React.useMemo(() => { + if (totalBytes === undefined) return ""; + let displaySize = totalBytes; + const displayUnits = ["B", "K", "M", "G", "T", "P"]; + let displayUnitIndex = 0; + while (displaySize >= 100 && displayUnitIndex < displayUnits.length - 1) { + displaySize /= 1024; + displayUnitIndex += 1; + } + return `${displaySize.toFixed(1)}${displayUnits[displayUnitIndex]}`; + }, [totalBytes]); + + // Update notification status + React.useEffect(() => { + if (uploadState === undefined) return; + const { notificationId, filename } = uploadState; + if (uploadState.uploadedBytes === 0) { + // Show notification. + notifications.show({ + id: notificationId, + title: "Uploading " + `${filename} (${totalBytesString})`, + message: , + autoClose: false, + withCloseButton: false, + loading: true, + }); + } else { + // Update progress. + const progressValue = uploadState.uploadedBytes / uploadState.totalBytes; + const isDone = progressValue === 1.0; + notifications.update({ + id: notificationId, + title: "Uploading " + `${filename} (${totalBytesString})`, + message: !isDone ? ( + + ) : ( + "File uploaded successfully." + ), + autoClose: isDone, + withCloseButton: isDone, + loading: !isDone, + icon: isDone ? : undefined, + }); + } + }, [uploadState, totalBytesString]); + + const isUploading = + uploadState !== undefined && + uploadState.uploadedBytes < uploadState.totalBytes; + + async function upload(file: File) { + const chunkSize = 512 * 1024; // bytes + const numChunks = Math.ceil(file.size / chunkSize); + const transferUuid = uuid(); + const notificationId = "upload-" + transferUuid; + + const send = (message: Parameters[0]) => + websocketRef.current?.send(pack(message)); + + // Begin upload by setting initial state + updateUploadState({ + componentId: componentId, + uploadedBytes: 0, + totalBytes: file.size, + filename: file.name, + notificationId, + }); + + send({ + type: "FileTransferStart", + source_component_id: componentId, + transfer_uuid: transferUuid, + filename: file.name, + mime_type: file.type, + size_bytes: file.size, + part_count: numChunks, + }); + + for (let i = 0; i < numChunks; i++) { + const start = i * chunkSize; + const end = (i + 1) * chunkSize; + const chunk = file.slice(start, end); + const buffer = await chunk.arrayBuffer(); + + send({ + type: "FileTransferPart", + source_component_id: componentId, + transfer_uuid: transferUuid, + part: i, + content: new Uint8Array(buffer), + }); + } + } + + return { + isUploading, + upload, + }; +}