From abcab8d6ab1233f9881e3177a3e284743c7e2944 Mon Sep 17 00:00:00 2001 From: TheNexter Date: Tue, 18 Nov 2025 17:58:47 +0100 Subject: [PATCH] Audio recording --- .../MemoEditor/ActionButton/InsertMenu.tsx | 112 +++++++++++++----- .../InsertMenu/useAudioRecorder.ts | 103 ++++++++++++++++ 2 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx index 6de3991b5..234cddff3 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx @@ -1,8 +1,9 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; -import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; +import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MicIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useContext, useState } from "react"; +import { toast } from "react-hot-toast"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -13,12 +14,14 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { attachmentStore } from "@/store"; import { Attachment } from "@/types/proto/api/v1/attachment_service"; import { Location, MemoRelation } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; import { MemoEditorContext } from "../types"; import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog"; import { LocationDialog } from "./InsertMenu/LocationDialog"; +import { useAudioRecorder } from "./InsertMenu/useAudioRecorder"; import { useFileUpload } from "./InsertMenu/useFileUpload"; import { useLinkMemo } from "./InsertMenu/useLinkMemo"; import { useLocation } from "./InsertMenu/useLocation"; @@ -52,6 +55,7 @@ const InsertMenu = observer((props: Props) => { }); const location = useLocation(props.location); + const audioRecorder = useAudioRecorder(); const isUploading = uploadingFlag || props.isUploading; @@ -112,41 +116,91 @@ const InsertMenu = observer((props: Props) => { }); }; + const handleStopRecording = async () => { + try { + const blob = await audioRecorder.stopRecording(); + const filename = `recording-${Date.now()}.webm`; + const file = new File([blob], filename, { type: "audio/webm" }); + const { name, size, type } = file; + const buffer = new Uint8Array(await file.arrayBuffer()); + + const attachment = await attachmentStore.createAttachment({ + attachment: Attachment.fromPartial({ + filename: name, + size, + type, + content: buffer, + }), + attachmentId: "", + }); + context.setAttachmentList([...context.attachmentList, attachment]); + } catch (error: any) { + console.error("Failed to upload audio recording:", error); + toast.error(error.details || "Failed to upload audio recording"); + } + }; + return ( <> - + {audioRecorder.isRecording ? ( + + ) : ( + + )} - - - {t("common.upload")} - - setLinkDialogOpen(true)}> - - {t("tooltip.link-memo")} - - - - {t("tooltip.select-location")} - - {/* View submenu with Focus Mode */} - - - - {t("common.more")} - - - - - {t("editor.focus-mode")} - ⌘⇧F + {audioRecorder.isRecording ? ( + <> + +
+ Stop Recording - - + + + Cancel Recording + + + ) : ( + <> + + + {t("common.upload")} + + setLinkDialogOpen(true)}> + + {t("tooltip.link-memo")} + + + + {t("tooltip.select-location")} + + + + Record Audio + + {/* View submenu with Focus Mode */} + + + + {t("common.more")} + + + + + {t("editor.focus-mode")} + ⌘⇧F + + + + + )} diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts b/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts new file mode 100644 index 000000000..b8da4f39c --- /dev/null +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts @@ -0,0 +1,103 @@ +import { useRef, useState } from "react"; + +interface AudioRecorderState { + isRecording: boolean; + isPaused: boolean; + recordingTime: number; + mediaRecorder: MediaRecorder | null; +} + +export const useAudioRecorder = () => { + const [state, setState] = useState({ + isRecording: false, + isPaused: false, + recordingTime: 0, + mediaRecorder: null, + }); + const chunksRef = useRef([]); + const timerRef = useRef(null); + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mediaRecorder = new MediaRecorder(stream); + chunksRef.current = []; + + mediaRecorder.ondataavailable = (e: BlobEvent) => { + if (e.data.size > 0) { + chunksRef.current.push(e.data); + } + }; + + mediaRecorder.start(); + setState((prev: AudioRecorderState) => ({ ...prev, isRecording: true, mediaRecorder })); + + timerRef.current = window.setInterval(() => { + setState((prev: AudioRecorderState) => ({ ...prev, recordingTime: prev.recordingTime + 1 })); + }, 1000); + } catch (error) { + console.error("Error accessing microphone:", error); + throw error; + } + }; + + const stopRecording = (): Promise => { + return new Promise((resolve, reject) => { + const { mediaRecorder } = state; + if (!mediaRecorder) { + reject(new Error("No active recording")); + return; + } + + mediaRecorder.onstop = () => { + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + chunksRef.current = []; + resolve(blob); + }; + + mediaRecorder.stop(); + mediaRecorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + setState({ + isRecording: false, + isPaused: false, + recordingTime: 0, + mediaRecorder: null, + }); + }); + }; + + const cancelRecording = () => { + const { mediaRecorder } = state; + if (mediaRecorder) { + mediaRecorder.stop(); + mediaRecorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + } + + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + chunksRef.current = []; + setState({ + isRecording: false, + isPaused: false, + recordingTime: 0, + mediaRecorder: null, + }); + }; + + return { + isRecording: state.isRecording, + recordingTime: state.recordingTime, + startRecording, + stopRecording, + cancelRecording, + }; +};