mirror of
https://github.com/usememos/memos.git
synced 2025-03-03 16:53:30 +08:00
Merge branch 'nick/record' into feat/audio-recording
This commit is contained in:
commit
59cb359f00
7 changed files with 121 additions and 6 deletions
0
scripts/build.sh
Normal file → Executable file
0
scripts/build.sh
Normal file → Executable file
|
@ -81,5 +81,6 @@
|
|||
"protobufjs": "^7.4.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.6"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
||||
}
|
||||
|
|
110
web/src/components/MemoEditor/ActionButton/RecordAudioButton.tsx
Normal file
110
web/src/components/MemoEditor/ActionButton/RecordAudioButton.tsx
Normal file
|
@ -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<MediaRecorder | null>(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 (
|
||||
<Button className="relative" size="sm" variant="plain" onClick={isRecording ? stopRecording : startRecording}>
|
||||
{isRecording ? <StopCircleIcon className="w-5 h-5 mx-auto text-red-500" /> : <MicIcon className="w-5 h-5 mx-auto" />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordAudioButton;
|
|
@ -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) => {
|
|||
<TagSelector editorRef={editorRef} />
|
||||
<MarkdownMenu editorRef={editorRef} />
|
||||
<UploadResourceButton />
|
||||
<RecordAudioButton />
|
||||
<AddMemoRelationPopover editorRef={editorRef} />
|
||||
{workspaceMemoRelatedSetting.enableLocation && (
|
||||
<LocationSelector
|
||||
|
|
|
@ -18,7 +18,7 @@ const MemoResource: React.FC<Props> = (props: Props) => {
|
|||
return (
|
||||
<div className={`w-auto flex flex-row justify-start items-center text-gray-500 dark:text-gray-400 hover:opacity-80 ${className}`}>
|
||||
{resource.type.startsWith("audio") ? (
|
||||
<audio src={resourceUrl} controls></audio>
|
||||
<audio src={resourceUrl} controls className="max-w-full" controlsList="nodownload" />
|
||||
) : (
|
||||
<>
|
||||
<ResourceIcon className="!w-4 !h-4 mr-1" resource={resource} />
|
||||
|
|
|
@ -162,7 +162,8 @@
|
|||
"remove-completed-task-list-items-successfully": "The removal was successful",
|
||||
"failed-to-embed-memo": "Failed to embed memo",
|
||||
"description-is-required": "Description is required",
|
||||
"fill-all-required-fields": "Please fill all required fields"
|
||||
"fill-all-required-fields": "Please fill all required fields",
|
||||
"microphone-not-available": "Cannot access microphone"
|
||||
},
|
||||
"reference": {
|
||||
"add-references": "Add references",
|
||||
|
@ -320,7 +321,7 @@
|
|||
},
|
||||
"disable-password-login": "Disable password login",
|
||||
"disable-password-login-final-warning": "Please type \"CONFIRM\" if you know what you are doing.",
|
||||
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. You’ll also have to be extra carefull when removing an identity provider",
|
||||
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. You'll also have to be extra carefull when removing an identity provider",
|
||||
"disable-public-memos": "Disable public memos",
|
||||
"disable-markdown-shortcuts-in-editor": "Disable Markdown shortcuts in editor",
|
||||
"display-with-updated-time": "Display with updated time",
|
||||
|
|
|
@ -155,7 +155,8 @@
|
|||
"succeed-copy-link": "复制链接到剪贴板成功。",
|
||||
"update-succeed": "更新成功",
|
||||
"user-not-found": "未找到该用户",
|
||||
"remove-completed-task-list-items-successfully": "移除成功!"
|
||||
"remove-completed-task-list-items-successfully": "移除成功!",
|
||||
"microphone-not-available": "无法访问麦克风"
|
||||
},
|
||||
"reference": {
|
||||
"add-references": "添加引用",
|
||||
|
|
Loading…
Reference in a new issue