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';