diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 9998c6b..5549916 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -23,7 +23,7 @@ Legend: 14. Delivery Status - `DONE` (sent/delivered/read + reconnect reconciliation after backend restarts) 15. Typing Realtime - `PARTIAL` (typing start/stop done; voice/video typing signals limited) 16. Media & Attachments - `DONE` (upload/preview/download/gallery) -17. Voice Messages - `PARTIAL` (record/send/play/seek + global speed 1x/1.5x/2x; UX still being polished) +17. Voice Messages - `PARTIAL` (record/send/play/seek + global speed 1x/1.5x/2x; recorder now uses mime fallback + chunked capture; UX still being polished) 18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic) 19. Stickers - `PARTIAL` (web sticker picker with preset pack + favorites) 20. GIF - `PARTIAL` (web GIF picker with Tenor search + preset fallback + favorites) diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index d40ae60..2573608 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -59,6 +59,24 @@ function saveFavorites(key: string, values: Set): void { } } +function pickSupportedAudioMimeType(): string | undefined { + if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") { + return undefined; + } + const candidates = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/mp4", + "audio/ogg;codecs=opus", + ]; + for (const mime of candidates) { + if (MediaRecorder.isTypeSupported(mime)) { + return mime; + } + } + return undefined; +} + export function MessageComposer() { const activeChatId = useChatStore((s) => s.activeChatId); const chats = useChatStore((s) => s.chats); @@ -562,7 +580,8 @@ export function MessageComposer() { } } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const recorder = new MediaRecorder(stream); + const mimeType = pickSupportedAudioMimeType(); + const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); recordingStreamRef.current = stream; chunksRef.current = []; sendVoiceOnStopRef.current = true; @@ -584,13 +603,19 @@ export function MessageComposer() { setUploadError("Voice message is too short. Minimum length is 1 second."); return; } - const blob = new Blob(data, { type: "audio/webm" }); - const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); + const outputMime = recorder.mimeType || mimeType || "audio/webm"; + const blob = new Blob(data, { type: outputMime }); + if (blob.size < 512) { + setUploadError("Voice message is empty. Please try recording again."); + return; + } + const ext = outputMime.includes("ogg") ? "ogg" : outputMime.includes("mp4") ? "m4a" : "webm"; + const file = new File([blob], `voice-${Date.now()}.${ext}`, { type: outputMime }); const waveform = await buildWaveformPoints(blob, 64); await handleUpload(file, "voice", waveform); }; recorderRef.current = recorder; - recorder.start(); + recorder.start(250); recordingStartedAtRef.current = Date.now(); setRecordSeconds(0); setRecordingState("recording");