diff --git a/web/src/components/AudioPlayer.tsx b/web/src/components/AudioPlayer.tsx index c05dbc4cc..a9fdf1103 100644 --- a/web/src/components/AudioPlayer.tsx +++ b/web/src/components/AudioPlayer.tsx @@ -18,6 +18,12 @@ const AudioPlayer = ({ src, className = "" }: Props) => { 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); @@ -57,7 +63,22 @@ const AudioPlayer = ({ src, className = "" }: Props) => { 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; @@ -68,6 +89,10 @@ const AudioPlayer = ({ src, className = "" }: Props) => { 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) { @@ -97,30 +122,29 @@ const AudioPlayer = ({ src, className = "" }: Props) => { return (
); }; diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx index 1fea285b3..ed320b4fe 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx @@ -1,6 +1,18 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; -import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MicIcon, MoreHorizontalIcon, PlusIcon, XIcon } 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"; @@ -136,7 +148,7 @@ const InsertMenu = observer((props: Props) => { 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"); + toast.error(error.message || "Failed to upload audio recording"); } }; @@ -148,13 +160,31 @@ const InsertMenu = observer((props: Props) => {
{new Date(audioRecorder.recordingTime * 1000).toISOString().substring(14, 19)}
- - - diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts b/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts index 9888bf76c..28c130b89 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu/useAudioRecorder.ts @@ -1,46 +1,60 @@ -import { useRef, useState } from "react"; - -interface AudioRecorderState { - isRecording: boolean; - isPaused: boolean; - recordingTime: number; - mediaRecorder: MediaRecorder | null; -} +import { useEffect, useRef, useState } from "react"; export const useAudioRecorder = () => { - const [state, setState] = useState({ - isRecording: false, - isPaused: false, - recordingTime: 0, - mediaRecorder: null, - }); + 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 { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mediaRecorder = new MediaRecorder(stream); + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); chunksRef.current = []; + durationRef.current = 0; + setRecordingTime(0); - mediaRecorder.ondataavailable = (e: BlobEvent) => { + recorder.ondataavailable = (e: BlobEvent) => { if (e.data.size > 0) { chunksRef.current.push(e.data); } }; - mediaRecorder.start(); - setState((prev: AudioRecorderState) => ({ ...prev, isRecording: true, mediaRecorder })); + recorder.start(); + mediaRecorderRef.current = recorder; + + setIsRecording(true); + setIsPaused(false); timerRef.current = window.setInterval(() => { - setState((prev) => { - if (prev.isPaused) { - return prev; - } - return { ...prev, recordingTime: prev.recordingTime + 1 }; - }); + 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; } @@ -48,73 +62,92 @@ export const useAudioRecorder = () => { 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()); - + // Cleanup timer immediately to prevent further updates if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } - setState({ - isRecording: false, - isPaused: false, - recordingTime: 0, - mediaRecorder: 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 = () => { - const { mediaRecorder } = state; - if (mediaRecorder) { - mediaRecorder.stop(); - mediaRecorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); - } - + // 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 = []; - setState({ - isRecording: false, - isPaused: false, - recordingTime: 0, - mediaRecorder: null, - }); + durationRef.current = 0; + + setIsRecording(false); + setIsPaused(false); + setRecordingTime(0); + + mediaRecorderRef.current = null; }; const togglePause = () => { - const { mediaRecorder, isPaused } = state; - if (!mediaRecorder) return; + const recorder = mediaRecorderRef.current; + if (!recorder) return; if (isPaused) { - mediaRecorder.resume(); + recorder.resume(); + setIsPaused(false); } else { - mediaRecorder.pause(); + recorder.pause(); + setIsPaused(true); } - - setState((prev) => ({ ...prev, isPaused: !prev.isPaused })); }; return { - isRecording: state.isRecording, - isPaused: state.isPaused, - recordingTime: state.recordingTime, + isRecording, + isPaused, + recordingTime, startRecording, stopRecording, cancelRecording, diff --git a/web/src/index.css b/web/src/index.css index 72dc40711..c4824043c 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; }