diff --git a/messages/en.json b/messages/en.json
index fcaa7afe1..a7da286ee 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -25,6 +25,21 @@
"description": "Title of dialog that shows when cancelling a new observation",
"message": "Discard observation?"
},
+ "AudioPlaybackScreen.DeleteAudioRecordingModal.successDescription": {
+ "message": "Your {audioRecording} was added."
+ },
+ "AudioPlaybackScreen.DeleteAudioRecordingModal.successTitle": {
+ "message": "Success!"
+ },
+ "AudioPlaybackScreen.SuccessAudioRecordingModal.audioRecording": {
+ "message": "Audio Recording"
+ },
+ "AudioPlaybackScreen.SuccessAudioRecordingModal.recordAnother": {
+ "message": "Record Another"
+ },
+ "AudioPlaybackScreen.SuccessAudioRecordingModal.returnToEditor": {
+ "message": "Return to Editor"
+ },
"Modal.GPSDisable.button": {
"message": "Enable"
},
@@ -360,6 +375,27 @@
"description": "Screen title for audio permission screen",
"message": "Recording Audio with CoMapeo"
},
+ "screens.AudioScreen.CreateRecording.RecordingActive.description": {
+ "message": "Less than {length} {length, plural, one {minute} other {minutes}} left"
+ },
+ "screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetDescription": {
+ "message": "Your Audio Recording will be permanently deleted. This cannot be undone."
+ },
+ "screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetPrimaryButtonText": {
+ "message": "Delete"
+ },
+ "screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetSecondaryButtonText": {
+ "message": "Cancel"
+ },
+ "screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetTitle": {
+ "message": "Delete?"
+ },
+ "screens.AudioScreen.CreateRecording.RecordingIdle.description": {
+ "message": "Record up to {length} {length, plural, one {minute} other {minutes}}"
+ },
+ "screens.AudioScreen.Playback.description": {
+ "message": "Total length: {length}"
+ },
"screens.CameraScreen.goToSettings": {
"message": "Go to Settings"
},
diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts
index 24a477cda..c038a4424 100644
--- a/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts
+++ b/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts
@@ -6,7 +6,11 @@ import {
SavedPhoto,
} from '../../../contexts/PhotoPromiseContext/types';
import {deletePhoto, replaceDraftPhotos} from './photosMethods';
-import {ClientGeneratedObservation, Position} from '../../../sharedTypes';
+import {
+ ClientGeneratedObservation,
+ Position,
+ AudioRecording,
+} from '../../../sharedTypes';
import {Observation, Preset} from '@comapeo/schema';
import {usePresetsQuery} from '../../server/presets';
import {matchPreset} from '../../../lib/utils';
@@ -23,7 +27,7 @@ const emptyObservation: ClientGeneratedObservation = {
export type DraftObservationSlice = {
photos: Photo[];
- audioRecordings: [];
+ audioRecordings: AudioRecording[];
value: Observation | null | ClientGeneratedObservation;
observationId?: string;
actions: {
@@ -53,6 +57,7 @@ export type DraftObservationSlice = {
) => void;
updateTags: (tagKey: string, value: Observation['tags'][0]) => void;
updatePreset: (preset: Preset) => void;
+ addAudioRecording: (audioRecording: AudioRecording) => void;
};
};
@@ -173,6 +178,10 @@ const draftObservationSlice: StateCreator = (
},
});
},
+ addAudioRecording: recording =>
+ set({
+ audioRecordings: [...get().audioRecordings, recording],
+ }),
},
});
diff --git a/src/frontend/hooks/server/media.ts b/src/frontend/hooks/server/media.ts
index 6fe1980ee..7ead94048 100644
--- a/src/frontend/hooks/server/media.ts
+++ b/src/frontend/hooks/server/media.ts
@@ -7,6 +7,7 @@ import {useActiveProject} from '../../contexts/ActiveProjectContext';
import {ProcessedDraftPhoto} from '../../contexts/PhotoPromiseContext/types';
import type {MapeoProjectApi} from '@comapeo/ipc';
import {ClientApi} from 'rpc-reflector';
+import {AudioRecording} from '../../sharedTypes';
export function useCreateBlobMutation(opts: {retry?: number} = {}) {
const {projectApi} = useActiveProject();
@@ -34,6 +35,27 @@ export function useCreateBlobMutation(opts: {retry?: number} = {}) {
});
}
+export function useCreateAudioBlobMutation(opts: {retry?: number} = {}) {
+ const {projectApi} = useActiveProject();
+
+ return useMutation({
+ retry: opts.retry,
+ mutationFn: async (audio: AudioRecording) => {
+ const {uri, createdAt} = audio;
+
+ return projectApi.$blobs.create(
+ {
+ original: new URL(uri).pathname,
+ },
+ {
+ mimeType: 'audio/mp4',
+ timestamp: createdAt,
+ },
+ );
+ },
+ });
+}
+
const resolveAttachmentUrlQueryOptions = (
projectId: string,
projectApi: ClientApi,
diff --git a/src/frontend/hooks/useDraftObservation.ts b/src/frontend/hooks/useDraftObservation.ts
index 084be8b71..c45a3c648 100644
--- a/src/frontend/hooks/useDraftObservation.ts
+++ b/src/frontend/hooks/useDraftObservation.ts
@@ -29,6 +29,7 @@ export const useDraftObservation = () => {
updateTags,
updatePreset,
existingObservationToDraft,
+ addAudioRecording,
} = _usePersistedDraftObservationActions();
const addPhoto = useCallback(
@@ -102,5 +103,6 @@ export const useDraftObservation = () => {
updatePreset,
usePreset,
existingObservationToDraft,
+ addAudioRecording,
};
};
diff --git a/src/frontend/images/PlayArrow.svg b/src/frontend/images/PlayArrow.svg
new file mode 100644
index 000000000..31800da4f
--- /dev/null
+++ b/src/frontend/images/PlayArrow.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/frontend/screens/Audio/AnimatedBackground.tsx b/src/frontend/screens/Audio/AnimatedBackground.tsx
new file mode 100644
index 000000000..4ca6630cd
--- /dev/null
+++ b/src/frontend/screens/Audio/AnimatedBackground.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import {Dimensions, StyleSheet} from 'react-native';
+import Animated, {SharedValue, useAnimatedStyle} from 'react-native-reanimated';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
+
+import {MAX_RECORDING_DURATION_MS} from './index';
+
+export function AnimatedBackground({
+ elapsedTimeValue,
+}: {
+ elapsedTimeValue: SharedValue;
+}) {
+ const {top} = useSafeAreaInsets();
+ const {height} = Dimensions.get('window');
+
+ const animatedStyles = useAnimatedStyle(() => ({
+ height:
+ (height + top) *
+ (elapsedTimeValue.value * (1 / MAX_RECORDING_DURATION_MS)),
+ backgroundColor: `hsl(216, 100%, ${elapsedTimeValue.value * (1 / MAX_RECORDING_DURATION_MS) * 50}%)`,
+ }));
+
+ return ;
+}
+
+const styles = StyleSheet.create({
+ fill: {
+ position: 'absolute',
+ zIndex: -1,
+ bottom: 0,
+ width: '100%',
+ },
+});
diff --git a/src/frontend/screens/Audio/ContentWithControls.tsx b/src/frontend/screens/Audio/ContentWithControls.tsx
new file mode 100644
index 000000000..f16d8bbb7
--- /dev/null
+++ b/src/frontend/screens/Audio/ContentWithControls.tsx
@@ -0,0 +1,79 @@
+import React, {ReactNode} from 'react';
+import {StyleSheet, View} from 'react-native';
+import {Bar} from 'react-native-progress';
+import {Duration} from 'luxon';
+
+import {MEDIUM_GREY, WHITE} from '../../lib/styles';
+import {ScreenContentWithDock} from '../../sharedComponents/ScreenContentWithDock';
+import {Text} from '../../sharedComponents/Text';
+import {Row as ControlsRow} from './Controls';
+
+export function ContentWithControls({
+ controls,
+ message,
+ progress,
+ timeElapsed,
+}: {
+ controls: ReactNode;
+ message?: string;
+ progress?: number;
+ timeElapsed: number;
+}) {
+ return (
+ {controls}}>
+
+
+
+ {Duration.fromMillis(timeElapsed).toFormat('mm:ss')}
+
+
+ {typeof progress === 'number' ? (
+ 0 ? progress : 0.00000001}
+ indeterminate={false}
+ width={null}
+ color={WHITE}
+ borderColor="transparent"
+ borderRadius={0}
+ borderWidth={0}
+ unfilledColor={MEDIUM_GREY}
+ />
+ ) : (
+
+ )}
+
+ {message}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ contentContainer: {flex: 1},
+ dockContainer: {paddingVertical: 24},
+ container: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ },
+ timerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ gap: 48,
+ },
+ message: {
+ color: WHITE,
+ fontSize: 20,
+ textAlign: 'center',
+ },
+ timerText: {
+ fontFamily: 'Rubik',
+ fontSize: 96,
+ fontWeight: 'bold',
+ color: WHITE,
+ textAlign: 'center',
+ },
+});
diff --git a/src/frontend/screens/Audio/Controls.tsx b/src/frontend/screens/Audio/Controls.tsx
new file mode 100644
index 000000000..1090b2c1c
--- /dev/null
+++ b/src/frontend/screens/Audio/Controls.tsx
@@ -0,0 +1,113 @@
+import React, {PropsWithChildren} from 'react';
+import {
+ Dimensions,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import PlayArrow from '../../images/PlayArrow.svg';
+import {MAGENTA, BLACK, LIGHT_GREY, WHITE} from '../../lib/styles';
+
+type BaseProps = PropsWithChildren;
+
+function ControlButtonPrimaryBase({children, ...touchableProps}: BaseProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function Record(props: BaseProps) {
+ return (
+
+
+
+ );
+}
+
+export function Stop(props: BaseProps) {
+ return (
+
+
+
+ );
+}
+
+export function Play(props: BaseProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function Side({
+ children,
+ side,
+}: PropsWithChildren<{side: 'right' | 'left'}>) {
+ const {width} = Dimensions.get('window');
+
+ const midpoint = width / 2;
+
+ const sideControlOffset = Math.max(midpoint - 200, midpoint / 3);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function Row({children}: PropsWithChildren) {
+ return {children};
+}
+
+const PRIMARY_CONTROL_DIAMETER = 96;
+
+const styles = StyleSheet.create({
+ basePressable: {
+ height: PRIMARY_CONTROL_DIAMETER,
+ width: PRIMARY_CONTROL_DIAMETER,
+ borderRadius: PRIMARY_CONTROL_DIAMETER,
+ borderWidth: 12,
+ borderColor: WHITE,
+ overflow: 'hidden',
+ backgroundColor: WHITE,
+ justifyContent: 'center',
+ },
+ pressablePressed: {
+ backgroundColor: LIGHT_GREY,
+ borderColor: LIGHT_GREY,
+ },
+
+ record: {
+ height: PRIMARY_CONTROL_DIAMETER,
+ backgroundColor: MAGENTA,
+ },
+ stop: {
+ height: PRIMARY_CONTROL_DIAMETER / 3,
+ width: PRIMARY_CONTROL_DIAMETER / 3,
+ backgroundColor: BLACK,
+ alignSelf: 'center',
+ },
+ play: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+
+ controlsRow: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ sideControl: {
+ position: 'absolute',
+ },
+});
diff --git a/src/frontend/screens/Audio/CreateRecording/RecordingActive.tsx b/src/frontend/screens/Audio/CreateRecording/RecordingActive.tsx
new file mode 100644
index 000000000..4505f8f44
--- /dev/null
+++ b/src/frontend/screens/Audio/CreateRecording/RecordingActive.tsx
@@ -0,0 +1,60 @@
+import React, {useEffect} from 'react';
+import {defineMessages, useIntl} from 'react-intl';
+import {useDerivedValue, withTiming} from 'react-native-reanimated';
+
+import {useNavigationFromRoot} from '../../../hooks/useNavigationWithTypes';
+import {AnimatedBackground} from '../AnimatedBackground';
+import {ContentWithControls} from '../ContentWithControls';
+import * as Controls from '../Controls';
+import {MAX_RECORDING_DURATION_MS} from '../index';
+import {useAutoStopRecording} from './useAutoStopRecording';
+
+const m = defineMessages({
+ description: {
+ id: 'screens.AudioScreen.CreateRecording.RecordingActive.description',
+ defaultMessage:
+ 'Less than {length} {length, plural, one {minute} other {minutes}} left',
+ },
+});
+
+export function RecordingActive({
+ duration,
+ onPressStop,
+}: {
+ duration: number;
+ onPressStop: () => void;
+}) {
+ const navigation = useNavigationFromRoot();
+ const {formatMessage: t} = useIntl();
+
+ const minutesRemaining = Math.ceil(
+ (MAX_RECORDING_DURATION_MS - duration) / 60_000,
+ );
+
+ const elapsedTimeValue = useDerivedValue(() => {
+ return withTiming(duration, {duration: 500});
+ }, [duration]);
+
+ useEffect(() => {
+ navigation.setOptions({headerShown: false});
+ }, [navigation]);
+
+ useAutoStopRecording(minutesRemaining, onPressStop);
+
+ return (
+ <>
+ 0
+ ? t(m.description, {
+ length: minutesRemaining,
+ })
+ : undefined
+ }
+ controls={}
+ />
+
+ >
+ );
+}
diff --git a/src/frontend/screens/Audio/CreateRecording/RecordingDone.tsx b/src/frontend/screens/Audio/CreateRecording/RecordingDone.tsx
new file mode 100644
index 000000000..3644935f8
--- /dev/null
+++ b/src/frontend/screens/Audio/CreateRecording/RecordingDone.tsx
@@ -0,0 +1,223 @@
+import React, {useEffect, useState} from 'react';
+import {HeaderBackButton} from '@react-navigation/elements';
+import {defineMessages, useIntl, FormattedMessage} from 'react-intl';
+import {Pressable, Text, View} from 'react-native';
+import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
+import {useNavigationFromRoot} from '../../../hooks/useNavigationWithTypes';
+import {WHITE} from '../../../lib/styles';
+import {
+ BottomSheetModalContent,
+ BottomSheetModal,
+ useBottomSheetModal,
+} from '../../../sharedComponents/BottomSheetModal';
+import {CloseIcon, DeleteIcon} from '../../../sharedComponents/icons';
+import ErrorIcon from '../../../images/Error.svg';
+import SuccessIcon from '../../../images/GreenCheck.svg';
+import {Playback} from '../../../sharedComponents/Playback';
+import {useDraftObservation} from '../../../hooks/useDraftObservation';
+
+const m = defineMessages({
+ deleteBottomSheetTitle: {
+ id: 'screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetTitle',
+ defaultMessage: 'Delete?',
+ },
+ deleteBottomSheetDescription: {
+ id: 'screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetDescription',
+ defaultMessage:
+ 'Your Audio Recording will be permanently deleted. This cannot be undone.',
+ },
+ deleteBottomSheetPrimaryButtonText: {
+ id: 'screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetPrimaryButtonText',
+ defaultMessage: 'Delete',
+ },
+ deleteBottomSheetSecondaryButtonText: {
+ id: 'screens.AudioScreen.CreateRecording.RecordingDone.deleteBottomSheetSecondaryButtonText',
+ defaultMessage: 'Cancel',
+ },
+ successTitle: {
+ id: 'AudioPlaybackScreen.DeleteAudioRecordingModal.successTitle',
+ defaultMessage: 'Success!',
+ },
+ successDescription: {
+ id: 'AudioPlaybackScreen.DeleteAudioRecordingModal.successDescription',
+ defaultMessage: 'Your {audioRecording} was added.',
+ },
+ returnToEditorButtonText: {
+ id: 'AudioPlaybackScreen.SuccessAudioRecordingModal.returnToEditor',
+ defaultMessage: 'Return to Editor',
+ },
+ recordAnotherButtonText: {
+ id: 'AudioPlaybackScreen.SuccessAudioRecordingModal.recordAnother',
+ defaultMessage: 'Record Another',
+ },
+ audioRecording: {
+ id: 'AudioPlaybackScreen.SuccessAudioRecordingModal.audioRecording',
+ defaultMessage: 'Audio Recording',
+ },
+});
+
+interface RecordingDoneProps {
+ duration: number;
+ uri: string;
+ reset: () => void;
+}
+
+type ModalContentType = 'delete' | 'success' | null;
+type PendingAction = 'delete' | 'returnToEditor' | 'recordAnother' | null;
+
+export function RecordingDone({duration, uri, reset}: RecordingDoneProps) {
+ const {formatMessage: t} = useIntl();
+ const navigation = useNavigationFromRoot();
+ const {addAudioRecording} = useDraftObservation();
+ const [pendingAction, setPendingAction] = useState(null);
+
+ const [modalContentType, setModalContentType] =
+ useState(null);
+
+ const {sheetRef, isOpen, openSheet, closeSheet} = useBottomSheetModal({
+ openOnMount: false,
+ });
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerShown: true,
+ headerLeft: props => (
+ {
+ const audioRecording = {
+ createdAt: Date.now(),
+ duration,
+ uri,
+ };
+ addAudioRecording(audioRecording);
+ setModalContentType('success');
+ openSheet();
+ }}
+ backImage={props => }
+ />
+ ),
+ });
+ }, [navigation, addAudioRecording, duration, uri, openSheet]);
+
+ const handleDelete = () => {
+ closeSheet();
+ setPendingAction('delete');
+ reset();
+ };
+
+ const handleDeletePress = () => {
+ setModalContentType('delete');
+ openSheet();
+ };
+
+ const handleReturnToEditor = () => {
+ closeSheet();
+ setPendingAction('returnToEditor');
+ };
+
+ const handleRecordAnother = async () => {
+ closeSheet();
+ setPendingAction('recordAnother');
+ reset();
+ };
+
+ const onModalDismiss = () => {
+ switch (pendingAction) {
+ case 'delete':
+ navigation.goBack();
+ break;
+ case 'returnToEditor':
+ navigation.navigate('ObservationCreate');
+ break;
+ case 'recordAnother':
+ navigation.navigate('Audio');
+ break;
+ default:
+ break;
+ }
+ setPendingAction(null);
+ };
+
+ const renderModalContent = () => {
+ if (modalContentType === 'delete') {
+ return (
+ }
+ title={t(m.deleteBottomSheetTitle)}
+ description={t(m.deleteBottomSheetDescription)}
+ buttonConfigs={[
+ {
+ dangerous: true,
+ text: t(m.deleteBottomSheetPrimaryButtonText),
+ icon: ,
+ onPress: handleDelete,
+ variation: 'filled',
+ },
+ {
+ variation: 'outlined',
+ text: t(m.deleteBottomSheetSecondaryButtonText),
+ onPress: closeSheet,
+ },
+ ]}
+ />
+ );
+ } else if (modalContentType === 'success') {
+ const description = (
+
+
+ (
+ {message}
+ ),
+ }}
+ />
+
+
+ );
+ return (
+ }
+ title={t(m.successTitle)}
+ description={description}
+ buttonConfigs={[
+ {
+ text: t(m.returnToEditorButtonText),
+ onPress: handleReturnToEditor,
+ variation: 'outlined',
+ },
+ {
+ text: t(m.recordAnotherButtonText),
+ onPress: handleRecordAnother,
+ variation: 'filled',
+ },
+ ]}
+ />
+ );
+ }
+ return null;
+ };
+
+ return (
+ <>
+
+
+
+ }
+ />
+
+ {renderModalContent()}
+
+ >
+ );
+}
diff --git a/src/frontend/screens/Audio/CreateRecording/RecordingIdle.tsx b/src/frontend/screens/Audio/CreateRecording/RecordingIdle.tsx
new file mode 100644
index 000000000..f19eca4aa
--- /dev/null
+++ b/src/frontend/screens/Audio/CreateRecording/RecordingIdle.tsx
@@ -0,0 +1,41 @@
+import React, {useEffect} from 'react';
+import {defineMessages, useIntl} from 'react-intl';
+import {useNavigationFromRoot} from '../../../hooks/useNavigationWithTypes';
+import {CustomHeaderLeft} from '../../../sharedComponents/CustomHeaderLeft';
+import {ContentWithControls} from '../ContentWithControls';
+import * as Controls from '../Controls';
+import {MAX_RECORDING_DURATION_MS} from '../index';
+
+const m = defineMessages({
+ description: {
+ id: 'screens.AudioScreen.CreateRecording.RecordingIdle.description',
+ defaultMessage:
+ 'Record up to {length} {length, plural, one {minute} other {minutes}}',
+ },
+});
+
+export function RecordingIdle({onPressRecord}: {onPressRecord: () => void}) {
+ const navigation = useNavigationFromRoot();
+ const {formatMessage: t} = useIntl();
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerLeft: props => (
+
+ ),
+ });
+ }, [navigation]);
+
+ return (
+ }
+ />
+ );
+}
diff --git a/src/frontend/screens/Audio/CreateRecording/index.tsx b/src/frontend/screens/Audio/CreateRecording/index.tsx
index a0aa360a5..2f12fa29e 100644
--- a/src/frontend/screens/Audio/CreateRecording/index.tsx
+++ b/src/frontend/screens/Audio/CreateRecording/index.tsx
@@ -1,40 +1,51 @@
import React, {useEffect} from 'react';
-import {Text, View, StyleSheet} from 'react-native';
-import {WHITE} from '../../../lib/styles';
-
-import {useNavigationFromRoot} from '../../../hooks/useNavigationWithTypes';
-import {CustomHeaderLeft} from '../../../sharedComponents/CustomHeaderLeft';
+import {BackHandler} from 'react-native';
+import {RecordingActive} from './RecordingActive';
+import {RecordingDone} from './RecordingDone';
+import {RecordingIdle} from './RecordingIdle';
+import {useAudioRecording} from './useAudioRecording';
export function CreateRecording() {
- const navigation = useNavigationFromRoot();
+ const {startRecording, stopRecording, reset, status, uri} =
+ useAudioRecording();
+
+ const recordingState = status?.isRecording ? 'active' : uri ? 'done' : 'idle';
+
useEffect(() => {
- navigation.setOptions({
- headerLeft: props => (
-
- ),
- });
- }, [navigation]);
- return (
-
-
- Create Recording
-
-
- );
-}
+ if (recordingState === 'active' || recordingState === 'done') {
+ const onBackPress = () => {
+ return true;
+ };
+ const backHandler = BackHandler.addEventListener(
+ 'hardwareBackPress',
+ onBackPress,
+ );
+ return () => {
+ backHandler.remove();
+ };
+ }
+ }, [recordingState]);
-const styles = StyleSheet.create({
- contentContainer: {flex: 1},
- container: {
- flex: 1,
- justifyContent: 'center',
- },
- message: {
- color: WHITE,
- fontSize: 20,
- textAlign: 'center',
- },
-});
+ if (recordingState === 'idle') {
+ return ;
+ }
+
+ if (recordingState === 'active') {
+ return (
+
+ );
+ }
+
+ if (recordingState === 'done') {
+ return (
+
+ );
+ }
+}
diff --git a/src/frontend/screens/Audio/CreateRecording/useAudioRecording.ts b/src/frontend/screens/Audio/CreateRecording/useAudioRecording.ts
new file mode 100644
index 000000000..88279e531
--- /dev/null
+++ b/src/frontend/screens/Audio/CreateRecording/useAudioRecording.ts
@@ -0,0 +1,35 @@
+import {useState, useCallback} from 'react';
+import {Audio} from 'expo-av';
+
+export function useAudioRecording() {
+ const [recording, setRecording] = useState(null);
+ const [status, setStatus] = useState(null);
+ const [uri, setUri] = useState(null);
+
+ const startRecording = useCallback(async () => {
+ const {recording: audioRecording} = await Audio.Recording.createAsync(
+ Audio.RecordingOptionsPresets.HIGH_QUALITY,
+ stat => setStatus(stat),
+ );
+ setRecording(audioRecording);
+ }, [setRecording, setStatus]);
+
+ const reset = useCallback(async () => {
+ if (recording) {
+ if ((await recording.getStatusAsync()).isRecording) {
+ await recording.stopAndUnloadAsync();
+ }
+ }
+ setRecording(null);
+ setStatus(null);
+ setUri(null);
+ }, [recording, setRecording, setStatus, setUri]);
+
+ const stopRecording = useCallback(async () => {
+ if (!recording) return;
+ await recording.stopAndUnloadAsync();
+ setUri(recording.getURI());
+ }, [recording, setUri]);
+
+ return {reset, startRecording, stopRecording, status, uri};
+}
diff --git a/src/frontend/screens/Audio/CreateRecording/useAutoStopRecording.ts b/src/frontend/screens/Audio/CreateRecording/useAutoStopRecording.ts
new file mode 100644
index 000000000..3372dfef1
--- /dev/null
+++ b/src/frontend/screens/Audio/CreateRecording/useAutoStopRecording.ts
@@ -0,0 +1,12 @@
+import {useEffect} from 'react';
+
+export function useAutoStopRecording(
+ minutesRemaining: number,
+ onPressStop: () => void,
+) {
+ useEffect(() => {
+ if (minutesRemaining <= 0) {
+ onPressStop();
+ }
+ }, [minutesRemaining, onPressStop]);
+}
diff --git a/src/frontend/screens/Audio/PermissionAudioBottomSheetContent.tsx b/src/frontend/screens/Audio/PermissionAudioBottomSheetContent.tsx
index 287dd5df9..eb2919fb2 100644
--- a/src/frontend/screens/Audio/PermissionAudioBottomSheetContent.tsx
+++ b/src/frontend/screens/Audio/PermissionAudioBottomSheetContent.tsx
@@ -5,7 +5,6 @@ import AudioPermission from '../../images/observationEdit/AudioPermission.svg';
import {BottomSheetModalContent} from '../../sharedComponents/BottomSheetModal';
import {Audio} from 'expo-av';
import {PermissionResponse} from 'expo-modules-core';
-import {NativeRootNavigationProps} from '../../sharedTypes/navigation';
const m = defineMessages({
title: {
@@ -37,48 +36,38 @@ const m = defineMessages({
},
});
-type ObservationCreateNavigationProp =
- NativeRootNavigationProps<'ObservationCreate'>['navigation'];
-
interface PermissionAudioBottomSheetContentProps {
closeSheet: () => void;
- navigation: ObservationCreateNavigationProp;
+ setShouldNavigateToAudioTrue: () => void;
}
export const PermissionAudioBottomSheetContent: FC<
PermissionAudioBottomSheetContentProps
-> = ({closeSheet, navigation}) => {
+> = ({closeSheet, setShouldNavigateToAudioTrue}) => {
const {formatMessage: t} = useIntl();
const [permissionResponse, setPermissionResponse] =
useState(null);
const handleOpenSettings = () => {
Linking.openSettings();
- closeSheet();
};
const handleRequestPermission = async () => {
const response = await Audio.requestPermissionsAsync();
+ closeSheet();
setPermissionResponse(response);
if (response.status === 'granted') {
- closeSheet();
- navigation.navigate('Audio');
- } else if (response.status === 'denied' && response.canAskAgain) {
- closeSheet();
+ setShouldNavigateToAudioTrue();
} else if (response.status === 'denied' && !response.canAskAgain) {
handleOpenSettings();
}
};
const onPressActionButton = !permissionResponse
- ? async () => {
- await handleRequestPermission();
- }
+ ? handleRequestPermission
: permissionResponse.status === 'denied'
? handleOpenSettings
- : async () => {
- await handleRequestPermission();
- };
+ : handleRequestPermission;
const actionButtonText = !permissionResponse
? t(m.allowButtonText)
: permissionResponse.status === 'denied'
diff --git a/src/frontend/screens/Audio/index.tsx b/src/frontend/screens/Audio/index.tsx
index 8549f37ed..600b35377 100644
--- a/src/frontend/screens/Audio/index.tsx
+++ b/src/frontend/screens/Audio/index.tsx
@@ -3,13 +3,13 @@ import {NativeStackNavigationOptions} from '@react-navigation/native-stack';
import {DARK_GREY, WHITE} from '../../lib/styles';
import {CustomHeaderLeft} from '../../sharedComponents/CustomHeaderLeft';
-import {StatusBar} from 'expo-status-bar';
import {CreateRecording} from './CreateRecording';
+export const MAX_RECORDING_DURATION_MS = 5 * 60_000;
+
export function Audio() {
return (
<>
-
>
);
diff --git a/src/frontend/screens/Audio/useAudioPlayback.ts b/src/frontend/screens/Audio/useAudioPlayback.ts
new file mode 100644
index 000000000..7a133980c
--- /dev/null
+++ b/src/frontend/screens/Audio/useAudioPlayback.ts
@@ -0,0 +1,82 @@
+import {Audio, AVPlaybackStatus, AVPlaybackStatusSuccess} from 'expo-av';
+import {useCallback, useEffect, useState, useRef} from 'react';
+import {Sound} from 'expo-av/build/Audio/Sound';
+
+export const useAudioPlayback = (recordingUri: string) => {
+ const recordedSoundRef = useRef(null);
+ const [isPlaying, setPlaying] = useState(false);
+ const [duration, setDuration] = useState(0);
+ const [currentPosition, setCurrentPosition] = useState(0);
+
+ const audioCallbackHandler = useCallback((status: AVPlaybackStatus) => {
+ const update = status as AVPlaybackStatusSuccess;
+ if (update.didJustFinish) {
+ setPlaying(false);
+ setCurrentPosition(update.durationMillis ?? 0);
+ } else {
+ setPlaying(update.isPlaying);
+ if (update.isPlaying) {
+ setCurrentPosition(update.positionMillis);
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ let soundInstance: Sound | null = null;
+ Audio.Sound.createAsync({uri: recordingUri})
+ .then(({sound, status}) => {
+ if ('error' in status && status.error) {
+ console.error('Error while creating audio playback', status.error);
+ return;
+ }
+ soundInstance = sound;
+ recordedSoundRef.current = sound;
+ setDuration((status as AVPlaybackStatusSuccess).durationMillis ?? 0);
+ sound.setOnPlaybackStatusUpdate(audioCallbackHandler);
+ })
+ .catch(error => console.error('Error loading sound:', error));
+
+ return () => {
+ if (soundInstance) {
+ soundInstance
+ .unloadAsync()
+ .catch(err => console.error('Unload error:', err));
+ }
+ };
+ }, [recordingUri, audioCallbackHandler]);
+
+ const startPlayback = useCallback(async () => {
+ if (!recordedSoundRef.current || isPlaying) return;
+
+ try {
+ if (currentPosition >= duration) {
+ await recordedSoundRef.current!.setPositionAsync(0);
+ setCurrentPosition(0);
+ }
+
+ await recordedSoundRef.current!.playAsync();
+ setPlaying(true);
+ } catch (error) {
+ console.error('Failed to play sound:', error);
+ }
+ }, [isPlaying, currentPosition, duration]);
+
+ const stopPlayback = useCallback(async () => {
+ if (!recordedSoundRef.current || !isPlaying) return;
+
+ try {
+ await recordedSoundRef.current!.pauseAsync();
+ setPlaying(false);
+ } catch (error) {
+ console.error('Failed to pause sound:', error);
+ }
+ }, [isPlaying]);
+
+ return {
+ duration,
+ isPlaying,
+ currentPosition,
+ startPlayback,
+ stopPlayback,
+ };
+};
diff --git a/src/frontend/screens/ObservationCreate/index.tsx b/src/frontend/screens/ObservationCreate/index.tsx
index 45b55bf97..b0d539630 100644
--- a/src/frontend/screens/ObservationCreate/index.tsx
+++ b/src/frontend/screens/ObservationCreate/index.tsx
@@ -7,7 +7,10 @@ import {usePersistedDraftObservation} from '../../hooks/persistedState/usePersis
import {NativeRootNavigationProps} from '../../sharedTypes/navigation';
import {useCreateObservation} from '../../hooks/server/observations';
import {CommonActions} from '@react-navigation/native';
-import {useCreateBlobMutation} from '../../hooks/server/media';
+import {
+ useCreateBlobMutation,
+ useCreateAudioBlobMutation,
+} from '../../hooks/server/media';
import {usePersistedTrack} from '../../hooks/persistedState/usePersistedTrack';
import {SaveButton} from '../../sharedComponents/SaveButton';
import {useMostAccurateLocationForObservation} from './useMostAccurateLocationForObservation';
@@ -86,10 +89,14 @@ export const ObservationCreate = ({
const {usePreset} = useDraftObservation();
const preset = usePreset();
const value = usePersistedDraftObservation(store => store.value);
+ const audioRecordings = usePersistedDraftObservation(
+ store => store.audioRecordings,
+ );
const {updateTags, clearDraft} = useDraftObservation();
const photos = usePersistedDraftObservation(store => store.photos);
const createObservationMutation = useCreateObservation();
const createBlobMutation = useCreateBlobMutation();
+ const createAudioBlobMutation = useCreateAudioBlobMutation();
const isTracking = usePersistedTrack(state => state.isTracking);
const addNewTrackLocations = usePersistedTrack(
state => state.addNewLocations,
@@ -123,8 +130,9 @@ export const ObservationCreate = ({
if (!value) throw new Error('no observation saved in persisted state ');
const savablePhotos = photos.filter(photo => photo.type === 'processed');
+ const savableAudioRecordings = audioRecordings;
- if (!savablePhotos) {
+ if (savablePhotos.length === 0 && savableAudioRecordings.length === 0) {
createObservationMutation.mutate(
{value},
{
@@ -151,14 +159,18 @@ export const ObservationCreate = ({
// The alternative is to save the observation but excluding photos that failed to save, which is prone to an odd UX of an observation "missing" some attachments.
// This could potentially be alleviated by a more granular and informative UI about the photo-saving state, but currently there is nothing in place.
// Basically, which is worse: orphaned attachments or saving observations that seem to be missing attachments?
- Promise.all(
- savablePhotos.map(photo => {
- return createBlobMutation.mutateAsync(
- // @ts-expect-error Due to TS array filtering limitations. Fixed in TS 5.5
- photo,
- );
- }),
- ).then(results => {
+ const photoPromises = savablePhotos.map(photo => {
+ return createBlobMutation.mutateAsync(
+ // @ts-expect-error Due to TS array filtering limitations. Fixed in TS 5.5
+ photo,
+ );
+ });
+
+ const audioPromises = savableAudioRecordings.map(audio => {
+ return createAudioBlobMutation.mutateAsync(audio);
+ });
+
+ Promise.all([...photoPromises, ...audioPromises]).then(results => {
const newAttachments = results.map(
({driveId: driveDiscoveryId, type, name, hash}) => ({
driveDiscoveryId,
@@ -203,10 +215,12 @@ export const ObservationCreate = ({
addNewTrackObservation,
clearDraft,
createBlobMutation,
+ createAudioBlobMutation,
createObservationMutation,
isTracking,
navigation,
photos,
+ audioRecordings,
value,
]);
@@ -273,7 +287,9 @@ export const ObservationCreate = ({
),
@@ -282,6 +298,7 @@ export const ObservationCreate = ({
navigation,
createBlobMutation.isPending,
createObservationMutation.isPending,
+ createAudioBlobMutation.isPending,
checkAccuracyAndLocation,
]);
@@ -311,10 +328,15 @@ export const ObservationCreate = ({
actionsRow={}
/>
{
createObservationMutation.reset();
createBlobMutation.reset();
+ createAudioBlobMutation.reset();
}}
tryAgain={createObservation}
/>
diff --git a/src/frontend/sharedComponents/ActionRow.tsx b/src/frontend/sharedComponents/ActionRow.tsx
index ffe03d53b..cb8b5dfb8 100644
--- a/src/frontend/sharedComponents/ActionRow.tsx
+++ b/src/frontend/sharedComponents/ActionRow.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback} from 'react';
+import React, {useCallback, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {ActionTab} from './ActionTab';
import PhotoIcon from '../images/observationEdit/Photo.svg';
@@ -51,6 +51,8 @@ export const ActionsRow = ({fieldRefs}: ActionButtonsProps) => {
openOnMount: false,
});
+ const [shouldNavigateToAudio, setShouldNavigateToAudio] = useState(false);
+
const handleCameraPress = () => {
navigation.navigate('AddPhoto');
};
@@ -67,6 +69,13 @@ export const ActionsRow = ({fieldRefs}: ActionButtonsProps) => {
}
}, [navigation, openAudioPermissionSheet]);
+ const handleModalDismiss = useCallback(() => {
+ if (shouldNavigateToAudio) {
+ navigation.navigate('Audio');
+ setShouldNavigateToAudio(false);
+ }
+ }, [shouldNavigateToAudio, navigation]);
+
const bottomSheetItems = [
{
icon: ,
@@ -99,11 +108,11 @@ export const ActionsRow = ({fieldRefs}: ActionButtonsProps) => {
setShouldNavigateToAudio(true)}
/>
>
diff --git a/src/frontend/sharedComponents/Button.tsx b/src/frontend/sharedComponents/Button.tsx
index 723c20397..eedd9c21e 100644
--- a/src/frontend/sharedComponents/Button.tsx
+++ b/src/frontend/sharedComponents/Button.tsx
@@ -13,7 +13,7 @@ type Size = 'medium' | 'large';
interface SharedTouchableProps {
disabled?: boolean;
- onPress: (event: GestureResponderEvent) => void;
+ onPress: (event: GestureResponderEvent) => void | Promise;
}
interface Props {
@@ -22,7 +22,7 @@ interface Props {
color?: ColorScheme;
disabled?: boolean;
fullWidth?: boolean;
- onPress: (event: GestureResponderEvent) => void;
+ onPress: (event: GestureResponderEvent) => void | Promise;
size?: Size;
style?: ViewStyleProp;
testID?: string;
diff --git a/src/frontend/sharedComponents/Playback.tsx b/src/frontend/sharedComponents/Playback.tsx
new file mode 100644
index 000000000..97daa37cc
--- /dev/null
+++ b/src/frontend/sharedComponents/Playback.tsx
@@ -0,0 +1,53 @@
+import React, {ReactNode} from 'react';
+import {View} from 'react-native';
+import {Duration} from 'luxon';
+import {defineMessages, useIntl} from 'react-intl';
+import {ContentWithControls} from '../screens/Audio/ContentWithControls';
+import * as Controls from '../screens/Audio/Controls';
+import {useAudioPlayback} from '../screens/Audio/useAudioPlayback';
+const m = defineMessages({
+ description: {
+ id: 'screens.AudioScreen.Playback.description',
+ defaultMessage: 'Total length: {length}',
+ },
+});
+export function Playback({
+ uri,
+ leftControl,
+ rightControl,
+}: {
+ uri: string;
+ isModalOpen?: boolean;
+ leftControl?: ReactNode;
+ rightControl?: ReactNode;
+}) {
+ const {formatMessage: t} = useIntl();
+ const {duration, currentPosition, isPlaying, stopPlayback, startPlayback} =
+ useAudioPlayback(uri);
+ return (
+
+
+ {leftControl && (
+ {leftControl}
+ )}
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+ {rightControl && (
+ {rightControl}
+ )}
+ >
+ }
+ />
+
+ );
+}
diff --git a/src/frontend/sharedTypes/index.ts b/src/frontend/sharedTypes/index.ts
index b4ccbe4a9..9e022bb5c 100644
--- a/src/frontend/sharedTypes/index.ts
+++ b/src/frontend/sharedTypes/index.ts
@@ -32,6 +32,11 @@ export type PhotoVariant = 'original' | 'thumbnail' | 'preview';
export type CoordinateFormat = 'utm' | 'dd' | 'dms';
export type MediaSyncSetting = 'previews' | 'everything';
+export type AudioRecording = {
+ createdAt: number;
+ duration: number;
+ uri: string;
+};
// Copied from @comapeo/core/src/roles.js. Created an issue to eventually expose this: https://github.com/digidem/mapeo-core-next/issues/532
export const CREATOR_ROLE_ID = 'a12a6702b93bd7ff';