diff --git a/web/src/components/AudioPlayer.tsx b/web/src/components/AudioPlayer.tsx new file mode 100644 index 0000000000000..a9fdf11035904 --- /dev/null +++ b/web/src/components/AudioPlayer.tsx @@ -0,0 +1,152 @@ +import { PauseIcon, PlayIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; + +interface Props { + src: string; + className?: string; +} + +const AudioPlayer = ({ src, className = "" }: Props) => { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + // Reset state when src changes + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + setIsLoading(true); + + const handleLoadedMetadata = () => { + if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) { + setDuration(audio.duration); + } + setIsLoading(false); + }; + + const handleTimeUpdate = () => { + setCurrentTime(audio.currentTime); + if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) { + setDuration((prev) => (prev === 0 ? audio.duration : prev)); + } + }; + + const handleEnded = () => { + setIsPlaying(false); + setCurrentTime(0); + }; + + const handleLoadedData = () => { + // For files without proper duration in metadata, + // try to get it after some data is loaded + if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) { + setDuration(audio.duration); + setIsLoading(false); + } + }; + + audio.addEventListener("loadedmetadata", handleLoadedMetadata); + audio.addEventListener("loadeddata", handleLoadedData); + audio.addEventListener("timeupdate", handleTimeUpdate); + audio.addEventListener("ended", handleEnded); + + return () => { + audio.removeEventListener("loadedmetadata", handleLoadedMetadata); + audio.removeEventListener("loadeddata", handleLoadedData); + audio.removeEventListener("timeupdate", handleTimeUpdate); + audio.removeEventListener("ended", handleEnded); + }; + }, [src]); + + useEffect(() => { + const handlePlayAudio = (e: Event) => { + const customEvent = e as CustomEvent; + if (customEvent.detail !== audioRef.current && isPlaying) { + audioRef.current?.pause(); + setIsPlaying(false); + } + }; + + document.addEventListener("play-audio", handlePlayAudio); + return () => { + document.removeEventListener("play-audio", handlePlayAudio); + }; + }, [isPlaying]); + + const togglePlayPause = async () => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + setIsPlaying(false); + } else { + try { + // Stop other audio players + const event = new CustomEvent("play-audio", { detail: audio }); + document.dispatchEvent(event); + + await audio.play(); + setIsPlaying(true); + } catch (error) { + console.error("Failed to play audio:", error); + setIsPlaying(false); + } + } + }; + + const handleSeek = (e: React.ChangeEvent) => { + const audio = audioRef.current; + if (!audio) return; + + const newTime = parseFloat(e.target.value); + audio.currentTime = newTime; + setCurrentTime(newTime); + }; + + const formatTime = (time: number): string => { + if (!isFinite(time) || isNaN(time)) return "0:00"; + + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + + return ( +
+
+ ); +}; + +export default AudioPlayer; diff --git a/web/src/components/MemoAttachment.tsx b/web/src/components/MemoAttachment.tsx index 2c6e318b5165c..1833614b336e1 100644 --- a/web/src/components/MemoAttachment.tsx +++ b/web/src/components/MemoAttachment.tsx @@ -1,6 +1,7 @@ import { Attachment } from "@/types/proto/api/v1/attachment_service"; import { getAttachmentUrl, isMidiFile } from "@/utils/attachment"; import AttachmentIcon from "./AttachmentIcon"; +import AudioPlayer from "./AudioPlayer"; interface Props { attachment: Attachment; @@ -20,7 +21,7 @@ const MemoAttachment: React.FC = (props: Props) => { className={`w-auto flex flex-row justify-start items-center text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors ${className}`} > {attachment.type.startsWith("audio") && !isMidiFile(attachment.type) ? ( - + ) : ( <> diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx index 6de3991b59178..ed320b4fe3ec1 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx @@ -1,8 +1,21 @@ 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, + PauseIcon, + PlayIcon, + 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 +26,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 +67,7 @@ const InsertMenu = observer((props: Props) => { }); const location = useLocation(props.location); + const audioRecorder = useAudioRecorder(); const isUploading = uploadingFlag || props.isUploading; @@ -112,43 +128,107 @@ 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.message || "Failed to upload audio recording"); + } + }; + return ( <> - - - + + - - - - - {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 - - - - - + + ) : ( + + + + + + + + {t("common.upload")} + + setLinkDialogOpen(true)}> + + {t("tooltip.link-memo")} + + + + {t("tooltip.select-location")} + + + + {t("tooltip.record-audio")} + + {/* View submenu with Focus Mode */} + + + + {t("common.more")} + + + + + {t("editor.focus-mode")} + ⌘⇧F + + + + + + )} {/* Hidden file input */} { + const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [recordingTime, setRecordingTime] = useState(0); + + const chunksRef = useRef([]); + const timerRef = useRef(null); + const durationRef = useRef(0); + const mediaRecorderRef = useRef(null); + + useEffect(() => { + return () => { + if (mediaRecorderRef.current) { + mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop()); + mediaRecorderRef.current = null; + } + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, []); + + const startRecording = async () => { + let stream: MediaStream | null = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + chunksRef.current = []; + durationRef.current = 0; + setRecordingTime(0); + + recorder.ondataavailable = (e: BlobEvent) => { + if (e.data.size > 0) { + chunksRef.current.push(e.data); + } + }; + + recorder.start(); + mediaRecorderRef.current = recorder; + + setIsRecording(true); + setIsPaused(false); + + timerRef.current = window.setInterval(() => { + if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "paused") { + return; + } + durationRef.current += 1; + setRecordingTime(durationRef.current); + }, 1000); + } catch (error) { + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + console.error("Error accessing microphone:", error); + throw error; + } + }; + + const stopRecording = (): Promise => { + return new Promise((resolve, reject) => { + // Cleanup timer immediately to prevent further updates + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + const recorder = mediaRecorderRef.current; + if (!recorder) { + reject(new Error("No active recording")); + return; + } + + let isResolved = false; + + const finalize = () => { + if (isResolved) return; + isResolved = true; + + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + chunksRef.current = []; + durationRef.current = 0; + + setIsRecording(false); + setIsPaused(false); + setRecordingTime(0); + + mediaRecorderRef.current = null; + + resolve(blob); + }; + + recorder.onstop = finalize; + + try { + recorder.stop(); + recorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + } catch (error) { + // Ignore errors during stop, as we'll finalize anyway + console.warn("Error stopping media recorder:", error); + } + + // Safety timeout in case onstop never fires + setTimeout(finalize, 1000); + }); + }; + + const cancelRecording = () => { + // Cleanup timer immediately + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + const recorder = mediaRecorderRef.current; + if (recorder) { + recorder.stop(); + recorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + } + + chunksRef.current = []; + durationRef.current = 0; + + setIsRecording(false); + setIsPaused(false); + setRecordingTime(0); + + mediaRecorderRef.current = null; + }; + + const togglePause = () => { + const recorder = mediaRecorderRef.current; + if (!recorder) return; + + if (isPaused) { + recorder.resume(); + setIsPaused(false); + } else { + recorder.pause(); + setIsPaused(true); + } + }; + + return { + isRecording, + isPaused, + recordingTime, + startRecording, + stopRecording, + cancelRecording, + togglePause, + }; +}; diff --git a/web/src/index.css b/web/src/index.css index 72dc407114554..c4824043cd5dd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -10,6 +10,7 @@ * { @apply border-border outline-none ring-0; } + body { @apply bg-background text-foreground; } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 9ab89ee6252f0..a0a0c306d7002 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -477,6 +477,7 @@ "select-location": "Location", "select-visibility": "Visibility", "tags": "Tags", - "upload-attachment": "Upload Attachment(s)" + "upload-attachment": "Upload Attachment(s)", + "record-audio": "Record Audio" } }