mirror of
https://github.com/laurent22/joplin.git
synced 2026-05-07 20:02:45 +00:00
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { PrimaryButton, SecondaryButton } from '../buttons';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Audio, InterruptionModeIOS } from 'expo-av';
|
||||
import { AudioQuality, getRecordingPermissionsAsync, IOSOutputFormat, requestRecordingPermissionsAsync, setAudioModeAsync, type RecordingOptions, useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState } from 'expo-audio';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnFileSavedCallback, RecorderState } from './types';
|
||||
import { Platform } from 'react-native';
|
||||
@@ -11,7 +11,6 @@ import FsDriverWeb from '../../utils/fs-driver/fs-driver-rn.web';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import RecordingControls from './RecordingControls';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { AndroidAudioEncoder, AndroidOutputFormat, IOSAudioQuality, IOSOutputFormat, RecordingOptions } from 'expo-av/build/Audio';
|
||||
import time from '@joplin/lib/time';
|
||||
import { toFileExtension } from '@joplin/lib/mime-utils';
|
||||
import { formatMsToDurationCompat, msleep } from '@joplin/utils/time';
|
||||
@@ -25,23 +24,21 @@ interface Props {
|
||||
|
||||
// Modified from the Expo default recording options to create
|
||||
// .m4a recordings on both Android and iOS (rather than .3gp on Android).
|
||||
const recordingOptions = (): RecordingOptions => ({
|
||||
const recordingOptions: RecordingOptions = {
|
||||
extension: '.m4a',
|
||||
isMeteringEnabled: true,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
android: {
|
||||
extension: '.m4a',
|
||||
outputFormat: AndroidOutputFormat.MPEG_4,
|
||||
audioEncoder: AndroidAudioEncoder.AAC,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
outputFormat: 'mpeg4',
|
||||
audioEncoder: 'aac',
|
||||
},
|
||||
ios: {
|
||||
extension: '.m4a',
|
||||
audioQuality: IOSAudioQuality.MIN,
|
||||
audioQuality: AudioQuality.MIN,
|
||||
outputFormat: IOSOutputFormat.MPEG4AAC,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
bitRate: 64000,
|
||||
linearPCMBitDepth: 16,
|
||||
linearPCMIsBigEndian: false,
|
||||
linearPCMIsFloat: false,
|
||||
@@ -56,14 +53,16 @@ const recordingOptions = (): RecordingOptions => ({
|
||||
].find(type => MediaRecorder.isTypeSupported(type)) ?? 'audio/webm',
|
||||
bitsPerSecond: 128000,
|
||||
} : {},
|
||||
});
|
||||
};
|
||||
|
||||
const getRecordingFileName = (extension: string) => {
|
||||
return `recording-${time.formatDateToLocal(new Date())}${extension}`;
|
||||
};
|
||||
|
||||
const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
let uri = recording.getURI();
|
||||
const recordingToSaveData = async (recordingUri: string|null) => {
|
||||
if (!recordingUri) throw new Error(_('Unable to access the recording file.'));
|
||||
|
||||
let uri = recordingUri;
|
||||
let type: string|undefined;
|
||||
let fileName;
|
||||
|
||||
@@ -73,7 +72,7 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
const fetchResult = await fetch(uri);
|
||||
const blob = await fetchResult.blob();
|
||||
|
||||
type = recordingOptions().web.mimeType;
|
||||
type = recordingOptions.web.mimeType;
|
||||
const extension = `.${toFileExtension(type)}`;
|
||||
fileName = getRecordingFileName(extension);
|
||||
const file = new File([blob], fileName);
|
||||
@@ -82,10 +81,9 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
await (shim.fsDriver() as FsDriverWeb).createReadOnlyVirtualFile(path, file);
|
||||
uri = path;
|
||||
} else {
|
||||
const options = recordingOptions();
|
||||
const extension = Platform.select({
|
||||
android: options.android.extension,
|
||||
ios: options.ios.extension,
|
||||
android: recordingOptions.android.extension,
|
||||
ios: recordingOptions.ios.extension,
|
||||
default: '',
|
||||
});
|
||||
fileName = getRecordingFileName(extension);
|
||||
@@ -95,75 +93,72 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
|
||||
};
|
||||
|
||||
const resetAudioMode = async () => {
|
||||
await Audio.setAudioModeAsync({
|
||||
// When enabled, iOS may use the small (phone call) speaker
|
||||
// instead of the default one, so it's disabled when not recording:
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: false,
|
||||
staysActiveInBackground: false,
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: false,
|
||||
allowsBackgroundRecording: false,
|
||||
playsInSilentMode: false,
|
||||
shouldPlayInBackground: false,
|
||||
});
|
||||
};
|
||||
|
||||
const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void) => {
|
||||
const [permissionResponse, requestPermissions] = Audio.usePermissions();
|
||||
const [recordingState, setRecordingState] = useState<RecorderState>(RecorderState.Idle);
|
||||
const [error, setError] = useState('');
|
||||
const [duration, setDuration] = useState(0);
|
||||
const recorder = useExpoAudioRecorder(recordingOptions);
|
||||
const recorderStatus = useAudioRecorderState(recorder, 100);
|
||||
const isRecordingRef = useRef(false);
|
||||
|
||||
const recordingRef = useRef<Audio.Recording|null>(null);
|
||||
const onStartRecording = useCallback(async () => {
|
||||
try {
|
||||
setRecordingState(RecorderState.Loading);
|
||||
setError('');
|
||||
|
||||
if (permissionResponse?.status !== 'granted') {
|
||||
const response = await requestPermissions();
|
||||
const permissionResponse = await getRecordingPermissionsAsync();
|
||||
if (permissionResponse.status !== 'granted') {
|
||||
const response = await requestRecordingPermissionsAsync();
|
||||
if (!response.granted) {
|
||||
throw new Error(_('Missing permission to record audio.'));
|
||||
}
|
||||
|
||||
// Work around "This experience is currently in the background, so the audio session could not be activated"
|
||||
// See https://github.com/expo/expo/issues/21782
|
||||
// May be resolved by migrating to expo-audio.
|
||||
await msleep(500);
|
||||
}
|
||||
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: true,
|
||||
allowsBackgroundRecording: true,
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
// Fixes an issue where opening a recording in the iOS audio player
|
||||
// breaks creating new recordings.
|
||||
// See https://github.com/expo/expo/issues/31152#issuecomment-2341811087
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
interruptionMode: 'doNotMix',
|
||||
});
|
||||
await recorder.prepareToRecordAsync();
|
||||
isRecordingRef.current = true;
|
||||
recorder.record();
|
||||
setRecordingState(RecorderState.Recording);
|
||||
const recording = new Audio.Recording();
|
||||
await recording.prepareToRecordAsync(recordingOptions());
|
||||
recording.setOnRecordingStatusUpdate(status => {
|
||||
setDuration(status.durationMillis);
|
||||
});
|
||||
recordingRef.current = recording;
|
||||
await recording.startAsync();
|
||||
} catch (error) {
|
||||
logger.error('Error starting recording:', error);
|
||||
setError(`Recording error: ${error}`);
|
||||
setRecordingState(RecorderState.Error);
|
||||
|
||||
void recordingRef.current?.stopAndUnloadAsync();
|
||||
recordingRef.current = null;
|
||||
if (isRecordingRef.current) {
|
||||
isRecordingRef.current = false;
|
||||
void recorder.stop();
|
||||
}
|
||||
}
|
||||
}, [permissionResponse, requestPermissions]);
|
||||
}, [recorder]);
|
||||
|
||||
const onStopRecording = useCallback(async () => {
|
||||
const recording = recordingRef.current;
|
||||
recordingRef.current = null;
|
||||
|
||||
try {
|
||||
setRecordingState(RecorderState.Processing);
|
||||
await recording.stopAndUnloadAsync();
|
||||
await recorder.stop();
|
||||
isRecordingRef.current = false;
|
||||
await resetAudioMode();
|
||||
|
||||
const saveEvent = await recordingToSaveData(recording);
|
||||
const saveEvent = await recordingToSaveData(recorder.uri);
|
||||
onFileSaved(saveEvent);
|
||||
onDismiss();
|
||||
} catch (error) {
|
||||
@@ -171,25 +166,35 @@ const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void
|
||||
setError(`Save error: ${error}`);
|
||||
setRecordingState(RecorderState.Error);
|
||||
}
|
||||
}, [onFileSaved, onDismiss]);
|
||||
}, [onFileSaved, onDismiss, recorder]);
|
||||
|
||||
const onStartStopRecording = useCallback(async () => {
|
||||
if (recordingState === RecorderState.Idle) {
|
||||
await onStartRecording();
|
||||
} else if (recordingState === RecorderState.Recording && recordingRef.current) {
|
||||
} else if (recordingState === RecorderState.Recording) {
|
||||
await onStopRecording();
|
||||
}
|
||||
}, [recordingState, onStartRecording, onStopRecording]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (recordingRef.current) {
|
||||
void recordingRef.current?.stopAndUnloadAsync();
|
||||
recordingRef.current = null;
|
||||
void resetAudioMode();
|
||||
}
|
||||
}, []);
|
||||
if (isRecordingRef.current) {
|
||||
isRecordingRef.current = false;
|
||||
|
||||
return { onStartStopRecording, error, duration, recordingState };
|
||||
const stopRecorderOnCleanup = async () => {
|
||||
try {
|
||||
await recorder.stop();
|
||||
} catch (error) {
|
||||
logger.warn('Error stopping recorder during cleanup:', error);
|
||||
}
|
||||
|
||||
await resetAudioMode();
|
||||
};
|
||||
|
||||
void stopRecorderOnCleanup();
|
||||
}
|
||||
}, [recorder]);
|
||||
|
||||
return { onStartStopRecording, error, duration: recorderStatus.durationMillis, recordingState };
|
||||
};
|
||||
|
||||
const AudioRecordingBanner: React.FC<Props> = props => {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
PODS:
|
||||
- EXAV (16.0.8):
|
||||
- ExpoModulesCore
|
||||
- ReactCommon/turbomodule/core
|
||||
- EXConstants (18.0.13):
|
||||
- ExpoModulesCore
|
||||
- EXImageLoader (6.0.0):
|
||||
@@ -34,6 +31,8 @@ PODS:
|
||||
- Yoga
|
||||
- ExpoAsset (12.0.12):
|
||||
- ExpoModulesCore
|
||||
- ExpoAudio (1.1.1):
|
||||
- ExpoModulesCore
|
||||
- ExpoCamera (17.0.10):
|
||||
- ExpoModulesCore
|
||||
- ZXingObjC/OneD
|
||||
@@ -72,9 +71,9 @@ PODS:
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- FBLazyVector (0.81.6)
|
||||
- hermes-engine (0.81.5):
|
||||
- hermes-engine/Pre-built (= 0.81.5)
|
||||
- hermes-engine/Pre-built (0.81.5)
|
||||
- hermes-engine (0.81.6):
|
||||
- hermes-engine/Pre-built (= 0.81.6)
|
||||
- hermes-engine/Pre-built (0.81.6)
|
||||
- JoplinCommonShareExtension (1.0.0)
|
||||
- JoplinRNShareExtension (1.0.0):
|
||||
- JoplinCommonShareExtension
|
||||
@@ -154,7 +153,7 @@ PODS:
|
||||
- React-utils
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- React-Core-prebuilt (0.81.5):
|
||||
- React-Core-prebuilt (0.81.6):
|
||||
- ReactNativeDependencies
|
||||
- React-Core/CoreModulesHeaders (0.81.6):
|
||||
- hermes-engine
|
||||
@@ -2134,7 +2133,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- SDWebImage/Core (5.21.5)
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- SDWebImageWebPCoder (0.15.0):
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.17)
|
||||
@@ -2169,11 +2168,11 @@ PODS:
|
||||
- ZXingObjC/Core
|
||||
|
||||
DEPENDENCIES:
|
||||
- EXAV (from `../node_modules/expo-av/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
||||
- Expo (from `../node_modules/expo`)
|
||||
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||
- ExpoAudio (from `../node_modules/expo-audio/ios`)
|
||||
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||
@@ -2293,8 +2292,6 @@ SPEC REPOS:
|
||||
- ZXingObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
EXAV:
|
||||
:path: "../node_modules/expo-av/ios"
|
||||
EXConstants:
|
||||
:path: "../node_modules/expo-constants/ios"
|
||||
EXImageLoader:
|
||||
@@ -2303,6 +2300,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/expo"
|
||||
ExpoAsset:
|
||||
:path: "../node_modules/expo-asset/ios"
|
||||
ExpoAudio:
|
||||
:path: "../node_modules/expo-audio/ios"
|
||||
ExpoCamera:
|
||||
:path: "../node_modules/expo-camera/ios"
|
||||
ExpoFileSystem:
|
||||
@@ -2522,11 +2521,11 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
|
||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||
Expo: 04993fbd7b06dc98ffac58da8847298470dc3db1
|
||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
||||
ExpoAudio: e4cfe3a2f3317b8487460685385a9867a07fb4fb
|
||||
ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e
|
||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
||||
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509
|
||||
@@ -2534,7 +2533,7 @@ SPEC CHECKSUMS:
|
||||
ExpoLocalAuthentication: 8a31808565da7af926dd9b595e98594d8b1553b6
|
||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
||||
FBLazyVector: 14ce6e3675cacb2683ad30272f04274a4ee5b67d
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
@@ -2546,7 +2545,7 @@ SPEC CHECKSUMS:
|
||||
React: 348d1689d8686d034c5b7667dc45de86c6319dd1
|
||||
React-callinvoker: 2c3b664f3482f5bc5560ea1edcbbe69748752f08
|
||||
React-Core: 346787852200a732b187805344b8a350d464e004
|
||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
||||
React-Core-prebuilt: 721ab014acfaff1e4b8fc0d2f7d6f41ea9a706ed
|
||||
React-CoreModules: 7e07391a1082d02c37f846a362f7574ab035933c
|
||||
React-cxxreact: c50d278c785792a077a6b357aaabd9e5d09e9c6f
|
||||
React-debug: 1b91785fec02ea76c793ead23bed1528d96b4262
|
||||
@@ -2635,7 +2634,7 @@ SPEC CHECKSUMS:
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0
|
||||
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
WhisperVoiceTyping: 343ea840cbde2a5f3508f8b016ebcf1c089179ea
|
||||
Yoga: 786fa7d9d2ff6060b4e688062243fa69c323d140
|
||||
|
||||
@@ -76,14 +76,41 @@ jest.mock('@react-native-clipboard/clipboard', () => {
|
||||
return { default: { getString: jest.fn(), setString: jest.fn() } };
|
||||
});
|
||||
|
||||
jest.doMock('expo-audio', () => {
|
||||
return {
|
||||
AudioQuality: {
|
||||
MIN: 'min',
|
||||
},
|
||||
IOSOutputFormat: {
|
||||
MPEG4AAC: 'mpeg4aac',
|
||||
},
|
||||
getRecordingPermissionsAsync: jest.fn(async () => ({
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
})),
|
||||
requestRecordingPermissionsAsync: jest.fn(async () => ({
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
})),
|
||||
setAudioModeAsync: jest.fn(async () => null),
|
||||
useAudioRecorder: jest.fn(() => ({
|
||||
prepareToRecordAsync: jest.fn(async () => null),
|
||||
record: jest.fn(),
|
||||
stop: jest.fn(async () => null),
|
||||
uri: null,
|
||||
})),
|
||||
useAudioRecorderState: jest.fn(() => ({
|
||||
durationMillis: 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const emptyMockPackages = [
|
||||
'react-native-share',
|
||||
'react-native-file-viewer',
|
||||
'react-native-image-picker',
|
||||
'@react-native-documents/picker',
|
||||
'@joplin/react-native-saf-x',
|
||||
'expo-av',
|
||||
'expo-av/build/Audio',
|
||||
'expo-image-manipulator',
|
||||
];
|
||||
for (const packageName of emptyMockPackages) {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"deprecated-react-native-prop-types": "5.0.0",
|
||||
"events": "3.3.0",
|
||||
"expo": "54.0.31",
|
||||
"expo-av": "16.0.8",
|
||||
"expo-audio": "1.1.1",
|
||||
"expo-camera": "17.0.10",
|
||||
"expo-image-manipulator": "14.0.8",
|
||||
"expo-local-authentication": "17.0.8",
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@
|
||||
"browserify",
|
||||
"codemirror",
|
||||
"cspell",
|
||||
"expo-av", // Must be updated with expo
|
||||
"expo-audio", // Must be updated with expo
|
||||
"file-loader",
|
||||
"gradle",
|
||||
"html-webpack-plugin",
|
||||
|
||||
@@ -10827,7 +10827,7 @@ __metadata:
|
||||
esbuild: "npm:0.27.2"
|
||||
events: "npm:3.3.0"
|
||||
expo: "npm:54.0.31"
|
||||
expo-av: "npm:16.0.8"
|
||||
expo-audio: "npm:1.1.1"
|
||||
expo-camera: "npm:17.0.10"
|
||||
expo-image-manipulator: "npm:14.0.8"
|
||||
expo-local-authentication: "npm:17.0.8"
|
||||
@@ -28797,18 +28797,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"expo-av@npm:16.0.8":
|
||||
version: 16.0.8
|
||||
resolution: "expo-av@npm:16.0.8"
|
||||
"expo-audio@npm:1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "expo-audio@npm:1.1.1"
|
||||
peerDependencies:
|
||||
expo: "*"
|
||||
expo-asset: "*"
|
||||
react: "*"
|
||||
react-native: "*"
|
||||
react-native-web: "*"
|
||||
peerDependenciesMeta:
|
||||
react-native-web:
|
||||
optional: true
|
||||
checksum: 10/c274ae6b98e30b673d2dd03119da703a379c01367466022fa1d2dc1b17ce9475711488132d2c7271a88ecda3800c5d03697371761fa8330e40ca0c02f9b951fa
|
||||
checksum: 10/b2013e196eb56d6b31f62d82e3918d81626b8d48bd3b9c3b50d8d97b8329de8ec1cb700cb61cc03b637371213aa2de78fec10208e742e8e2a2019dbf25777814
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user