From 067d7ff0ce84a5b68f0ff27f06d83032206e14e9 Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 6 Apr 2026 15:46:38 +0800 Subject: [PATCH] chore: refactor memo editor audio recording flow --- .../MemoEditor/Toolbar/InsertMenu.tsx | 29 +- .../components/AudioRecorderPanel.tsx | 52 +++ .../MemoEditor/components/EditorContent.tsx | 2 + .../MemoEditor/components/EditorToolbar.tsx | 4 +- .../components/VoiceRecorderPanel.tsx | 135 ------- .../components/MemoEditor/components/index.ts | 2 +- web/src/components/MemoEditor/hooks/index.ts | 2 +- ...seVoiceRecorder.ts => useAudioRecorder.ts} | 111 +++--- .../MemoEditor/hooks/useFileUpload.ts | 1 + web/src/components/MemoEditor/index.tsx | 107 +++--- .../MemoEditor/services/memoService.ts | 3 +- .../MemoEditor/services/validationService.ts | 6 +- .../components/MemoEditor/state/actions.ts | 27 +- .../components/MemoEditor/state/reducer.ts | 41 +-- web/src/components/MemoEditor/state/types.ts | 31 +- .../components/MemoEditor/types/attachment.ts | 38 ++ .../components/MemoEditor/types/components.ts | 14 +- .../Attachment/AttachmentListEditor.tsx | 329 +++++++++++++----- web/src/locales/en.json | 21 +- web/src/locales/tr.json | 11 +- 20 files changed, 547 insertions(+), 419 deletions(-) create mode 100644 web/src/components/MemoEditor/components/AudioRecorderPanel.tsx delete mode 100644 web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx rename web/src/components/MemoEditor/hooks/{useVoiceRecorder.ts => useAudioRecorder.ts} (61%) diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 2393dbd3c..63da079c5 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -20,6 +20,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -135,28 +136,28 @@ const InsertMenu = (props: InsertMenuProps) => { [ { key: "upload", - label: t("common.upload"), + label: t("editor.insert-menu.upload-file"), icon: FileIcon, onClick: handleUploadClick, }, + { + key: "record-audio", + label: t("editor.audio-recorder.trigger"), + icon: MicIcon, + onClick: () => props.onAudioRecorderClick?.(), + }, { key: "link", - label: t("tooltip.link-memo"), + label: t("editor.insert-menu.link-memo"), icon: LinkIcon, onClick: handleOpenLinkDialog, }, { key: "location", - label: t("tooltip.select-location"), + label: t("editor.insert-menu.add-location"), icon: MapPinIcon, onClick: handleLocationClick, }, - { - key: "voice-note", - label: t("editor.voice-recorder.trigger"), - icon: MicIcon, - onClick: () => props.onVoiceRecorderClick?.(), - }, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, [handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t], ); @@ -170,12 +171,20 @@ const InsertMenu = (props: InsertMenuProps) => { - {menuItems.map((item) => ( + {menuItems.slice(0, 2).map((item) => ( {item.label} ))} + + {menuItems.slice(2).map((item) => ( + + + {item.label} + + ))} + {/* View submenu with Focus Mode */} diff --git a/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx b/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx new file mode 100644 index 000000000..6b7c49fb1 --- /dev/null +++ b/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx @@ -0,0 +1,52 @@ +import { LoaderCircleIcon, XIcon } from "lucide-react"; +import type { FC } from "react"; +import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; +import type { AudioRecorderPanelProps } from "../types/components"; + +export const AudioRecorderPanel: FC = ({ audioRecorder, onStop, onCancel }) => { + const t = useTranslate(); + const { status, elapsedSeconds } = audioRecorder; + + const isRequestingPermission = status === "requesting_permission"; + + return ( +
+
+
+
+ {isRequestingPermission ? t("editor.audio-recorder.requesting-permission") : t("editor.audio-recorder.recording")} +
+
+ +
+ {isRequestingPermission ? ( + + ) : ( + + )} + {formatAudioTime(elapsedSeconds)} +
+ +
+ + +
+
+
+ ); +}; diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 5cc14f784..dab0f32d7 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -13,6 +13,7 @@ export const EditorContent = forwardRef(({ const localFiles: LocalFile[] = Array.from(files).map((file) => ({ file, previewUrl: createBlobUrl(file), + origin: "upload", })); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); }); @@ -49,6 +50,7 @@ export const EditorContent = forwardRef(({ const localFiles: LocalFile[] = files.map((file) => ({ file, previewUrl: createBlobUrl(file), + origin: "upload", })); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); event.preventDefault(); diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index 6f9ad1b19..5205e0b5b 100644 --- a/web/src/components/MemoEditor/components/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; import VisibilitySelector from "../Toolbar/VisibilitySelector"; import type { EditorToolbarProps } from "../types"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onVoiceRecorderClick }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onAudioRecorderClick }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -35,7 +35,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} memoName={memoName} - onVoiceRecorderClick={onVoiceRecorderClick} + onAudioRecorderClick={onAudioRecorderClick} /> diff --git a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx b/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx deleted file mode 100644 index 121ddbe65..000000000 --- a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { AudioLinesIcon, LoaderCircleIcon, MicIcon, RotateCcwIcon, SquareIcon, Trash2Icon } from "lucide-react"; -import type { FC } from "react"; -import { AudioAttachmentItem } from "@/components/MemoMetadata/Attachment"; -import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { useTranslate } from "@/utils/i18n"; -import type { VoiceRecorderPanelProps } from "../types/components"; - -export const VoiceRecorderPanel: FC = ({ - voiceRecorder, - onStart, - onStop, - onKeep, - onDiscard, - onRecordAgain, - onClose, -}) => { - const t = useTranslate(); - const { status, elapsedSeconds, error, recording } = voiceRecorder; - - const isRecording = status === "recording"; - const isRequestingPermission = status === "requesting_permission"; - const isUnsupported = status === "unsupported"; - const hasRecording = status === "recorded" && recording; - - return ( -
-
-
-
- {isRequestingPermission ? ( - - ) : hasRecording ? ( - - ) : ( - - )} -
- -
-
- {isRecording - ? t("editor.voice-recorder.recording") - : isRequestingPermission - ? t("editor.voice-recorder.requesting-permission") - : hasRecording - ? t("editor.voice-recorder.ready") - : isUnsupported - ? t("editor.voice-recorder.unsupported") - : error - ? t("editor.voice-recorder.error") - : t("editor.voice-recorder.title")} -
- -
- {isRecording - ? t("editor.voice-recorder.recording-description", { duration: formatAudioTime(elapsedSeconds) }) - : isRequestingPermission - ? t("editor.voice-recorder.requesting-permission-description") - : hasRecording - ? t("editor.voice-recorder.ready-description") - : isUnsupported - ? t("editor.voice-recorder.unsupported-description") - : error - ? error - : t("editor.voice-recorder.idle-description")} -
-
-
- - {isRecording && ( -
- - {formatAudioTime(elapsedSeconds)} -
- )} -
- - {hasRecording && ( -
- -
- )} - -
- {hasRecording ? ( - <> - - - - - ) : isRecording ? ( - - ) : ( - <> - - {!isUnsupported && ( - - )} - - )} -
-
- ); -}; diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts index eed02ff12..73faf0f16 100644 --- a/web/src/components/MemoEditor/components/index.ts +++ b/web/src/components/MemoEditor/components/index.ts @@ -1,8 +1,8 @@ // UI components for MemoEditor +export * from "./AudioRecorderPanel"; export * from "./EditorContent"; export * from "./EditorMetadata"; export * from "./EditorToolbar"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { TimestampPopover } from "./TimestampPopover"; -export * from "./VoiceRecorderPanel"; diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts index a1ca003e8..dc2780d23 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -1,4 +1,5 @@ // Custom hooks for MemoEditor (internal use only) +export { useAudioRecorder } from "./useAudioRecorder"; export { useAutoSave } from "./useAutoSave"; export { useBlobUrls } from "./useBlobUrls"; export { useDragAndDrop } from "./useDragAndDrop"; @@ -8,4 +9,3 @@ export { useKeyboard } from "./useKeyboard"; export { useLinkMemo } from "./useLinkMemo"; export { useLocation } from "./useLocation"; export { useMemoInit } from "./useMemoInit"; -export { useVoiceRecorder } from "./useVoiceRecorder"; diff --git a/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts b/web/src/components/MemoEditor/hooks/useAudioRecorder.ts similarity index 61% rename from web/src/components/MemoEditor/hooks/useVoiceRecorder.ts rename to web/src/components/MemoEditor/hooks/useAudioRecorder.ts index eb21d4a67..1bc9a99a0 100644 --- a/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts +++ b/web/src/components/MemoEditor/hooks/useAudioRecorder.ts @@ -4,13 +4,13 @@ import { useBlobUrls } from "./useBlobUrls"; const FALLBACK_AUDIO_MIME_TYPE = "audio/webm"; -interface VoiceRecorderActions { - setVoiceRecorderSupport: (value: boolean) => void; - setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => void; - setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => void; - setVoiceRecorderElapsed: (value: number) => void; - setVoiceRecorderError: (value?: string) => void; - setVoiceRecording: (value?: { localFile: LocalFile; durationSeconds: number; mimeType: string }) => void; +interface AudioRecorderActions { + setAudioRecorderSupport: (value: boolean) => void; + setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => void; + setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => void; + setAudioRecorderElapsed: (value: number) => void; + setAudioRecorderError: (value?: string) => void; + onRecordingComplete: (localFile: LocalFile) => void; } const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const; @@ -39,17 +39,22 @@ function createRecordedFile(blob: Blob, mimeType: string): File { const extension = getFileExtension(mimeType); const now = new Date(); const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join(""); - const timePart = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0")].join(""); + const timePart = [ + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ].join(""); return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType }); } -export const useVoiceRecorder = (actions: VoiceRecorderActions) => { +export const useAudioRecorder = (actions: AudioRecorderActions) => { const mediaRecorderRef = useRef(null); const mediaStreamRef = useRef(null); const chunksRef = useRef([]); const startedAtRef = useRef(null); const elapsedTimerRef = useRef(null); const recorderMimeTypeRef = useRef(FALLBACK_AUDIO_MIME_TYPE); + const startRequestIdRef = useRef(0); const { createBlobUrl } = useBlobUrls(); const cleanupTimer = () => { @@ -79,15 +84,15 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { typeof navigator.mediaDevices?.getUserMedia === "function" && typeof MediaRecorder !== "undefined"; - actions.setVoiceRecorderSupport(isSupported); + actions.setAudioRecorderSupport(isSupported); if (!isSupported) { - actions.setVoiceRecorderStatus("unsupported"); - actions.setVoiceRecorderError("Voice recording is not supported in this browser."); + actions.setAudioRecorderStatus("unsupported"); + actions.setAudioRecorderError("Audio recording is not supported in this browser."); return; } - actions.setVoiceRecorderStatus("idle"); - actions.setVoiceRecorderError(undefined); + actions.setAudioRecorderStatus("idle"); + actions.setAudioRecorderError(undefined); return () => { resetRecorderRefs(); @@ -95,24 +100,31 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { }, [actions]); const startRecording = async () => { + const requestId = startRequestIdRef.current + 1; + startRequestIdRef.current = requestId; + if ( typeof navigator === "undefined" || typeof navigator.mediaDevices?.getUserMedia !== "function" || typeof MediaRecorder === "undefined" ) { - actions.setVoiceRecorderSupport(false); - actions.setVoiceRecorderStatus("unsupported"); - actions.setVoiceRecorderError("Voice recording is not supported in this browser."); + actions.setAudioRecorderSupport(false); + actions.setAudioRecorderStatus("unsupported"); + actions.setAudioRecorderError("Audio recording is not supported in this browser."); return; } - actions.setVoiceRecorderError(undefined); - actions.setVoiceRecorderStatus("requesting_permission"); - actions.setVoiceRecorderElapsed(0); - actions.setVoiceRecording(undefined); + actions.setAudioRecorderError(undefined); + actions.setAudioRecorderStatus("requesting_permission"); + actions.setAudioRecorderElapsed(0); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (startRequestIdRef.current !== requestId) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE; const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined); @@ -122,47 +134,68 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { chunksRef.current = []; mediaRecorder.addEventListener("dataavailable", (event) => { + if (startRequestIdRef.current !== requestId) { + return; + } + if (event.data.size > 0) { chunksRef.current.push(event.data); } }); mediaRecorder.addEventListener("stop", () => { + if (startRequestIdRef.current !== requestId) { + return; + } + const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0; const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current }); + if (blob.size === 0) { + actions.setAudioRecorderElapsed(0); + actions.setAudioRecorderError(undefined); + actions.setAudioRecorderStatus("idle"); + resetRecorderRefs(); + return; + } + const file = createRecordedFile(blob, recorderMimeTypeRef.current); const previewUrl = createBlobUrl(file); - actions.setVoiceRecording({ - localFile: { - file, - previewUrl, + actions.onRecordingComplete({ + file, + previewUrl, + origin: "audio_recording", + audioMeta: { + durationSeconds, }, - durationSeconds, - mimeType: recorderMimeTypeRef.current, }); - actions.setVoiceRecorderElapsed(durationSeconds); - actions.setVoiceRecorderStatus("recorded"); + actions.setAudioRecorderElapsed(0); + actions.setAudioRecorderError(undefined); + actions.setAudioRecorderStatus("idle"); resetRecorderRefs(); }); mediaRecorder.start(); startedAtRef.current = Date.now(); - actions.setVoiceRecorderPermission("granted"); - actions.setVoiceRecorderStatus("recording"); + actions.setAudioRecorderPermission("granted"); + actions.setAudioRecorderStatus("recording"); elapsedTimerRef.current = window.setInterval(() => { if (startedAtRef.current) { - actions.setVoiceRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000))); + actions.setAudioRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000))); } }, 250); } catch (error) { + if (startRequestIdRef.current !== requestId) { + return; + } + const permissionDenied = error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError"); - actions.setVoiceRecorderPermission(permissionDenied ? "denied" : "unknown"); - actions.setVoiceRecorderStatus("error"); - actions.setVoiceRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start voice recording."); + actions.setAudioRecorderPermission(permissionDenied ? "denied" : "unknown"); + actions.setAudioRecorderStatus("error"); + actions.setAudioRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start audio recording."); resetRecorderRefs(); } }; @@ -177,11 +210,11 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { }; const resetRecording = () => { + startRequestIdRef.current += 1; resetRecorderRefs(); - actions.setVoiceRecorderElapsed(0); - actions.setVoiceRecorderError(undefined); - actions.setVoiceRecording(undefined); - actions.setVoiceRecorderStatus("idle"); + actions.setAudioRecorderElapsed(0); + actions.setAudioRecorderError(undefined); + actions.setAudioRecorderStatus("idle"); }; return { diff --git a/web/src/components/MemoEditor/hooks/useFileUpload.ts b/web/src/components/MemoEditor/hooks/useFileUpload.ts index a850c372a..7d867510b 100644 --- a/web/src/components/MemoEditor/hooks/useFileUpload.ts +++ b/web/src/components/MemoEditor/hooks/useFileUpload.ts @@ -17,6 +17,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void files.map((file) => ({ file, previewUrl: URL.createObjectURL(file), + origin: "upload", })), ); onFilesSelected(localFiles); diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 38798c312..018080f52 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -10,17 +10,17 @@ import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { convertVisibilityFromString } from "@/utils/memo"; import { + AudioRecorderPanel, EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover, - VoiceRecorderPanel, } from "./components"; import { FOCUS_MODE_STYLES } from "./constants"; import type { EditorRefActions } from "./Editor"; -import { useAutoSave, useFocusMode, useKeyboard, useMemoInit, useVoiceRecorder } from "./hooks"; +import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks"; import { cacheService, errorService, memoService, validationService } from "./services"; import { EditorProvider, useEditorContext } from "./state"; import type { MemoEditorProps } from "./types"; @@ -47,7 +47,7 @@ const MemoEditorImpl: React.FC = ({ const editorRef = useRef(null); const { state, actions, dispatch } = useEditorContext(); const { userGeneralSetting } = useAuth(); - const [isVoiceRecorderOpen, setIsVoiceRecorderOpen] = useState(false); + const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false); const memoName = memo?.name; @@ -62,72 +62,55 @@ const MemoEditorImpl: React.FC = ({ // Focus mode management with body scroll lock useFocusMode(state.ui.isFocusMode); - const voiceRecorderActions = useMemo( + const audioRecorderActions = useMemo( () => ({ - setVoiceRecorderSupport: (value: boolean) => dispatch(actions.setVoiceRecorderSupport(value)), - setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setVoiceRecorderPermission(value)), - setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => - dispatch(actions.setVoiceRecorderStatus(value)), - setVoiceRecorderElapsed: (value: number) => dispatch(actions.setVoiceRecorderElapsed(value)), - setVoiceRecorderError: (value?: string) => dispatch(actions.setVoiceRecorderError(value)), - setVoiceRecording: (value?: typeof state.voiceRecorder.recording) => dispatch(actions.setVoiceRecording(value)), + setAudioRecorderSupport: (value: boolean) => dispatch(actions.setAudioRecorderSupport(value)), + setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setAudioRecorderPermission(value)), + setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => + dispatch(actions.setAudioRecorderStatus(value)), + setAudioRecorderElapsed: (value: number) => dispatch(actions.setAudioRecorderElapsed(value)), + setAudioRecorderError: (value?: string) => dispatch(actions.setAudioRecorderError(value)), + onRecordingComplete: (localFile: (typeof state.localFiles)[number]) => { + dispatch(actions.addLocalFile(localFile)); + setIsAudioRecorderOpen(false); + }, }), - [actions, dispatch], + [actions, dispatch, state.localFiles], ); - const voiceRecorder = useVoiceRecorder(voiceRecorderActions); + const audioRecorder = useAudioRecorder(audioRecorderActions); + + useEffect(() => { + if (!isAudioRecorderOpen) { + return; + } + + if (state.audioRecorder.status === "error" || state.audioRecorder.status === "unsupported") { + toast.error(state.audioRecorder.error || t("editor.audio-recorder.error-description")); + setIsAudioRecorderOpen(false); + } + }, [isAudioRecorderOpen, state.audioRecorder.error, state.audioRecorder.status, t]); const handleToggleFocusMode = () => { dispatch(actions.toggleFocusMode()); }; - const handleStartVoiceRecording = async () => { - setIsVoiceRecorderOpen(true); - await voiceRecorder.startRecording(); + const handleStartAudioRecording = async () => { + setIsAudioRecorderOpen(true); + await audioRecorder.startRecording(); }; - const handleVoiceRecorderClick = () => { - setIsVoiceRecorderOpen(true); - - if ( - state.voiceRecorder.status === "recording" || - state.voiceRecorder.status === "requesting_permission" || - state.voiceRecorder.status === "recorded" - ) { + const handleAudioRecorderClick = () => { + if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") { return; } - void handleStartVoiceRecording(); + void handleStartAudioRecording(); }; - const handleKeepVoiceRecording = () => { - const recording = state.voiceRecorder.recording; - if (!recording) { - return; - } - - dispatch(actions.addLocalFile(recording.localFile)); - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); - }; - - const handleDiscardVoiceRecording = () => { - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); - }; - - const handleCloseVoiceRecorder = () => { - if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { - return; - } - - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); - }; - - const handleRecordAgain = async () => { - voiceRecorder.resetRecording(); - await handleStartVoiceRecording(); + const handleCancelAudioRecording = () => { + audioRecorder.resetRecording(); + setIsAudioRecorderOpen(false); }; useKeyboard(editorRef, handleSave); @@ -220,22 +203,18 @@ const MemoEditorImpl: React.FC = ({ {/* Editor content grows to fill available space in focus mode */} - {isVoiceRecorderOpen && ( - void handleStartVoiceRecording()} - onStop={voiceRecorder.stopRecording} - onKeep={handleKeepVoiceRecording} - onDiscard={handleDiscardVoiceRecording} - onRecordAgain={() => void handleRecordAgain()} - onClose={handleCloseVoiceRecorder} + {isAudioRecorderOpen && (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") && ( + )} {/* Metadata and toolbar grouped together at bottom */}
- +
diff --git a/web/src/components/MemoEditor/services/memoService.ts b/web/src/components/MemoEditor/services/memoService.ts index 0230aec42..20eee213b 100644 --- a/web/src/components/MemoEditor/services/memoService.ts +++ b/web/src/components/MemoEditor/services/memoService.ts @@ -142,13 +142,12 @@ export const memoService = { updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined, }, localFiles: [], - voiceRecorder: { + audioRecorder: { isSupported: true, permission: "unknown", status: "idle", elapsedSeconds: 0, error: undefined, - recording: undefined, }, }; }, diff --git a/web/src/components/MemoEditor/services/validationService.ts b/web/src/components/MemoEditor/services/validationService.ts index ac32c1504..8c2509dcb 100644 --- a/web/src/components/MemoEditor/services/validationService.ts +++ b/web/src/components/MemoEditor/services/validationService.ts @@ -22,9 +22,9 @@ export const validationService = { return { valid: false, reason: "Wait for upload to complete" }; } - // Cannot save while voice recorder is active - if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { - return { valid: false, reason: "Finish voice recording before saving" }; + // Cannot save while audio recorder is active + if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") { + return { valid: false, reason: "Finish audio recording before saving" }; } // Cannot save while already saving diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts index b58e6b7e2..392a96bb3 100644 --- a/web/src/components/MemoEditor/state/actions.ts +++ b/web/src/components/MemoEditor/state/actions.ts @@ -1,7 +1,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import type { LocalFile } from "../types/attachment"; -import type { EditorAction, EditorState, LoadingKey, VoiceRecorderPermission, VoiceRecorderStatus, VoiceRecordingPreview } from "./types"; +import type { AudioRecorderPermission, AudioRecorderStatus, EditorAction, EditorState, LoadingKey } from "./types"; export const editorActions = { initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({ @@ -77,33 +77,28 @@ export const editorActions = { payload: timestamps, }), - setVoiceRecorderSupport: (value: boolean): EditorAction => ({ - type: "SET_VOICE_RECORDER_SUPPORT", + setAudioRecorderSupport: (value: boolean): EditorAction => ({ + type: "SET_AUDIO_RECORDER_SUPPORT", payload: value, }), - setVoiceRecorderPermission: (value: VoiceRecorderPermission): EditorAction => ({ - type: "SET_VOICE_RECORDER_PERMISSION", + setAudioRecorderPermission: (value: AudioRecorderPermission): EditorAction => ({ + type: "SET_AUDIO_RECORDER_PERMISSION", payload: value, }), - setVoiceRecorderStatus: (value: VoiceRecorderStatus): EditorAction => ({ - type: "SET_VOICE_RECORDER_STATUS", + setAudioRecorderStatus: (value: AudioRecorderStatus): EditorAction => ({ + type: "SET_AUDIO_RECORDER_STATUS", payload: value, }), - setVoiceRecorderElapsed: (value: number): EditorAction => ({ - type: "SET_VOICE_RECORDER_ELAPSED", + setAudioRecorderElapsed: (value: number): EditorAction => ({ + type: "SET_AUDIO_RECORDER_ELAPSED", payload: value, }), - setVoiceRecorderError: (value?: string): EditorAction => ({ - type: "SET_VOICE_RECORDER_ERROR", - payload: value, - }), - - setVoiceRecording: (value?: VoiceRecordingPreview): EditorAction => ({ - type: "SET_VOICE_RECORDING", + setAudioRecorderError: (value?: string): EditorAction => ({ + type: "SET_AUDIO_RECORDER_ERROR", payload: value, }), diff --git a/web/src/components/MemoEditor/state/reducer.ts b/web/src/components/MemoEditor/state/reducer.ts index 59d4c0c0c..820282317 100644 --- a/web/src/components/MemoEditor/state/reducer.ts +++ b/web/src/components/MemoEditor/state/reducer.ts @@ -125,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS }, }; - case "SET_VOICE_RECORDER_SUPPORT": + case "SET_AUDIO_RECORDER_SUPPORT": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, isSupported: action.payload, - status: action.payload ? state.voiceRecorder.status : "unsupported", + status: action.payload ? state.audioRecorder.status : "unsupported", }, }; - case "SET_VOICE_RECORDER_PERMISSION": + case "SET_AUDIO_RECORDER_PERMISSION": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, permission: action.payload, }, }; - case "SET_VOICE_RECORDER_STATUS": + case "SET_AUDIO_RECORDER_STATUS": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, status: action.payload, }, }; - case "SET_VOICE_RECORDER_ELAPSED": + case "SET_AUDIO_RECORDER_ELAPSED": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, elapsedSeconds: action.payload, }, }; - case "SET_VOICE_RECORDER_ERROR": + case "SET_AUDIO_RECORDER_ERROR": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, error: action.payload, }, }; - case "SET_VOICE_RECORDING": - return { - ...state, - voiceRecorder: { - ...state.voiceRecorder, - recording: action.payload, - }, - }; - case "RESET": return { ...initialState, diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts index 3a38b545b..f06c33366 100644 --- a/web/src/components/MemoEditor/state/types.ts +++ b/web/src/components/MemoEditor/state/types.ts @@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; import type { LocalFile } from "../types/attachment"; export type LoadingKey = "saving" | "uploading" | "loading"; -export type VoiceRecorderPermission = "unknown" | "granted" | "denied"; -export type VoiceRecorderStatus = "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported"; - -export interface VoiceRecordingPreview { - localFile: LocalFile; - durationSeconds: number; - mimeType: string; -} +export type AudioRecorderPermission = "unknown" | "granted" | "denied"; +export type AudioRecorderStatus = "idle" | "requesting_permission" | "recording" | "error" | "unsupported"; export interface EditorState { content: string; @@ -35,13 +29,12 @@ export interface EditorState { updateTime?: Date; }; localFiles: LocalFile[]; - voiceRecorder: { + audioRecorder: { isSupported: boolean; - permission: VoiceRecorderPermission; - status: VoiceRecorderStatus; + permission: AudioRecorderPermission; + status: AudioRecorderStatus; elapsedSeconds: number; error?: string; - recording?: VoiceRecordingPreview; }; } @@ -61,12 +54,11 @@ export type EditorAction = | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } | { type: "SET_COMPOSING"; payload: boolean } | { type: "SET_TIMESTAMPS"; payload: Partial } - | { type: "SET_VOICE_RECORDER_SUPPORT"; payload: boolean } - | { type: "SET_VOICE_RECORDER_PERMISSION"; payload: VoiceRecorderPermission } - | { type: "SET_VOICE_RECORDER_STATUS"; payload: VoiceRecorderStatus } - | { type: "SET_VOICE_RECORDER_ELAPSED"; payload: number } - | { type: "SET_VOICE_RECORDER_ERROR"; payload?: string } - | { type: "SET_VOICE_RECORDING"; payload?: VoiceRecordingPreview } + | { type: "SET_AUDIO_RECORDER_SUPPORT"; payload: boolean } + | { type: "SET_AUDIO_RECORDER_PERMISSION"; payload: AudioRecorderPermission } + | { type: "SET_AUDIO_RECORDER_STATUS"; payload: AudioRecorderStatus } + | { type: "SET_AUDIO_RECORDER_ELAPSED"; payload: number } + | { type: "SET_AUDIO_RECORDER_ERROR"; payload?: string } | { type: "RESET" }; export const initialState: EditorState = { @@ -91,12 +83,11 @@ export const initialState: EditorState = { updateTime: undefined, }, localFiles: [], - voiceRecorder: { + audioRecorder: { isSupported: true, permission: "unknown", status: "idle", elapsedSeconds: 0, error: undefined, - recording: undefined, }, }; diff --git a/web/src/components/MemoEditor/types/attachment.ts b/web/src/components/MemoEditor/types/attachment.ts index 63cd2d8d6..2cb0d6756 100644 --- a/web/src/components/MemoEditor/types/attachment.ts +++ b/web/src/components/MemoEditor/types/attachment.ts @@ -15,14 +15,42 @@ export interface AttachmentItem { readonly sourceUrl: string; readonly size?: number; readonly isLocal: boolean; + readonly isVoiceNote: boolean; + readonly audioMeta?: LocalFile["audioMeta"]; } export interface LocalFile { readonly file: File; readonly previewUrl: string; + readonly origin?: "audio_recording" | "upload"; + readonly audioMeta?: { + readonly durationSeconds: number; + }; readonly motionMedia?: MotionMedia; } +const AUDIO_RECORDING_FILENAME_RE = /^(?:voice-(?:recording|note)|audio-recording)-(\d{8})-(\d{4,6})/i; + +export const isAudioRecordingFilename = (filename: string): boolean => AUDIO_RECORDING_FILENAME_RE.test(filename); + +export const getAudioRecordingTimeLabel = (filename: string): string | undefined => { + const match = filename.match(AUDIO_RECORDING_FILENAME_RE); + const timePart = match?.[2]; + if (!timePart) { + return undefined; + } + + if (timePart.length === 4) { + return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}`; + } + + if (timePart.length === 6) { + return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}`; + } + + return undefined; +}; + function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory { if (motionMedia) return "motion"; if (mimeType.startsWith("image/")) return "image"; @@ -45,6 +73,8 @@ function attachmentGroupToItem(attachment: Attachment): AttachmentItem { sourceUrl, size: Number(attachment.size), isLocal: false, + isVoiceNote: categorizeFile(attachment.type) === "audio" && isAudioRecordingFilename(attachment.filename), + audioMeta: undefined, }; } @@ -59,6 +89,8 @@ function visualItemToAttachmentItem(item: ReturnType total + Number(attachment.size), 0), isLocal: false, + isVoiceNote: false, + audioMeta: undefined, }; } @@ -73,6 +105,10 @@ function fileToItem(file: LocalFile): AttachmentItem { sourceUrl: file.previewUrl, size: file.file.size, isLocal: true, + isVoiceNote: + categorizeFile(file.file.type, file.motionMedia) === "audio" && + (file.origin === "audio_recording" || isAudioRecordingFilename(file.file.name)), + audioMeta: file.audioMeta, }; } @@ -111,6 +147,8 @@ function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] { sourceUrl: video.previewUrl, size: still.file.size + video.file.size, isLocal: true, + isVoiceNote: false, + audioMeta: undefined, }, ]; } diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 6069768b4..b8cee491b 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -23,21 +23,17 @@ export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; - onVoiceRecorderClick: () => void; + onAudioRecorderClick: () => void; } export interface EditorMetadataProps { memoName?: string; } -export interface VoiceRecorderPanelProps { - voiceRecorder: EditorState["voiceRecorder"]; - onStart: () => void; +export interface AudioRecorderPanelProps { + audioRecorder: EditorState["audioRecorder"]; onStop: () => void; - onKeep: () => void; - onDiscard: () => void; - onRecordAgain: () => void; - onClose: () => void; + onCancel: () => void; } export interface FocusModeOverlayProps { @@ -57,7 +53,7 @@ export interface InsertMenuProps { onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; memoName?: string; - onVoiceRecorderClick?: () => void; + onAudioRecorderClick?: () => void; } export interface TagSuggestionsProps { diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx index ee5c992d1..79c38a5ef 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx @@ -1,11 +1,15 @@ -import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react"; -import type { FC } from "react"; +import { ChevronDownIcon, ChevronUpIcon, FileAudioIcon, FileIcon, PaperclipIcon, PauseIcon, PlayIcon, XIcon } from "lucide-react"; +import { type FC, type MouseEvent, useMemo, useRef, useState } from "react"; import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment"; -import { toAttachmentItems } from "@/components/MemoEditor/types/attachment"; +import { getAudioRecordingTimeLabel, toAttachmentItems } from "@/components/MemoEditor/types/attachment"; import MetadataSection from "@/components/MemoMetadata/MetadataSection"; +import PreviewImageDialog from "@/components/PreviewImageDialog"; import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { formatFileSize, getFileTypeLabel } from "@/utils/format"; +import { useTranslate } from "@/utils/i18n"; +import type { PreviewMediaItem } from "@/utils/media-item"; +import { formatAudioTime } from "./attachmentHelpers"; interface AttachmentListEditorProps { attachments: Attachment[]; @@ -15,25 +19,173 @@ interface AttachmentListEditorProps { onRemoveLocalFile?: (previewUrl: string) => void; } -const AttachmentItemCard: FC<{ - item: AttachmentItem; +const AttachmentItemActions: FC<{ onRemove?: () => void; onMoveUp?: () => void; onMoveDown?: () => void; canMoveUp?: boolean; canMoveDown?: boolean; -}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { - const { category, filename, thumbnailUrl, mimeType, size } = item; +}> = ({ onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { + const stopPropagation = (event: MouseEvent) => { + event.stopPropagation(); + }; + + return ( +
+ {onMoveUp && ( + + )} + + {onMoveDown && ( + + )} + + {onRemove && ( + + )} +
+ ); +}; + +const AttachmentItemCard: FC<{ + item: AttachmentItem; + onPreview?: () => void; + onRemove?: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + canMoveUp?: boolean; + canMoveDown?: boolean; +}> = ({ item, onPreview, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { + const t = useTranslate(); + const { category, filename, thumbnailUrl, mimeType, size, sourceUrl, isVoiceNote, audioMeta } = item; + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType); - const fileSizeLabel = size ? formatFileSize(size) : undefined; - const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename; + const isPreviewable = category === "image" || category === "video" || category === "motion"; + const recordingTimeLabel = isVoiceNote ? getAudioRecordingTimeLabel(filename) : undefined; + const titleLabel = + isVoiceNote && recordingTimeLabel + ? t("editor.audio-recorder.attachment-label-with-time", { time: recordingTimeLabel }) + : isVoiceNote + ? t("editor.audio-recorder.attachment-label") + : filename; + const detailParts = [ + audioMeta?.durationSeconds ? formatAudioTime(audioMeta.durationSeconds) : undefined, + fileTypeLabel, + size ? formatFileSize(size) : undefined, + ].filter(Boolean); + + const handleAudioToggle = async (event: MouseEvent) => { + event.stopPropagation(); + + const audio = audioRef.current; + if (!audio) { + return; + } + + if (audio.paused) { + try { + await audio.play(); + } catch { + setIsPlaying(false); + } + return; + } + + audio.pause(); + }; return (
{(category === "image" || category === "motion") && thumbnailUrl ? ( - + + ) : isVoiceNote ? ( + <> + +
); @@ -117,13 +230,32 @@ const AttachmentListEditor: FC = ({ onLocalFilesChange, onRemoveLocalFile, }) => { - if (attachments.length === 0 && localFiles.length === 0) { - return null; - } - + const [previewState, setPreviewState] = useState<{ open: boolean; initialIndex: number }>({ open: false, initialIndex: 0 }); const items = toAttachmentItems(attachments, localFiles); const attachmentItems = items.filter((item) => !item.isLocal); const localItems = items.filter((item) => item.isLocal); + const previewItems = useMemo( + () => + items.reduce((acc, item) => { + if (item.category === "image") { + acc.push({ id: item.id, kind: "image", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } + + if (item.category === "video") { + acc.push({ id: item.id, kind: "video", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } + + if (item.category === "motion") { + acc.push({ id: item.id, kind: "motion", motionUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } + + return acc; + }, []), + [items], + ); const handleMoveAttachments = (itemId: string, direction: -1 | 1) => { if (!onAttachmentsChange) return; @@ -176,25 +308,52 @@ const AttachmentListEditor: FC = ({ } }; - return ( - - {items.map((item) => { - const itemList = item.isLocal ? localItems : attachmentItems; - const itemIndex = itemList.findIndex((entry) => entry.id === item.id); + const handlePreviewItem = (item: AttachmentItem) => { + const previewIndex = previewItems.findIndex((previewItem) => previewItem.id === item.id); + if (previewIndex < 0) { + return; + } - return ( - handleRemoveItem(item)} - onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)} - onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)} - canMoveUp={itemIndex > 0} - canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1} - /> - ); - })} - + setPreviewState({ open: true, initialIndex: previewIndex }); + }; + + if (items.length === 0) { + return null; + } + + return ( + <> + + {items.map((item) => { + const itemList = item.isLocal ? localItems : attachmentItems; + const itemIndex = itemList.findIndex((entry) => entry.id === item.id); + + return ( + handlePreviewItem(item) + : undefined + } + onRemove={() => handleRemoveItem(item)} + onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)} + onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)} + canMoveUp={itemIndex > 0} + canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1} + /> + ); + })} + + + setPreviewState((state) => ({ ...state, open }))} + items={previewItems} + initialIndex={previewState.initialIndex} + /> + ); }; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c8c58fc8e..a2569f42f 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -121,29 +121,38 @@ "any-thoughts": "Any thoughts...", "exit-focus-mode": "Exit Focus Mode", "focus-mode": "Focus Mode", + "insert-menu": { + "add-location": "Add location", + "link-memo": "Link memo", + "upload-file": "Upload file" + }, "no-changes-detected": "No changes detected", "save": "Save", "saving": "Saving...", "slash-commands": "Type `/` for commands", - "voice-recorder": { + "audio-recorder": { + "attachment-label": "Audio recording", + "attachment-label-with-time": "Audio recording {{time}}", "discard": "Discard", "error": "Microphone unavailable", "error-description": "Try again after checking microphone access for this site.", - "idle-description": "Start recording to add a voice note as an audio attachment.", + "idle-description": "Start recording to add an audio recording as an attachment.", "keep": "Keep recording", + "pause-recording": "Pause audio recording", + "play-recording": "Play audio recording", "ready": "Recording ready", "ready-description": "Preview the clip, then keep it as an audio attachment or discard it.", "record-again": "Record again", - "recording": "Recording voice note", + "recording": "Recording audio", "recording-description": "Capture a quick audio attachment. Current length: {{duration}}", "requesting": "Requesting access...", "requesting-permission": "Requesting microphone access", "requesting-permission-description": "Allow microphone access in your browser to start recording.", "start": "Start recording", "stop": "Stop recording", - "title": "Voice recorder", - "trigger": "Voice note", - "unsupported": "Voice recording unsupported", + "title": "Audio recorder", + "trigger": "Record audio", + "unsupported": "Audio recording unsupported", "unsupported-description": "This browser cannot record audio from the memo composer." } }, diff --git a/web/src/locales/tr.json b/web/src/locales/tr.json index 8abf0eb63..a39f2a9b6 100644 --- a/web/src/locales/tr.json +++ b/web/src/locales/tr.json @@ -121,16 +121,25 @@ "any-thoughts": "Düşünceleriniz...", "exit-focus-mode": "Odak modundan çık", "focus-mode": "Odak modu", + "insert-menu": { + "add-location": "Konum ekle", + "link-memo": "Not bağla", + "upload-file": "Dosya yükle" + }, "no-changes-detected": "Değişiklik yok", "save": "Kaydet", "saving": "Kaydediliyor...", "slash-commands": "Komutlar için `/` yazın", - "voice-recorder": { + "audio-recorder": { + "attachment-label": "Ses kaydı", + "attachment-label-with-time": "Ses kaydı {{time}}", "discard": "Sil", "error": "Mikrofon kullanılamıyor", "error-description": "Bu site için mikrofon erişimini kontrol ettikten sonra tekrar deneyin.", "idle-description": "Ses eki olarak bir sesli not eklemek için kayda başlayın.", "keep": "Sakla", + "pause-recording": "Ses kaydını duraklat", + "play-recording": "Ses kaydını oynat", "ready": "Kayıt hazır", "ready-description": "Klibi önizleyin, ardından ses eki olarak saklayın veya silin.", "record-again": "Tekrar kaydet",