mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): record voice messages in a widely playable container (#1973)
This commit is contained in:
1 parent
bf11445299
commit
fdd12194b1
1 file changed
+54
-6
@@ -6,7 +6,11 @@ import styles from '@app/features/channel/components/VoiceMessageRecorder.module
|
|||||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
||||||
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
|
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
|
||||||
import {prepareVoiceMessageWav} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
|
import {
|
||||||
|
computeVoiceWaveformFromAudioBuffer,
|
||||||
|
type PreparedVoiceMessage,
|
||||||
|
prepareVoiceMessageWav,
|
||||||
|
} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
|
||||||
import {sendVoiceMessage} from '@app/features/voice/utils/VoiceMessageSendUtils';
|
import {sendVoiceMessage} from '@app/features/voice/utils/VoiceMessageSendUtils';
|
||||||
import {autoUpdate, FloatingPortal, flip, offset, shift, useFloating} from '@floating-ui/react';
|
import {autoUpdate, FloatingPortal, flip, offset, shift, useFloating} from '@floating-ui/react';
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +71,43 @@ const SEND_VOICE_MESSAGE_DESCRIPTOR = msg({
|
|||||||
comment: 'Accessible label and tooltip for the send button after recording a voice message.',
|
comment: 'Accessible label and tooltip for the send button after recording a voice message.',
|
||||||
});
|
});
|
||||||
const logger = new Logger('VoiceMessageRecorder');
|
const logger = new Logger('VoiceMessageRecorder');
|
||||||
|
const UNIVERSALLY_PLAYABLE_RECORDING_MIME = 'audio/mp4';
|
||||||
|
const OPUS_RECORDING_MIME_CANDIDATES = ['audio/ogg;codecs=opus', 'audio/webm;codecs=opus'] as const;
|
||||||
|
|
||||||
|
export function selectRecordingMimeType(isTypeSupported: (mimeType: string) => boolean): string | undefined {
|
||||||
|
if (isTypeSupported(UNIVERSALLY_PLAYABLE_RECORDING_MIME)) {
|
||||||
|
return UNIVERSALLY_PLAYABLE_RECORDING_MIME;
|
||||||
|
}
|
||||||
|
return OPUS_RECORDING_MIME_CANDIDATES.find((candidate) => isTypeSupported(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUniversallyPlayableContainer(mimeType: string): boolean {
|
||||||
|
return mimeType.split(';')[0].trim().toLowerCase() === UNIVERSALLY_PLAYABLE_RECORDING_MIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareUniversalUpload(blob: Blob, filename: string): Promise<PreparedVoiceMessage> {
|
||||||
|
const contextClass =
|
||||||
|
window.AudioContext || (window as typeof window & {webkitAudioContext?: typeof AudioContext}).webkitAudioContext;
|
||||||
|
if (!contextClass) {
|
||||||
|
throw new Error('AudioContext is unavailable; cannot measure voice message');
|
||||||
|
}
|
||||||
|
const audioContext = new contextClass();
|
||||||
|
try {
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(await blob.arrayBuffer());
|
||||||
|
const {duration, waveform} = computeVoiceWaveformFromAudioBuffer(audioBuffer);
|
||||||
|
return {file: new File([blob], filename, {type: blob.type}), duration, waveform};
|
||||||
|
} finally {
|
||||||
|
audioContext.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareVoiceMessageUpload(blob: Blob): Promise<PreparedVoiceMessage> {
|
||||||
|
const stamp = Date.now();
|
||||||
|
if (isUniversallyPlayableContainer(blob.type)) {
|
||||||
|
return prepareUniversalUpload(blob, `voice-message-${stamp}.m4a`);
|
||||||
|
}
|
||||||
|
return prepareVoiceMessageWav(blob, `voice-message-${stamp}.wav`);
|
||||||
|
}
|
||||||
|
|
||||||
type StopAction = 'send' | 'discard';
|
type StopAction = 'send' | 'discard';
|
||||||
|
|
||||||
@@ -322,13 +363,13 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
|||||||
resetState();
|
resetState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = new Blob(recordedChunks, {type: recorderMime || 'audio/ogg'});
|
const blob = new Blob(recordedChunks, {type: recorderMime || OPUS_RECORDING_MIME_CANDIDATES[0]});
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsLocked(false);
|
setIsLocked(false);
|
||||||
setLockPreview(false);
|
setLockPreview(false);
|
||||||
try {
|
try {
|
||||||
const {file, duration, waveform} = await prepareVoiceMessageWav(blob, `voice-message-${Date.now()}.wav`);
|
const {file, duration, waveform} = await prepareVoiceMessageUpload(blob);
|
||||||
await sendVoiceMessage({
|
await sendVoiceMessage({
|
||||||
channelId,
|
channelId,
|
||||||
file,
|
file,
|
||||||
@@ -351,11 +392,15 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
|||||||
showTooltip(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
|
showTooltip(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const preferredType = selectRecordingMimeType((candidate) => MediaRecorder.isTypeSupported(candidate));
|
||||||
|
if (preferredType === undefined) {
|
||||||
|
showTooltip(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let acquiredStream: MediaStream | null = null;
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||||
const preferredType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
|
acquiredStream = stream;
|
||||||
? 'audio/ogg;codecs=opus'
|
|
||||||
: 'audio/webm;codecs=opus';
|
|
||||||
const recorder = new MediaRecorder(stream, {mimeType: preferredType});
|
const recorder = new MediaRecorder(stream, {mimeType: preferredType});
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
recorder.addEventListener('dataavailable', handleDataAvailable);
|
recorder.addEventListener('dataavailable', handleDataAvailable);
|
||||||
@@ -379,6 +424,9 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
|||||||
startWaveform(stream);
|
startWaveform(stream);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to start voice recording', error);
|
logger.error('Failed to start voice recording', error);
|
||||||
|
if (acquiredStream !== null && streamRef.current !== acquiredStream) {
|
||||||
|
acquiredStream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
showTooltip(i18n._(UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR));
|
showTooltip(i18n._(UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR));
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
Reference in new issue
Block a user