diff --git a/scripts/build.sh b/scripts/build.sh old mode 100644 new mode 100755 diff --git a/web/package.json b/web/package.json index 472e225a..6a369eed 100644 --- a/web/package.json +++ b/web/package.json @@ -81,5 +81,6 @@ "protobufjs": "^7.4.0", "typescript": "^5.7.3", "vite": "^6.0.6" - } -} \ No newline at end of file + }, + "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" +} diff --git a/web/src/components/MemoEditor/ActionButton/RecordAudioButton.tsx b/web/src/components/MemoEditor/ActionButton/RecordAudioButton.tsx new file mode 100644 index 00000000..6e9b1bff --- /dev/null +++ b/web/src/components/MemoEditor/ActionButton/RecordAudioButton.tsx @@ -0,0 +1,110 @@ +import { Button } from "@usememos/mui"; +import { MicIcon, StopCircleIcon } from "lucide-react"; +import { useCallback, useContext, useState } from "react"; +import toast from "react-hot-toast"; +import { useResourceStore } from "@/store/v1"; +import { Resource } from "@/types/proto/api/v1/resource_service"; +import { useTranslate } from "@/utils/i18n"; +import { MemoEditorContext } from "../types"; + +const RecordAudioButton = () => { + const t = useTranslate(); + const context = useContext(MemoEditorContext); + const resourceStore = useResourceStore(); + const [isRecording, setIsRecording] = useState(false); + const [mediaRecorder, setMediaRecorder] = useState(null); + + // 检测浏览器支持的音频格式 + const getSupportedMimeType = () => { + const types = ["audio/webm", "audio/mp4", "audio/aac", "audio/wav", "audio/ogg"]; + + for (const type of types) { + if (MediaRecorder.isTypeSupported(type)) { + return type; + } + } + return null; + }; + + const startRecording = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + const mimeType = getSupportedMimeType(); + if (!mimeType) { + throw new Error("No supported audio format found"); + } + + const recorder = new MediaRecorder(stream, { + mimeType: mimeType, + }); + + const chunks: BlobPart[] = []; + + recorder.ondataavailable = (e) => chunks.push(e.data); + recorder.onstop = async () => { + const blob = new Blob(chunks, { type: mimeType }); + const buffer = new Uint8Array(await blob.arrayBuffer()); + + // 根据不同的 mimeType 选择合适的文件扩展名 + const getFileExtension = (mimeType: string) => { + switch (mimeType) { + case "audio/webm": + return "webm"; + case "audio/mp4": + return "m4a"; + case "audio/aac": + return "aac"; + case "audio/wav": + return "wav"; + case "audio/ogg": + return "ogg"; + default: + return "webm"; + } + }; + + try { + const resource = await resourceStore.createResource({ + resource: Resource.fromPartial({ + filename: `recording-${new Date().getTime()}.${getFileExtension(mimeType)}`, + type: mimeType, + size: buffer.length, + content: buffer, + }), + }); + context.setResourceList([...context.resourceList, resource]); + } catch (error: any) { + console.error(error); + toast.error(error.details); + } + + stream.getTracks().forEach((track) => track.stop()); + }; + + // 每秒记录一次数据 + recorder.start(1000); + setMediaRecorder(recorder); + setIsRecording(true); + } catch (error) { + console.error(error); + toast.error(t("message.microphone-not-available")); + } + }, [context, resourceStore, t]); + + const stopRecording = useCallback(() => { + if (mediaRecorder) { + mediaRecorder.stop(); + setMediaRecorder(null); + setIsRecording(false); + } + }, [mediaRecorder]); + + return ( + + ); +}; + +export default RecordAudioButton; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 2e0bd384..46b4695a 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -26,6 +26,7 @@ import VisibilityIcon from "../VisibilityIcon"; import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover"; import LocationSelector from "./ActionButton/LocationSelector"; import MarkdownMenu from "./ActionButton/MarkdownMenu"; +import RecordAudioButton from "./ActionButton/RecordAudioButton"; import TagSelector from "./ActionButton/TagSelector"; import UploadResourceButton from "./ActionButton/UploadResourceButton"; import Editor, { EditorRefActions } from "./Editor"; @@ -466,6 +467,7 @@ const MemoEditor = observer((props: Props) => { + {workspaceMemoRelatedSetting.enableLocation && ( = (props: Props) => { return (
{resource.type.startsWith("audio") ? ( - +