diff --git a/app/containers/AudioPlayer/Seek.test.tsx b/app/containers/AudioPlayer/Seek.test.tsx new file mode 100644 index 00000000000..ce7668c55a7 --- /dev/null +++ b/app/containers/AudioPlayer/Seek.test.tsx @@ -0,0 +1,44 @@ +import { act, render, renderHook } from '@testing-library/react-native'; +import { Gesture, State } from 'react-native-gesture-handler'; +import { fireGestureHandler } from 'react-native-gesture-handler/jest-utils'; +import { useSharedValue } from 'react-native-reanimated'; + +import Seek from './Seek'; + +jest.mock('~/theme', () => ({ useTheme: () => ({ colors: {} }) })); + +const setup = () => { + const pan = jest.spyOn(Gesture, 'Pan'); + const { result } = renderHook(() => ({ currentTime: useSharedValue(30), duration: useSharedValue(120) })); + const { currentTime, duration } = result.current; + const onChangeTime = jest.fn(); + render(); + return { gesture: pan.mock.results[0].value, currentTime, onChangeTime }; +}; + +afterEach(() => jest.restoreAllMocks()); + +test('a tap that never starts dragging preserves the paused position', () => { + const { gesture, currentTime, onChangeTime } = setup(); + act(() => fireGestureHandler(gesture, [{ state: State.BEGAN }, { state: State.FAILED }])); + expect(currentTime.value).toBe(30); + expect(onChangeTime).not.toHaveBeenCalled(); +}); + +test('a cancelled drag restores its starting position without seeking', () => { + const { gesture, currentTime, onChangeTime } = setup(); + act(() => fireGestureHandler(gesture, [{ state: State.BEGAN }, { state: State.ACTIVE }, { state: State.CANCELLED }])); + expect(currentTime.value).toBe(30); + expect(onChangeTime).not.toHaveBeenCalled(); + + currentTime.value = 45; + act(() => fireGestureHandler(gesture, [{ state: State.BEGAN }, { state: State.FAILED }])); + expect(currentTime.value).toBe(45); +}); + +test('a completed drag seeks in seconds', () => { + const { gesture, onChangeTime } = setup(); + act(() => fireGestureHandler(gesture, [{ state: State.BEGAN }, { state: State.ACTIVE }, { state: State.END }])); + expect(onChangeTime).toHaveBeenCalledTimes(1); + expect(onChangeTime).toHaveBeenCalledWith(30); +}); diff --git a/app/containers/AudioPlayer/Seek.tsx b/app/containers/AudioPlayer/Seek.tsx index 929f0b8bc78..ebef805872b 100644 --- a/app/containers/AudioPlayer/Seek.tsx +++ b/app/containers/AudioPlayer/Seek.tsx @@ -20,7 +20,7 @@ interface ISeek { duration: SharedValue; currentTime: SharedValue; loaded: boolean; - onChangeTime: (time: number) => Promise; + onChangeTime: (time: number) => void; } function clamp(value: number, min: number, max: number) { @@ -49,6 +49,8 @@ const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => const scale = useSharedValue(1); const isPanning = useSharedValue(false); const contextX = useSharedValue(0); + const savedTranslateX = useSharedValue(0); + const savedCurrentTime = useSharedValue(0); const styleLine = useAnimatedStyle(() => ({ width: translateX.value @@ -69,23 +71,24 @@ const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => .onStart(() => { isPanning.value = true; contextX.value = translateX.value; + savedTranslateX.value = translateX.value; + savedCurrentTime.value = currentTime.value; scale.value = withTiming(1.3, { duration: 150 }); }) .onUpdate(event => { const newX = contextX.value + event.translationX; translateX.value = clamp(newX, 0, maxWidth.value); }) - .onEnd(() => { - scheduleOnRN(onChangeTime, Math.round(currentTime.value * 1000)); - }) .onFinalize((_, didSucceed) => { - if (isPanning.value && !didSucceed) { - translateX.value = contextX.value; - currentTime.value = (contextX.value * duration.value) / maxWidth.value || 0; - } - + if (!isPanning.value) return; isPanning.value = false; scale.value = withTiming(1, { duration: 150 }); + if (didSucceed) { + scheduleOnRN(onChangeTime, currentTime.value); + } else { + translateX.value = savedTranslateX.value; + currentTime.value = savedCurrentTime.value; + } }); useDerivedValue(() => { diff --git a/app/containers/AudioPlayer/index.test.tsx b/app/containers/AudioPlayer/index.test.tsx index e4166f42a8d..1f28c6a3d2f 100644 --- a/app/containers/AudioPlayer/index.test.tsx +++ b/app/containers/AudioPlayer/index.test.tsx @@ -13,12 +13,12 @@ jest.mock('~/lib/methods/AudioManager', () => ({ default: { loadAudio: jest.fn(), setOnPlaybackStatusUpdate: jest.fn(), - setRateAsync: jest.fn(), + setRate: jest.fn(), addAudioRendered: jest.fn(), removeAudioRendered: jest.fn(), pauseAudio: jest.fn(), playAudio: jest.fn(), - setPositionAsync: jest.fn() + setPositionAsync: jest.fn(() => Promise.resolve()) } })); @@ -40,10 +40,10 @@ jest.mock('~/theme', () => ({ })); const mockTaskCancel = jest.fn(); -let capturedFocusCb: Function | undefined; -let capturedBlurCb: Function | undefined; +let capturedFocusCb: (() => void) | undefined; +let capturedBlurCb: (() => void) | undefined; let mockUnsubscribe: jest.Mock; -let capturedAudioFocusedCb: Function | undefined; +let capturedAudioFocusedCb: ((audioFocused: string) => void) | undefined; const defaultProps = { fileUri: 'file:///audio.mp3', @@ -67,7 +67,7 @@ describe('AudioPlayer', () => { capturedFocusCb = undefined; capturedBlurCb = undefined; mockUnsubscribe = jest.fn(); - const mockAddListener = jest.fn((event: string, cb: Function) => { + const mockAddListener = jest.fn((event: string, cb: () => void) => { if (event === 'focus') capturedFocusCb = cb; if (event === 'blur') capturedBlurCb = cb; return mockUnsubscribe; @@ -75,7 +75,7 @@ describe('AudioPlayer', () => { (useNavigation as jest.Mock).mockReturnValue({ navigate: jest.fn(), addListener: mockAddListener }); capturedAudioFocusedCb = undefined; - (emitter.on as jest.Mock).mockImplementation((event: string, cb: Function) => { + (emitter.on as jest.Mock).mockImplementation((event: string, cb: (audioFocused: string) => void) => { if (event === 'audioFocused') capturedAudioFocusedCb = cb; }); }); @@ -102,7 +102,7 @@ describe('AudioPlayer', () => { await waitFor(() => { expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalledWith('mock-audio-key', expect.any(Function)); - expect(AudioManager.setRateAsync).toHaveBeenCalledWith('mock-audio-key', 1.0); + expect(AudioManager.setRate).toHaveBeenCalledWith('mock-audio-key', 1.0); }); }); @@ -168,7 +168,7 @@ describe('AudioPlayer', () => { expect(() => getByTestId('play-button')).not.toThrow(); }); - it('skips setOnPlaybackStatusUpdate and setRateAsync (with loaded key) when loadAudio throws', async () => { + it('skips setOnPlaybackStatusUpdate and setRate (with loaded key) when loadAudio throws', async () => { (AudioManager.loadAudio as jest.Mock).mockRejectedValue(new Error('load failed')); render(); @@ -176,7 +176,7 @@ describe('AudioPlayer', () => { await act(async () => {}); expect(AudioManager.setOnPlaybackStatusUpdate).not.toHaveBeenCalled(); - expect(AudioManager.setRateAsync).not.toHaveBeenCalledWith('mock-audio-key', expect.any(Number)); + expect(AudioManager.setRate).not.toHaveBeenCalledWith('mock-audio-key', expect.any(Number)); }); }); @@ -273,7 +273,7 @@ describe('AudioPlayer', () => { await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb({ isLoaded: true, isPlaying: true })); + act(() => statusCb({ isLoaded: true, playing: true })); fireEvent.press(getByTestId('play-button')); @@ -299,7 +299,7 @@ describe('AudioPlayer', () => { await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb({ isLoaded: true, isPlaying: true })); + act(() => statusCb({ isLoaded: true, playing: true })); await waitFor(() => expect(getByTestId('play-button').props.accessibilityLabel).toBe('Pause')); }); @@ -310,10 +310,10 @@ describe('AudioPlayer', () => { await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb({ isLoaded: true, isPlaying: true })); + act(() => statusCb({ isLoaded: true, playing: true })); await waitFor(() => expect(getByTestId('play-button').props.accessibilityLabel).toBe('Pause')); - act(() => statusCb({ isLoaded: true, isPlaying: false })); + act(() => statusCb({ isLoaded: true, playing: false })); await waitFor(() => expect(getByTestId('play-button').props.accessibilityLabel).toBe('Play')); }); @@ -324,13 +324,13 @@ describe('AudioPlayer', () => { const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; const currentTimeValue = (useSharedValue as jest.Mock).mock.results[1].value as { value: number }; - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 30000, positionMillis: 1000 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 30, currentTime: 1 })); expect(currentTimeValue.value).toBe(1); - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 30000, positionMillis: 2000 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 30, currentTime: 2 })); expect(currentTimeValue.value).toBe(2); - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 30000, positionMillis: 3000 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 30, currentTime: 3 })); expect(currentTimeValue.value).toBe(3); }); }); @@ -356,7 +356,7 @@ describe('AudioPlayer', () => { await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb({ isLoaded: true, isPlaying: true })); + act(() => statusCb({ isLoaded: true, playing: true })); await waitFor(() => expect(queryByTestId('playback-speed')).not.toBeNull()); }); @@ -383,7 +383,7 @@ describe('AudioPlayer', () => { await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb({ isLoaded: true, isPlaying: true })); + act(() => statusCb({ isLoaded: true, playing: true })); await waitFor(() => expect(activateKeepAwake).toHaveBeenCalled()); }); @@ -466,24 +466,24 @@ describe('AudioPlayer', () => { }); describe('onPlaybackStatusUpdate callback chain', () => { - it('sets paused to false (audioState: playing) when status has isPlaying: true', async () => { + it('sets paused to false (audioState: playing) when status has playing: true', async () => { const { queryByTestId } = render(); await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb({ isLoaded: true, isPlaying: true })); + act(() => statusCb({ isLoaded: true, playing: true })); await waitFor(() => expect(queryByTestId('playback-speed')).not.toBeNull()); }); - it('keeps paused true when status has isPlaying: false', async () => { + it('keeps paused true when status has playing: false', async () => { const { queryByTestId } = render(); await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb({ isLoaded: true, isPlaying: false })); + act(() => statusCb({ isLoaded: true, playing: false })); await act(async () => {}); @@ -500,13 +500,13 @@ describe('AudioPlayer', () => { const durationValue = sharedValueMock.mock.results[0].value as { value: number }; const currentTimeValue = sharedValueMock.mock.results[1].value as { value: number }; - act(() => statusCb({ isLoaded: true, durationMillis: 10000, positionMillis: 3000, isPlaying: false })); + act(() => statusCb({ isLoaded: true, duration: 10, currentTime: 3, playing: false })); expect(durationValue.value).toBe(10); expect(currentTimeValue.value).toBe(3); }); - it('does not update currentTime.value when positionMillis exceeds durationMillis', async () => { + it('does not update currentTime.value when currentTime exceeds duration', async () => { render(); await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); @@ -514,14 +514,14 @@ describe('AudioPlayer', () => { const currentTimeValue = (useSharedValue as jest.Mock).mock.results[1].value as { value: number }; - act(() => statusCb({ isLoaded: true, durationMillis: 5000, positionMillis: 2000, isPlaying: false })); + act(() => statusCb({ isLoaded: true, duration: 5, currentTime: 2, playing: false })); expect(currentTimeValue.value).toBe(2); - act(() => statusCb({ isLoaded: true, durationMillis: 5000, positionMillis: 9000, isPlaying: false })); + act(() => statusCb({ isLoaded: true, duration: 5, currentTime: 9, playing: false })); expect(currentTimeValue.value).toBe(2); }); - it('does not update shared values when durationMillis is absent', async () => { + it('does not update shared values when duration is absent', async () => { render(); await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); @@ -529,7 +529,7 @@ describe('AudioPlayer', () => { const durationValue = (useSharedValue as jest.Mock).mock.results[0].value as { value: number }; - act(() => statusCb({ isLoaded: true, isPlaying: false })); + act(() => statusCb({ isLoaded: true, playing: false })); expect(durationValue.value).toBe(0); }); @@ -542,9 +542,9 @@ describe('AudioPlayer', () => { const currentTimeValue = (useSharedValue as jest.Mock).mock.results[1].value as { value: number }; - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 5000, positionMillis: 4000 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 5, currentTime: 4 })); - act(() => statusCb({ isLoaded: true, isPlaying: false, didJustFinish: true })); + act(() => statusCb({ isLoaded: true, playing: false, didJustFinish: true, currentTime: 5, duration: 5 })); await waitFor(() => { expect(queryByTestId('playback-speed')).toBeNull(); @@ -560,16 +560,16 @@ describe('AudioPlayer', () => { const currentTimeValue = (useSharedValue as jest.Mock).mock.results[1].value as { value: number }; - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 10000, positionMillis: 0 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 10, currentTime: 0 })); expect(currentTimeValue.value).toBe(0); - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 10000, positionMillis: 2500 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 10, currentTime: 2.5 })); expect(currentTimeValue.value).toBe(2.5); - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 10000, positionMillis: 5000 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 10, currentTime: 5 })); expect(currentTimeValue.value).toBe(5); - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 10000, positionMillis: 7500 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 10, currentTime: 7.5 })); expect(currentTimeValue.value).toBe(7.5); }); @@ -580,7 +580,7 @@ describe('AudioPlayer', () => { const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; const durationValue = (useSharedValue as jest.Mock).mock.results[0].value as { value: number }; - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 10000, positionMillis: 0 })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 10, currentTime: 0 })); expect(durationValue.value).toBe(10); }); @@ -592,7 +592,7 @@ describe('AudioPlayer', () => { const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; const currentTimeValue = (useSharedValue as jest.Mock).mock.results[1].value as { value: number }; - act(() => statusCb({ isLoaded: true, isPlaying: false, durationMillis: 10000, positionMillis: 3500 })); + act(() => statusCb({ isLoaded: true, playing: false, duration: 10, currentTime: 3.5 })); expect(currentTimeValue.value).toBe(3.5); }); @@ -604,19 +604,19 @@ describe('AudioPlayer', () => { const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; const currentTimeValue = (useSharedValue as jest.Mock).mock.results[1].value as { value: number }; - act(() => statusCb({ isLoaded: true, isPlaying: true, durationMillis: 10000, positionMillis: 7777 })); - act(() => statusCb({ isLoaded: true, isPlaying: false, didJustFinish: true })); + act(() => statusCb({ isLoaded: true, playing: true, duration: 10, currentTime: 7.777 })); + act(() => statusCb({ isLoaded: true, playing: false, didJustFinish: true, currentTime: 10, duration: 10 })); expect(currentTimeValue.value).toBe(0); }); - it('does not crash when status is null', async () => { + it('does not crash or update state when status is not loaded', async () => { render(); await waitFor(() => expect(AudioManager.setOnPlaybackStatusUpdate).toHaveBeenCalled()); const statusCb = (AudioManager.setOnPlaybackStatusUpdate as jest.Mock).mock.calls[0][1] as Function; - act(() => statusCb(null)); + act(() => statusCb({ isLoaded: false })); await act(async () => {}); }); @@ -634,7 +634,7 @@ describe('AudioPlayer', () => { await waitFor(() => expect(AudioManager.playAudio).toHaveBeenCalledWith('mock-audio-key')); act(() => { - statusCb({ isLoaded: true, isPlaying: true, positionMillis: 5000, durationMillis: 30000 }); + statusCb({ isLoaded: true, playing: true, currentTime: 5, duration: 30 }); }); expect(currentTimeValue.value).toBe(5); diff --git a/app/containers/AudioPlayer/index.tsx b/app/containers/AudioPlayer/index.tsx index 544de5850b9..442ae6e3c5b 100644 --- a/app/containers/AudioPlayer/index.tsx +++ b/app/containers/AudioPlayer/index.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import { InteractionManager, View } from 'react-native'; -import { type AVPlaybackStatus } from 'expo-av'; import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake'; import { useSharedValue } from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; +import type { AudioStatus } from 'expo-audio'; import { useTheme } from '~/theme'; import styles from './styles'; @@ -16,6 +16,7 @@ import { type TDownloadState } from '~/lib/methods/handleMediaDownload'; import { emitter } from '~/lib/methods/helpers/emitter'; import { type TAudioState } from './types'; import { useUserPreferences } from '~/lib/methods/userPreferences'; +import log from '~/lib/methods/helpers/log'; interface IAudioPlayerProps { fileUri: string; @@ -47,7 +48,7 @@ const AudioPlayer = ({ const audioUri = useRef(''); const navigation = useNavigation(); - const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => { + const onPlaybackStatusUpdate = (status: AudioStatus): void => { if (status) { onPlaying(status); handlePlaybackStatusUpdate(status); @@ -55,37 +56,37 @@ const AudioPlayer = ({ } }; - const onPlaying = (data: AVPlaybackStatus) => { - if (data.isLoaded && data.isPlaying) { + const onPlaying = (data: AudioStatus): void => { + if (data.isLoaded && data.playing) { setPaused(false); } else { setPaused(true); } }; - const handlePlaybackStatusUpdate = (data: AVPlaybackStatus) => { - if (data.isLoaded && data.durationMillis) { - const durationSeconds = data.durationMillis / 1000; - duration.value = durationSeconds > 0 ? durationSeconds : 0; - const currentSecond = data.positionMillis / 1000; - if (currentSecond <= durationSeconds) { - currentTime.value = currentSecond; - } + const handlePlaybackStatusUpdate = (data: AudioStatus): void => { + if (!data.isLoaded) return; + const durationSeconds = data.duration || 0; + duration.value = durationSeconds > 0 ? durationSeconds : 0; + const currentSecond = data.currentTime || 0; + if (currentSecond <= durationSeconds) { + currentTime.value = currentSecond; } }; - const onEnd = (data: AVPlaybackStatus) => { + const onEnd = (data: AudioStatus) => { if (data.isLoaded && data.didJustFinish) { try { setPaused(true); currentTime.value = 0; - } catch { - // do nothing + AudioManager.setPositionAsync(audioUri.current, 0).catch(log); + } catch (error) { + log(error); } } }; - const setPosition = async (time: number) => { + const setPosition = async (time: number): Promise => { await AudioManager.setPositionAsync(audioUri.current, time); }; @@ -102,7 +103,7 @@ const AudioPlayer = ({ }; useEffect(() => { - AudioManager.setRateAsync(audioUri.current, playbackSpeed); + AudioManager.setRate(audioUri.current, playbackSpeed); }, [playbackSpeed]); const onPress = () => { @@ -121,7 +122,7 @@ const AudioPlayer = ({ try { audioUri.current = await AudioManager.loadAudio({ msgId, rid, uri: fileUri }); AudioManager.setOnPlaybackStatusUpdate(audioUri.current, onPlaybackStatusUpdate); - AudioManager.setRateAsync(audioUri.current, playbackSpeed); + AudioManager.setRate(audioUri.current, playbackSpeed); } catch { // do nothing } diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index e470df6bc2c..a1f7acfcec1 100644 --- a/app/containers/MessageComposer/MessageComposer.test.tsx +++ b/app/containers/MessageComposer/MessageComposer.test.tsx @@ -1,6 +1,7 @@ import { useEffect, type ReactElement, type RefObject } from 'react'; -import { act, render, screen, fireEvent, waitFor, userEvent } from '@testing-library/react-native'; +import { act, render, renderHook, screen, fireEvent, waitFor, userEvent } from '@testing-library/react-native'; import { Provider } from 'react-redux'; +import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync, useAudioRecorder, PermissionStatus } from 'expo-audio'; import { MessageComposerContainer } from './MessageComposerContainer'; import { ComposerAttachments } from './components/Attachments/ComposerAttachments'; @@ -23,6 +24,10 @@ import { useMessageComposerApi } from './context'; import { type IMessageComposerRef } from './interfaces'; import { sendFileMessage } from '~/lib/methods/sendFileMessage'; import { runSlashCommand } from '~/lib/services/restApi'; +import log from '~/lib/methods/helpers/log'; +import { RECORDING_SETTINGS } from '~/lib/constants/audio'; + +jest.mock('~/lib/methods/helpers/log', () => ({ __esModule: true, default: jest.fn() })); jest.useFakeTimers(); @@ -734,6 +739,33 @@ describe('MessageComposer', () => { }); describe('Audio', () => { + test('requests undetermined microphone permission before recording', async () => { + jest.mocked(getRecordingPermissionsAsync).mockResolvedValueOnce({ + status: PermissionStatus.UNDETERMINED, + granted: false, + canAskAgain: true, + expires: 'never' + }); + jest.mocked(requestRecordingPermissionsAsync).mockClear(); + render(); + await user.press(screen.getByTestId('message-composer-send-audio')); + expect(requestRecordingPermissionsAsync).toHaveBeenCalled(); + expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen(); + }); + + test('exits recording when recorder preparation fails', async () => { + const error = new Error('Failed to prepare recorder'); + const { result } = renderHook(() => useAudioRecorder(RECORDING_SETTINGS)); + const recorder = result.current; + jest.mocked(recorder.prepareToRecordAsync).mockRejectedValueOnce(error); + jest.mocked(useAudioRecorder).mockReturnValueOnce(recorder); + render(); + await user.press(screen.getByTestId('message-composer-send-audio')); + await waitFor(() => expect(log).toHaveBeenCalledWith(error)); + expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen(); + expect(recorder.record).not.toHaveBeenCalled(); + }); + test('tap record', async () => { render(); expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen(); diff --git a/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx b/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx index cfc4a5051cd..24b34428161 100644 --- a/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx @@ -1,11 +1,11 @@ -import { Audio } from 'expo-av'; +import { requestRecordingPermissionsAsync, getRecordingPermissionsAsync, PermissionStatus } from 'expo-audio'; import { useContext, type ReactElement } from 'react'; import { Alert } from 'react-native'; -import { PermissionStatus } from 'expo-camera'; import i18n from '~/i18n'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import { openAppSettings } from '~/lib/methods/helpers/openAppSettings'; +import log from '~/lib/methods/helpers/log'; import { useTheme } from '~/theme'; import { useComposerRid, useComposerSharing } from '~/containers/MessageComposer/ComposerStore'; import { @@ -28,16 +28,27 @@ export const MicOrSendButton = (): ReactElement | null => { const { colors } = useTheme(); const { setRecordingAudio } = useMessageComposerApi(); - const requestPermissionAndStartToRecordAudio = () => - Audio.requestPermissionsAsync() - .then(({ granted }) => setRecordingAudio(granted)) - .catch(() => {}); + const requestPermissionAndStartToRecordAudio = async () => { + try { + const { granted } = await requestRecordingPermissionsAsync(); + setRecordingAudio(granted); + } catch (error) { + log(error); + setRecordingAudio(false); + } + }; const startRecording = async () => { - const { status, granted, canAskAgain } = await Audio.getPermissionsAsync(); - if (granted) return setRecordingAudio(true); - if (status === PermissionStatus.UNDETERMINED) return requestPermissionAndStartToRecordAudio(); - if (canAskAgain) return requestPermissionAndStartToRecordAudio(); + try { + const { status, granted, canAskAgain } = await getRecordingPermissionsAsync(); + if (granted) return setRecordingAudio(true); + if (status === PermissionStatus.UNDETERMINED) return requestPermissionAndStartToRecordAudio(); + if (canAskAgain) return requestPermissionAndStartToRecordAudio(); + } catch (error) { + log(error); + setRecordingAudio(false); + return; + } Alert.alert( i18n.t('Microphone_access_needed_to_record_audio'), diff --git a/app/containers/MessageComposer/components/RecordAudio/Duration.tsx b/app/containers/MessageComposer/components/RecordAudio/Duration.tsx index d8396723cb4..9390b653311 100644 --- a/app/containers/MessageComposer/components/RecordAudio/Duration.tsx +++ b/app/containers/MessageComposer/components/RecordAudio/Duration.tsx @@ -1,32 +1,24 @@ -import { forwardRef, useImperativeHandle, useState } from 'react'; +import { useState } from 'react'; import { type FontVariant, Text } from 'react-native'; -import { type Audio } from 'expo-av'; +import { useAudioRecorderState, type AudioRecorder } from 'expo-audio'; import sharedStyles from '~/views/Styles'; import { useTheme } from '~/theme'; import { formatTime } from './utils'; -export interface IDurationRef { - onRecordingStatusUpdate: (status: Audio.RecordingStatus) => void; -} - -export const Duration = forwardRef((_, ref) => { +export const Duration = ({ audioRecorder }: { audioRecorder: AudioRecorder }) => { const [styles] = useStyle(); + const recorderState = useAudioRecorderState(audioRecorder); const [duration, setDuration] = useState('00:00'); - useImperativeHandle(ref, () => ({ - onRecordingStatusUpdate - })); - - const onRecordingStatusUpdate = (status: Audio.RecordingStatus) => { - if (!status.isRecording) { - return; - } - setDuration(formatTime(Math.floor(status.durationMillis / 1000))); - }; + // Adjust state during render (instead of an effect) to sync the timer with the recorder state + const nextDuration = recorderState.isRecording ? formatTime(Math.floor(recorderState.durationMillis / 1000)) : duration; + if (nextDuration !== duration) { + setDuration(nextDuration); + } return {duration}; -}); +}; function useStyle() { const { colors } = useTheme(); diff --git a/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx b/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx index 773fab2203f..25c6712dcfa 100644 --- a/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx +++ b/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx @@ -1,6 +1,6 @@ import { View, Text } from 'react-native'; -import { useEffect, useRef, useState, type ReactElement } from 'react'; -import { Audio } from 'expo-av'; +import { useEffect, useState, type ReactElement } from 'react'; +import { requestRecordingPermissionsAsync, setAudioModeAsync, useAudioRecorder } from 'expo-audio'; import { getInfoAsync } from 'expo-file-system/legacy'; import { useKeepAwake } from 'expo-keep-awake'; import { shallowEqual } from 'react-redux'; @@ -12,22 +12,21 @@ import sharedStyles from '~/views/Styles'; import { ReviewButton } from './ReviewButton'; import { useMessageComposerApi } from '~/containers/MessageComposer/context'; import { sendFileMessage } from '~/lib/methods/sendFileMessage'; -import { RECORDING_EXTENSION, RECORDING_MODE, RECORDING_SETTINGS } from '~/lib/constants/audio'; +import { RECORDING_EXTENSION, RECORDING_SETTINGS, RECORDING_MODE } from '~/lib/constants/audio'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import log from '~/lib/methods/helpers/log'; import { type IUpload } from '~/definitions'; import { useComposerRid, useComposerTmid } from '~/containers/MessageComposer/ComposerStore'; import { useCanUploadFile } from '~/containers/MessageComposer/hooks'; -import { Duration, type IDurationRef } from './Duration'; +import { Duration } from './Duration'; import AudioPlayer from '~/containers/AudioPlayer'; import { CancelButton } from './CancelButton'; import i18n from '~/i18n'; export const RecordAudio = (): ReactElement | null => { const [styles, colors] = useStyle(); - const recordingRef = useRef(null); - const durationRef = useRef({} as IDurationRef); - const numberOfTriesRef = useRef(0); + const audioRecorder = useAudioRecorder(RECORDING_SETTINGS); + const [status, setStatus] = useState<'recording' | 'reviewing'>('recording'); const { setRecordingAudio } = useMessageComposerApi(); const rid = useComposerRid(); @@ -40,33 +39,26 @@ export const RecordAudio = (): ReactElement | null => { useEffect(() => { const record = async () => { try { - await Audio.setAudioModeAsync(RECORDING_MODE); - recordingRef.current = new Audio.Recording(); - await recordingRef.current.prepareToRecordAsync(RECORDING_SETTINGS); - recordingRef.current.setOnRecordingStatusUpdate(durationRef.current.onRecordingStatusUpdate); - await recordingRef.current.startAsync(); - } catch (error: any) { - // error only occurs on iOS devices - if (error?.code === 'E_AUDIO_RECORDERNOTCREATED') { - if (numberOfTriesRef.current <= 5) { - recordingRef.current = null; - numberOfTriesRef.current += 1; - setTimeout(() => { - record(); - }, 100); - } else { - console.error(error); - } - } else { - console.error(error); + const permissions = await requestRecordingPermissionsAsync(); + if (!permissions.granted) { + setRecordingAudio(false); + return; } + + await setAudioModeAsync(RECORDING_MODE); + await audioRecorder.prepareToRecordAsync(); + audioRecorder.record(); + } catch (error) { + log(error); + setRecordingAudio(false); } }; + record(); return () => { try { - recordingRef.current?.stopAndUnloadAsync(); + audioRecorder.stop(); } catch { // Do nothing } @@ -75,7 +67,7 @@ export const RecordAudio = (): ReactElement | null => { const cancelRecording = async () => { try { - await recordingRef.current?.stopAndUnloadAsync(); + await audioRecorder.stop(); } catch { // Do nothing } finally { @@ -85,7 +77,7 @@ export const RecordAudio = (): ReactElement | null => { const goReview = async () => { try { - await recordingRef.current?.stopAndUnloadAsync(); + await audioRecorder.stop(); setStatus('reviewing'); } catch { // Do nothing @@ -94,16 +86,14 @@ export const RecordAudio = (): ReactElement | null => { const sendAudio = async () => { try { - if (!rid) return; - setRecordingAudio(false); - const fileURI = recordingRef.current?.getURI(); - const fileData = await getInfoAsync(fileURI as string); + if (!rid || !audioRecorder.uri) return; + const fileData = await getInfoAsync(audioRecorder.uri); const fileInfo = { name: `${Date.now()}${RECORDING_EXTENSION}`, mime: 'audio/aac', type: 'audio/aac', store: 'Uploads', - path: fileURI, + path: fileData.uri, size: fileData.exists ? fileData.size : null } as IUpload; @@ -112,7 +102,9 @@ export const RecordAudio = (): ReactElement | null => { await sendFileMessage(rid, fileInfo, tmid, server, user); } } + setRecordingAudio(false); } catch (e) { + setRecordingAudio(false); log(e); } }; @@ -125,7 +117,7 @@ export const RecordAudio = (): ReactElement | null => { return ( - + @@ -146,7 +138,7 @@ export const RecordAudio = (): ReactElement | null => { - + diff --git a/app/containers/Ringer/index.tsx b/app/containers/Ringer/index.tsx index 19dad9435cd..59174edc05b 100644 --- a/app/containers/Ringer/index.tsx +++ b/app/containers/Ringer/index.tsx @@ -1,5 +1,7 @@ -import { Audio } from 'expo-av'; -import { useEffect, useRef, memo } from 'react'; +import { createAudioPlayer } from 'expo-audio'; +import { useEffect, memo } from 'react'; + +import log from '~/lib/methods/helpers/log'; export enum ERingerSounds { DIALTONE = 'dialtone', @@ -7,25 +9,24 @@ export enum ERingerSounds { } const Ringer = memo(({ ringer }: { ringer: ERingerSounds }) => { - const sound = useRef(new Audio.Sound()); - useEffect(() => { - const loadAndPlay = async () => { + const soundFile = ringer === ERingerSounds.DIALTONE ? require('./dialtone.mp3') : require('./ringtone.mp3'); + const player = createAudioPlayer(soundFile); + try { + player.loop = true; + player.play(); + } catch (error) { + log(error); + } + + return () => { try { - const soundFile = ringer === ERingerSounds.DIALTONE ? require(`./dialtone.mp3`) : require(`./ringtone.mp3`); - await sound.current.loadAsync(soundFile); - await sound.current.playAsync(); - await sound.current.setIsLoopingAsync(true); + player.pause(); + player.release(); } catch (error) { - console.error('Error loading sound:', error); + log(error); } }; - - loadAndPlay(); - - return () => { - sound.current?.unloadAsync(); - }; }, []); return null; diff --git a/app/containers/VideoPlayer/__snapshots__/index.test.tsx.snap b/app/containers/VideoPlayer/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..4e2d0c7e633 --- /dev/null +++ b/app/containers/VideoPlayer/__snapshots__/index.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Snapshots: Basic should match snapshot 1`] = ` + +`; diff --git a/app/containers/VideoPlayer/index.stories.tsx b/app/containers/VideoPlayer/index.stories.tsx new file mode 100644 index 00000000000..fa4ee1ef025 --- /dev/null +++ b/app/containers/VideoPlayer/index.stories.tsx @@ -0,0 +1,20 @@ +import { type ReactElement } from 'react'; + +import VideoPlayer from '.'; + +export default { + title: 'Video Player' +}; + +const attachment = { + title_link: 'https://open.rocket.chat/video.mp4', + video_url: 'https://open.rocket.chat/video.mp4' +}; + +const user = { id: 'user-id', token: 'token' }; + +const setLoading = () => undefined; + +export const Basic = (): ReactElement => ( + +); diff --git a/app/containers/VideoPlayer/index.test.tsx b/app/containers/VideoPlayer/index.test.tsx new file mode 100644 index 00000000000..12e94566684 --- /dev/null +++ b/app/containers/VideoPlayer/index.test.tsx @@ -0,0 +1,90 @@ +import { render } from '@testing-library/react-native'; +import { Alert } from 'react-native'; + +import VideoPlayer from '.'; +import { useAppNavigation } from '~/lib/hooks/navigation'; +import { generateSnapshots } from '~/.rnstorybook/generateSnapshots'; +import * as stories from './index.stories'; + +jest.mock('expo', () => ({ + useEventListener: jest.fn() +})); + +jest.mock('~/i18n', () => ({ + __esModule: true, + default: { + t: (key: string) => key + } +})); + +jest.mock('~/lib/methods/helpers', () => ({ + formatAttachmentUrl: (url: string) => url, + encodeAttachmentUrl: (url: string) => url +})); + +jest.mock('~/lib/hooks/navigation', () => ({ + useAppNavigation: jest.fn(() => ({ + addListener: jest.fn(() => jest.fn()), + goBack: jest.fn() + })) +})); + +const mockUseEventListener = require('expo').useEventListener as jest.Mock; + +const mockUseAppNavigation = useAppNavigation as jest.Mock; + +let statusCallback: ((event: { status: string }) => void) | undefined; + +const baseProps = { + attachment: { + title_link: 'https://open.rocket.chat/video.mp4', + video_url: 'https://open.rocket.chat/video.mp4' + }, + user: { id: 'user-id', token: 'token' }, + baseUrl: 'https://open.rocket.chat' +}; + +describe('VideoPlayer', () => { + beforeEach(() => { + jest.clearAllMocks(); + statusCallback = undefined; + mockUseAppNavigation.mockReturnValue({ + addListener: jest.fn(() => jest.fn()), + goBack: jest.fn() + }); + mockUseEventListener.mockImplementation((_player: unknown, _event: string, cb: (event: { status: string }) => void) => { + statusCallback = cb; + }); + }); + + it('hides the loading indicator when the video is ready to play', () => { + const setLoading = jest.fn(); + render(); + + statusCallback?.({ status: 'readyToPlay' }); + + expect(setLoading).toHaveBeenCalledWith(false); + }); + + it('alerts and navigates back only once when the video fails to load', () => { + const setLoading = jest.fn(); + const goBack = jest.fn(); + mockUseAppNavigation.mockReturnValue({ + addListener: jest.fn(() => jest.fn()), + goBack + }); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + + render(); + + statusCallback?.({ status: 'error' }); + statusCallback?.({ status: 'error' }); + + expect(alertSpy).toHaveBeenCalledTimes(1); + expect(goBack).toHaveBeenCalledTimes(1); + + alertSpy.mockRestore(); + }); +}); + +generateSnapshots(stories); diff --git a/app/containers/VideoPlayer/index.tsx b/app/containers/VideoPlayer/index.tsx new file mode 100644 index 00000000000..4d156ff367f --- /dev/null +++ b/app/containers/VideoPlayer/index.tsx @@ -0,0 +1,71 @@ +import { useVideoPlayer, VideoView } from 'expo-video'; +import { useEffect, useRef, type Dispatch, type SetStateAction, type ReactElement } from 'react'; +import { View } from 'react-native'; +import { useEventListener } from 'expo'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { styles } from './styles'; +import { useAppNavigation } from '~/lib/hooks/navigation'; +import I18n from '~/i18n'; +import { type IAttachment } from '~/definitions'; +import { formatAttachmentUrl, encodeAttachmentUrl } from '~/lib/methods/helpers'; +import { showErrorAlert } from '~/lib/methods/helpers/info'; + +interface IVideoPlayerProps { + attachment: IAttachment; + user: { id: string; token: string }; + baseUrl: string; + setLoading: Dispatch>; +} + +const VideoPlayer = ({ attachment, user, baseUrl, setLoading }: IVideoPlayerProps): ReactElement => { + const navigation = useAppNavigation(); + const hasHandledErrorRef = useRef(false); + const { bottom } = useSafeAreaInsets(); + + const url = formatAttachmentUrl(attachment.title_link || attachment.video_url, user.id, user.token, baseUrl); + const uri = encodeAttachmentUrl(url); + + const player = useVideoPlayer(uri, player => { + player.play(); + }); + + useEventListener(player, 'statusChange', ({ status }) => { + if (status === 'readyToPlay') { + setLoading(false); + } else if (status === 'error' && !hasHandledErrorRef.current) { + hasHandledErrorRef.current = true; + setLoading(false); + showErrorAlert(I18n.t('There_was_an_error_while_playing_video'), I18n.t('Error')); + navigation.goBack(); + } + }); + + useEffect(() => { + hasHandledErrorRef.current = false; + }, [uri]); + + useEffect(() => { + const blurSub = navigation.addListener('blur', () => { + player.pause(); + }); + return () => { + blurSub(); + }; + }, [navigation, player]); + + return ( + + + + ); +}; + +export default VideoPlayer; diff --git a/app/containers/VideoPlayer/styles.ts b/app/containers/VideoPlayer/styles.ts new file mode 100644 index 00000000000..c445de8903d --- /dev/null +++ b/app/containers/VideoPlayer/styles.ts @@ -0,0 +1,7 @@ +import { StyleSheet } from 'react-native'; + +export const styles = StyleSheet.create({ + container: { + flex: 1 + } +}); diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 52945e6c6c3..9439f105e59 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -209,6 +209,7 @@ "Enter_E2EE_Password_description": "أدخل كلمة مرور E2EE لعرض وإرسال الرسائل المشفرة.\n\nيجب إدخال كلمة المرور على كل جهاز.", "Enter_manually": "أدخل يدويًا", "Enter_username_or_number": "أدخل اسم المستخدم أو الرقم", + "Error": "خطأ", "Error_incorrect_password": "كلمة المرور غير صحيحة", "Error_prefix": "خطأ: {{message}}", "Error_uploading": "خطأ في الرفع", @@ -619,6 +620,7 @@ "The_user_wont_be_able_to_type_in_roomName": "المستخدم لن يتمكن من الكتابة في {{roomName}}", "Theme": "سمة", "There_was_an_error_while_action": "حدث خطأ أثناء {{action}}!", + "There_was_an_error_while_playing_video": "حدث خطأ أثناء تشغيل الفيديو.", "This_room_is_blocked": "هذه الغرفة محظورة", "This_room_is_read_only": "هذه الغرفة للقراءة فقط", "This_will_clear_all_your_offline_data": "سيؤدي هذا إلى محو جميع بياناتك في وضع عدم الاتصال.", diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index f8405d979fd..7402a97dc4d 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -310,6 +310,7 @@ "Enter_E2EE_Password_description": "আপনার ই2ইইই পাসওয়ার্ড প্রবেশ করান গোপন বার্তা দেখতে এবং পাঠানোর জন্য।\n\nপ্রতিটি যন্ত্রে পাসওয়ার্ড প্রবেশ করতে হবে।", "Enter_manually": "ম্যানুয়ালি লিখুন", "Enter_username_or_number": "ব্যবহারকারীর নাম বা নম্বর লিখুন", + "Error": "ত্রুটি", "Error_Download_file": "ফাইল ডাউনলোড করতে ত্রুটি", "Error_incorrect_password": "ভুল পাসওয়ার্ড", "Error_prefix": "ত্রুটি: {{message}}", @@ -840,6 +841,7 @@ "The_user_wont_be_able_to_type_in_roomName": "ব্যবহারকারী প্রতিষ্ঠানগুলি {{roomName}} টাইপ করতে পারবেন না", "Theme": "থিম", "There_was_an_error_while_action": "{{action}} সময়ে একটি ত্রুটি ছিল!", + "There_was_an_error_while_playing_video": "ভিডিও চালানোর সময় একটি ত্রুটি ঘটেছে।", "This_room_is_blocked": "এই রুমটি অবরোধিত", "This_room_is_read_only": "এই রুমটি শুধুমাত্র পড়া যায়", "This_will_clear_all_your_offline_data": "এটি আপনার সমস্ত অফলাইন ডেটা মুছে ফেলবে।", diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 9504525eb3a..199c40e0381 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -329,6 +329,7 @@ "Enter_Your_E2E_Password": "Zadejte heslo E2E", "Enter_Your_Encryption_Password_desc1": "To vám umožní přístup k vašim zašifrovaným soukromým skupinám a přímým zprávám.", "Enter_Your_Encryption_Password_desc2": "Ke kódování/dekódování zpráv musíte zadat heslo na každém místě, kde používáte chat.", + "Error": "Chyba", "Error_Download_file": "Chyba při stahování souboru", "Error_incorrect_password": "Nesprávné heslo", "Error_prefix": "Chyba: {{message}}", @@ -903,6 +904,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Uživatel nebude moci psát v {{roomName}}", "Theme": "Téma", "There_was_an_error_while_action": "Při {{action}} došlo k chybě!", + "There_was_an_error_while_playing_video": "Při přehrávání videa došlo k chybě.", "This_room_is_blocked": "Tato místnost je zablokována", "This_room_is_read_only": "Tato místnost je pouze pro čtení", "This_will_clear_all_your_offline_data": "Tímto vymažete všechna vaše offline data.", diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index 2ede91213d0..9e4e5a5a4d8 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -304,6 +304,7 @@ "Enter_E2EE_Password_description": "Geben Sie Ihr E2EE-Passwort ein, um verschlüsselte Nachrichten anzuzeigen und zu senden.\n\nDas Passwort muss auf jedem Gerät eingegeben werden.", "Enter_manually": "Manuell eingeben", "Enter_username_or_number": "Benutzername oder Nummer eingeben", + "Error": "Fehler", "Error_Download_file": "Fehler beim Herunterladen der Datei", "Error_incorrect_password": "Falsches Passwort", "Error_prefix": "Fehler: {{message}}", @@ -832,6 +833,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Dem Nutzer wird es nicht möglich sein in {{roomName}} zu schreiben", "Theme": "Erscheinungsbild", "There_was_an_error_while_action": "Während {{action}} ist ein Fehler aufgetreten!", + "There_was_an_error_while_playing_video": "Beim Abspielen des Videos ist ein Fehler aufgetreten.", "This_room_is_blocked": "Dieser Room ist gesperrt", "This_room_is_read_only": "Dieser Room kann nur gelesen werden", "This_will_clear_all_your_offline_data": "Dies wird Ihre Offline-Daten löschen.", diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 7693b5faa92..9fd5f9b0a34 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -350,9 +350,9 @@ "Enter_manually": "Enter manually", "Enter_the_code": "Enter the code we just emailed you.", "Enter_username_or_number": "Enter username or number", + "Error": "Error", "Error_Download_file": "Error while downloading file", "Error_incorrect_password": "Incorrect password", - "Error_play_video": "There was an error while playing this video", "Error_prefix": "Error: {{message}}", "Error_uploading": "Error uploading", "error-action-not-allowed": "{{action}} is not allowed", @@ -929,6 +929,7 @@ "The_user_wont_be_able_to_type_in_roomName": "The user won't be able to type in {{roomName}}", "Theme": "Theme", "There_was_an_error_while_action": "There was an error while {{action}}!", + "There_was_an_error_while_playing_video": "There was an error while playing the video.", "This_room_is_blocked": "This room is blocked", "This_room_is_read_only": "This room is read only", "This_will_clear_all_your_offline_data": "This will clear all your offline data.", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index d40287f08e8..da62e02988b 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -174,6 +174,7 @@ "Enter_E2EE_Password_description": "Ingrese su contraseña E2EE para ver y enviar mensajes cifrados.\n\nLa contraseña debe ingresarse en cada dispositivo.", "Enter_manually": "Ingresar manualmente", "Enter_username_or_number": "Introduzca usuario o número", + "Error": "Error", "Error_incorrect_password": "Contraseña incorrecta", "Error_prefix": "Error: {{mensaje}}", "Error_uploading": "Error en la subida", @@ -464,6 +465,7 @@ "The_autocomplete_options_are_available_above_the_input_composer": "Las opciones de autocompletado están disponibles arriba del compositor de entrada.", "Theme": "Tema", "There_was_an_error_while_action": "¡Ha habido un error mientras {{action}}!", + "There_was_an_error_while_playing_video": "Se produjo un error al reproducir el vídeo.", "This_room_is_blocked": "La sala está bloqueada", "This_room_is_read_only": "Esta sala es de sólo lectura", "Thread": "Hilo", diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index c21d7453d2a..8fa95b736e7 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -289,6 +289,7 @@ "Enter_E2EE_Password_description": "Syötä E2EE-salasanasi, jotta voit tarkastella ja lähettää salattuja viestejä.\n\nSalasana on syötettävä jokaisella laitteella.", "Enter_manually": "Syötä manuaalisesti", "Enter_username_or_number": "Anna käyttäjänimi tai numero", + "Error": "Virhe", "Error_Download_file": "Virhe ladattaessa tiedostoa", "Error_incorrect_password": "Virheellinen salasana", "Error_prefix": "Virhe: {{message}}", @@ -807,6 +808,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Käyttäjä ei voi kirjoittaa huoneessa {{roomName}}", "Theme": "Teema", "There_was_an_error_while_action": "Virhe toiminnon {{action}} aikana!", + "There_was_an_error_while_playing_video": "Videon toiston aikana tapahtui virhe.", "This_room_is_blocked": "Huone on estetty", "This_room_is_read_only": "Huone on vain luku -tilassa", "This_will_clear_all_your_offline_data": "Tämä poistaa kaikki offline-tietosi.", diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index 5c4cbdfe90a..91b03429ccf 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -257,6 +257,7 @@ "Enter_E2EE_Password_description": "Entrez votre mot de passe E2EE pour consulter et envoyer des messages chiffrés.\n\nLe mot de passe doit être saisi sur chaque appareil.", "Enter_manually": "Saisir manuellement", "Enter_username_or_number": "Saisissez un nom d’utilisateur ou un numéro", + "Error": "Erreur", "Error_Download_file": "Erreur lors du téléchargement du fichier", "Error_incorrect_password": "Mot de passe incorrect", "Error_prefix": "Erreur : {{message}}", @@ -740,6 +741,7 @@ "The_user_wont_be_able_to_type_in_roomName": "L'utilisateur ne pourra pas écrire dans {{roomName}}", "Theme": "Thème", "There_was_an_error_while_action": "Une erreur s'est produite lors de {{action}} !", + "There_was_an_error_while_playing_video": "Une erreur s'est produite lors de la lecture de la vidéo.", "This_room_is_blocked": "Ce salon est bloqué", "This_room_is_read_only": "Ce salon est en lecture seule", "This_will_clear_all_your_offline_data": "Cela effacera toutes vos données hors ligne.", diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index d437e02defe..de52a5998f1 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -310,6 +310,7 @@ "Enter_E2EE_Password_description": "एन्क्रिप्टेड संदेश देखने और भेजने के लिए अपना E2EE पासवर्ड दर्ज करें।\n\nप्रत्येक डिवाइस पर पासवर्ड दर्ज करना आवश्यक है।", "Enter_manually": "मैन्युअल रूप से दर्ज करें", "Enter_username_or_number": "उपयोगकर्ता नाम या नंबर दर्ज करें", + "Error": "त्रुटि", "Error_Download_file": "फ़ाइल डाउनलोड करते समय त्रुटि", "Error_incorrect_password": "गलत पासवर्ड", "Error_prefix": "त्रुटि: {{message}}", @@ -840,6 +841,7 @@ "The_user_wont_be_able_to_type_in_roomName": "उपयोगकर्ता {{roomName}} में टाइप नहीं कर सकता है", "Theme": "थीम", "There_was_an_error_while_action": "{{action}} करते समय एक त्रुटि हुई थी!", + "There_was_an_error_while_playing_video": "वीडियो चलाते समय एक त्रुटि हुई।", "This_room_is_blocked": "यह कमरा ब्लॉक किया गया है", "This_room_is_read_only": "यह कमरा केवल पठन के लिए है", "This_will_clear_all_your_offline_data": "यह आपके सभी ऑफ़लाइन डेटा को हटा देगा।", diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 85fc9042746..52e10e5ded8 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -310,6 +310,7 @@ "Enter_E2EE_Password_description": "Adja meg az E2EE jelszavát a titkosított üzenetek megtekintéséhez és küldéséhez.\n\nA jelszót minden eszközön meg kell adni.", "Enter_manually": "Kézi bevitel", "Enter_username_or_number": "Adjon meg felhasználónevet vagy számot", + "Error": "Hiba", "Error_Download_file": "Hiba a fájl letöltése közben", "Error_incorrect_password": "Helytelen jelszó", "Error_prefix": "Hiba: {{üzenet}}", @@ -841,6 +842,7 @@ "The_user_wont_be_able_to_type_in_roomName": "A felhasználó nem tud írni itt: {{roomName}}", "Theme": "Téma", "There_was_an_error_while_action": "Hiba történt a {{action}}!", + "There_was_an_error_while_playing_video": "Hiba történt a videó lejátszása közben.", "This_room_is_blocked": "Ez a szoba tiltva van", "This_room_is_read_only": "Ez a szoba csak olvasható", "This_will_clear_all_your_offline_data": "Ez törli az összes offline adatot.", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 414fbe686a4..e57a0a868c1 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -233,6 +233,7 @@ "Enter_E2EE_Password_description": "Inserisci la tua password E2EE per visualizzare e inviare messaggi crittografati.\n\nLa password deve essere inserita su ciascun dispositivo.", "Enter_manually": "Inserisci manualmente", "Enter_username_or_number": "Inserisci nome utente o numero", + "Error": "Errore", "Error_incorrect_password": "Password errata", "Error_prefix": "Errore: {{message}}", "Error_uploading": "Errore nel caricamento di", @@ -653,6 +654,7 @@ "The_user_wont_be_able_to_type_in_roomName": "L'utente non potrà scrivere in {{roomName}}", "Theme": "Tema", "There_was_an_error_while_action": "Si è verificato un errore nel {{action}}!", + "There_was_an_error_while_playing_video": "Si è verificato un errore durante la riproduzione del video.", "This_room_is_blocked": "Questa stanza è bloccata", "This_room_is_read_only": "Questa stanza è in sola lettura", "This_will_clear_all_your_offline_data": "Questo cancellerà tutti i tuoi dati offline.", diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 1f4aca707b6..292135e6973 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -208,6 +208,7 @@ "Enter_E2EE_Password_description": "E2EEパスワードを入力して、暗号化されたメッセージを表示および送信してください。\n\n各デバイスでパスワードを入力する必要があります。", "Enter_manually": "手動で入力", "Enter_username_or_number": "ユーザー名または番号を入力", + "Error": "エラー", "Error_incorrect_password": "パスワードが違います。", "Error_prefix": "エラー: {{message}}", "Error_uploading": "アップロードエラー", @@ -551,6 +552,7 @@ "The_autocomplete_options_are_available_above_the_input_composer": "オートコンプリートのオプションは入力コンポーザーの上にあります。", "Theme": "テーマ", "There_was_an_error_while_action": "{{action}}の最中にエラーが発生しました!", + "There_was_an_error_while_playing_video": "動画の再生中にエラーが発生しました。", "This_room_is_blocked": "このルームはブロックされています。", "This_room_is_read_only": "このルームは読み取り専用です。", "This_will_clear_all_your_offline_data": "オフラインデータをすべて削除します。", diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 2586c4802b0..154f45b8274 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -257,6 +257,7 @@ "Enter_E2EE_Password_description": "Voer je E2EE-wachtwoord in om versleutelde berichten te bekijken en te verzenden.\n\nHet wachtwoord moet op elk apparaat worden ingevoerd.", "Enter_manually": "Handmatig invoeren", "Enter_username_or_number": "Voer een gebruikersnaam of nummer in", + "Error": "Fout", "Error_Download_file": "Fout tijdens het downloaden van bestand", "Error_incorrect_password": "Onjuist wachtwoord", "Error_prefix": "Fout: {{message}}", @@ -740,6 +741,7 @@ "The_user_wont_be_able_to_type_in_roomName": "De gebruiker zal in {{roomName}} niet kunnen typen", "Theme": "Thema", "There_was_an_error_while_action": "Er is een fout opgetreden tijdens {{action}}!", + "There_was_an_error_while_playing_video": "Er is een fout opgetreden tijdens het afspelen van de video.", "This_room_is_blocked": "Deze kamer is geblokkeerd", "This_room_is_read_only": "Deze kamer is alleen-lezen", "This_will_clear_all_your_offline_data": "Hiermee worden al jouw offline gegevens gewist.", diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 13d01cc9f0c..1903be41b3c 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -174,6 +174,7 @@ "Enter_manually": "Skriv inn manuelt", "Enter_the_code": "Skriv inn koden vi nettopp sendte deg på e-post.", "Enter_username_or_number": "Skriv inn brukarnamn eller nummer", + "Error": "Feil", "error-action-not-allowed": "{{action}} er ikke tillatt", "error-avatar-invalid-url": "Ugyldig avatar URL: {{url}}", "error-email-send-failed": "Feil ved å prøve å sende e-post: {{message}}", @@ -419,6 +420,7 @@ "The_maximum_number_of_users_has_been_reached": "Maksimalt antall brukere er nådd.", "The_room_does_not_exist": "Rommet eksisterer ikke eller du har ikke tilgang", "Theme": "Tema", + "There_was_an_error_while_playing_video": "Det oppstod ein feil under avspeling av videoen.", "This_room_is_blocked": "Dette rommet er blokkert", "This_room_is_read_only": "Dette rommet er kun skrivebeskyttet", "Thread_message": "trådmelding {{msg}}", diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index b8dfc15e5eb..975694a1714 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -335,8 +335,8 @@ "Enter_manually": "Skriv inn manuelt", "Enter_the_code": "Skriv inn koden vi nettopp sendte deg på e-post.", "Enter_username_or_number": "Skriv inn brukernavn eller nummer", + "Error": "Feil", "Error_Download_file": "Feil under nedlasting av fil", - "Error_play_video": "Det oppsto en feil under avspilling av denne videoen", "Error_uploading": "Feil ved opplasting", "error-action-not-allowed": "{{action}} er ikke tillat", "error-avatar-invalid-url": "Ugyldig avatar URL: {{url}}", @@ -887,6 +887,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Brukeren vil ikke kunne skrive inn {{roomName}}", "Theme": "Tema", "There_was_an_error_while_action": "Det oppsto en feil mens {{action}}!", + "There_was_an_error_while_playing_video": "Det oppstod en feil under avspilling av videoen.", "This_room_is_blocked": "Dette rommet er blokkert", "This_room_is_read_only": "Dette rommet er skrivebeskyttet", "This_will_clear_all_your_offline_data": "Dette vil slette alle dine frakoblede data.", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 2799f35d17f..8207718be7e 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -339,9 +339,9 @@ "Enter_manually": "Inserir manualmente", "Enter_the_code": "Insira o código que acabamos de enviar por e-mail.", "Enter_username_or_number": "Digite o usuário ou número", + "Error": "Erro", "Error_Download_file": "Erro ao baixar o arquivo", "Error_incorrect_password": "Senha incorreta", - "Error_play_video": "Houve um erro ao reproduzir esse vídeo", "Error_prefix": "Erro: {{message}}", "Error_uploading": "Erro subindo", "error-action-not-allowed": "{{action}} não é permitido", @@ -912,6 +912,7 @@ "The_user_wont_be_able_to_type_in_roomName": "O usuário não poderá digitar em {{roomName}}", "Theme": "Tema", "There_was_an_error_while_action": "Aconteceu um erro {{action}}!", + "There_was_an_error_while_playing_video": "Ocorreu um erro ao reproduzir o vídeo.", "This_room_is_blocked": "Esta sala está bloqueada", "This_room_is_read_only": "Esta sala é somente leitura", "This_will_clear_all_your_offline_data": "Isto limpará todos os seus dados offline.", diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index dc28164afdb..7af37603c07 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -204,6 +204,7 @@ "Enter_E2EE_Password_description": "Introduza a sua palavra-passe E2EE para ver e enviar mensagens encriptadas.\n\nA palavra-passe precisa ser introduzida em cada dispositivo.", "Enter_manually": "Inserir manualmente", "Enter_username_or_number": "Introduza o nome de utilizador ou número", + "Error": "Erro", "Error_incorrect_password": "Palavra-passe incorreta", "Error_prefix": "Erro: {{message}}", "Error_uploading": "Erro ao fazer o envio", @@ -535,6 +536,7 @@ "Terms_of_Service": " Termos do Serviço ", "The_autocomplete_options_are_available_above_the_input_composer": "As opções de preenchimento automático estão disponíveis acima do compositor de entrada.", "There_was_an_error_while_action": "Houve um erro enquanto {{action}}!", + "There_was_an_error_while_playing_video": "Ocorreu um erro ao reproduzir o vídeo.", "This_room_is_blocked": "Esta sala está bloqueada", "This_room_is_read_only": "Esta sala é apenas de leitura", "Thread_message": "mensagem do tópico {{msg}}", diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 81bcdcc4e02..c4760411621 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -280,6 +280,7 @@ "Enter_E2EE_Password_description": "Введите ваш E2EE пароль, чтобы просматривать и отправлять зашифрованные сообщения.\n\nПароль необходимо вводить на каждом устройстве.", "Enter_manually": "Ввести вручную", "Enter_username_or_number": "Введите имя пользователя или номер", + "Error": "Ошибка", "Error_Download_file": "Ошибка при скачивании файла", "Error_incorrect_password": "Неверный пароль", "Error_prefix": "Ошибка: {{сообщение}}", @@ -784,6 +785,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Пользователь не сможет писать сообщения в {{roomName}}", "Theme": "Тема", "There_was_an_error_while_action": "Произошла ошибка в процессе {{action}}!", + "There_was_an_error_while_playing_video": "Произошла ошибка при воспроизведении видео.", "This_room_is_blocked": "Этот чат заблокирован", "This_room_is_read_only": "Этот чат доступен только для чтения", "This_will_clear_all_your_offline_data": "Это очистит все ваши офлайн данные.", diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 31d1b58580a..6ef4e13ab44 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -267,6 +267,7 @@ "Enter_E2EE_Password_description": "Vnesite svoje geslo E2EE za ogled in pošiljanje šifriranih sporočil.\n\nGeslo je treba vnesti na vsaki napravi.", "Enter_manually": "Vnesi ročno", "Enter_username_or_number": "Vnesite uporabniško ime ali številko", + "Error": "Napaka", "Error_Download_file": "Napaka med prenosom datoteke", "Error_incorrect_password": "Napačno geslo", "Error_prefix": "Napaka: {{message}}", @@ -754,6 +755,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Uporabnik ne bo mogel vtipkavati v {{roomName}}", "Theme": "Tema", "There_was_an_error_while_action": "Med {{action}} je prišlo do napake!", + "There_was_an_error_while_playing_video": "Med predvajanjem videa je prišlo do napake.", "This_room_is_blocked": "Ta soba je blokirana", "This_room_is_read_only": "Ta soba se samo bere", "This_will_clear_all_your_offline_data": "To bo zbrisalo vse vaše podatke brez povezave.", diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 985e04c0f2c..6133d924135 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -289,6 +289,7 @@ "Enter_E2EE_Password_description": "Ange ditt E2EE-lösenord för att visa och skicka krypterade meddelanden.\n\nLösenordet måste anges på varje enhet.", "Enter_manually": "Ange manuellt", "Enter_username_or_number": "Ange användarnamn eller nummer", + "Error": "Fel", "Error_Download_file": "Fel vid nedladdning av fil", "Error_incorrect_password": "Felaktigt lösenord", "Error_prefix": "Fel: {{message}}", @@ -806,6 +807,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Användaren kan inte ange {{roomName}}", "Theme": "Tema", "There_was_an_error_while_action": "Ett fel inträffade vid {{action}}.", + "There_was_an_error_while_playing_video": "Ett fel uppstod när videon spelades upp.", "This_room_is_blocked": "Rummet är blockerat", "This_room_is_read_only": "Rummet är skrivskyddat", "This_will_clear_all_your_offline_data": "Med den här åtgärden rensas alla dina offlinedata.", diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 60bb12b8846..fd2374b09c0 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -310,6 +310,7 @@ "Enter_E2EE_Password_description": "உங்கள் கடவுச்சொல்லைப் பதிவு செய்து குறியாக்கச் செய்திகளைப் பார்க்கவும் மற்றும் அனுப்பவும். \n\nஒவ்வொரு சாதனத்திலும் கடவுச்சொல்லை உள்ளிட வேண்டும்.", "Enter_manually": "கைமுறையாக உள்ளிடவும்", "Enter_username_or_number": "பயனர் பெயர் அல்லது எண்ணை உள்ளிடவும்", + "Error": "பிழை", "Error_Download_file": "கோப்பு பதிவிறக்கம் செய்யும் போது பிழை", "Error_incorrect_password": "தவறான கடவுச்சொல்", "Error_prefix": "பிழை: {{message}}", @@ -840,6 +841,7 @@ "The_user_wont_be_able_to_type_in_roomName": "பயனர் {{roomName}} இல்லை என்றார்", "Theme": "வானம்", "There_was_an_error_while_action": "{{action}} போன்ற நடவடிக்கையில் பிழை ஏற்பட்டது", + "There_was_an_error_while_playing_video": "வீடியோவை இயக்கும்போது ஒரு பிழை ஏற்பட்டது.", "This_room_is_blocked": "இந்த அறை தடைக்கப்பட்டுள்ளது", "This_room_is_read_only": "இந்த அறை மட்டுமே படிக்கக்கூடியது", "This_will_clear_all_your_offline_data": "இது உங்களுடைய அனுமதிகள் அனைத்து ஆஃப்லைன் தரவையும் அழிக்கும்.", diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index f67b28ebfa7..4bee0d051b9 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -309,6 +309,7 @@ "Enter_E2EE_Password_description": "ఎన్‌క్రిప్ట్ చేయబడిన సందేశాలను వీక్షించడానికి మరియు పంపడానికి మీ E2EE పాస్‌వర్డ్‌ని నమోదు చెయ్యండి.\n\nప్రతీ పరికరంలో పాస్‌వర్డ్‌ని నమోదు చేయాల్సి ఉంటుంది.", "Enter_manually": "మాన్యువల్‌గా నమోదు చేయండి", "Enter_username_or_number": "వినియోగదారు పేరు లేదా నంబర్‌ను నమోదు చేయండి", + "Error": "లోపం", "Error_Download_file": "ఫైల్ డౌన్‌లోడ్ చేస్తున్నప్పుడు తప్పిబాటు", "Error_incorrect_password": "తప్పు పాస్‌వర్డ్", "Error_prefix": "తప్పు: {{message}}", @@ -839,6 +840,7 @@ "The_user_wont_be_able_to_type_in_roomName": "వాడు {{roomName}}లో టైప్ చేయగలరు", "Theme": "థీమ్", "There_was_an_error_while_action": "{{action}} ప్రవృత్తి ప్రారంభించడానికి ఒక లోపం ఉంది!", + "There_was_an_error_while_playing_video": "వీడియోను ప్లే చేస్తున్నప్పుడు ఒక లోపం సంభవించింది.", "This_room_is_blocked": "ఈ గది నిషేధించబడింది", "This_room_is_read_only": "ఈ గది కంటెంట్ చదవడానికి మాత్రమే", "This_will_clear_all_your_offline_data": "ఇది మీ ఆఫ్‌లైన్ డేటాను అన్ని క్లియర్ చేస్తుంది.", diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index a378e751c0a..f07d6b01fd7 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -222,6 +222,7 @@ "Enter_E2EE_Password_description": "Şifreli mesajları görüntülemek ve göndermek için E2EE şifrenizi girin.\n\nHer cihazda şifre girişi yapılmalıdır.", "Enter_manually": "Manuel olarak gir", "Enter_username_or_number": "Kullanıcı adı veya numara girin", + "Error": "Hata", "Error_incorrect_password": "Yanlış parola", "Error_prefix": "Hata: {{message}}", "Error_uploading": "Yükleme hatası", @@ -636,6 +637,7 @@ "The_user_wont_be_able_to_type_in_roomName": "Kullanıcı {{roomName}} içinde yazamayacak", "Theme": "Tema", "There_was_an_error_while_action": "{{action}} sırasında bir hata oluştu!", + "There_was_an_error_while_playing_video": "Video oynatılırken bir hata oluştu.", "This_room_is_blocked": "Bu oda engellendi", "This_room_is_read_only": "Bu oda yazma kısıtlı", "This_will_clear_all_your_offline_data": "Bu, tüm çevrimdışı verilerinizi temizleyecektir.", diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 31b581df8a1..75a662179e0 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -218,6 +218,7 @@ "Enter_E2EE_Password_description": "输入您的E2EE密码以查看和发送加密消息。\n\n每台设备都需要输入密码。", "Enter_manually": "手动输入", "Enter_username_or_number": "输入用户名或号码", + "Error": "错误", "Error_incorrect_password": "密码错误", "Error_prefix": "错误:{{message}}", "Error_uploading": "错误上传", @@ -608,6 +609,7 @@ "The_user_wont_be_able_to_type_in_roomName": "此用户将无法在 {{roomName}} 中输入", "Theme": "布景主题", "There_was_an_error_while_action": "{{action}}出现错误!", + "There_was_an_error_while_playing_video": "播放视频时发生错误。", "This_room_is_blocked": "这个聊天室被锁了", "This_room_is_read_only": "这个聊天室是只读的", "This_will_clear_all_your_offline_data": "这将清除您的所有离线资料。", diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index 81b5870168d..78700ad3df7 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -224,6 +224,7 @@ "Enter_E2EE_Password_description": "輸入您的E2EE密碼以查看和發送加密訊息。\n\n每個設備都需要輸入密碼。", "Enter_manually": "手動輸入", "Enter_username_or_number": "輸入使用者名稱或號碼", + "Error": "錯誤", "Error_incorrect_password": "密碼錯誤", "Error_prefix": "錯誤:{{message}}", "Error_uploading": "錯誤上傳", @@ -636,6 +637,7 @@ "The_user_wont_be_able_to_type_in_roomName": "此使用者將無法在 {{roomName}} 中輸入", "Theme": "佈景主題", "There_was_an_error_while_action": "{{action}}出現錯誤!", + "There_was_an_error_while_playing_video": "播放影片時發生錯誤。", "This_room_is_blocked": "這個聊天室已被鎖定", "This_room_is_read_only": "這個聊天室是唯讀的", "This_will_clear_all_your_offline_data": "這將清除您的所有離線資料。", diff --git a/app/lib/constants/audio.ts b/app/lib/constants/audio.ts index f0c506c34de..bfb77daeb4a 100644 --- a/app/lib/constants/audio.ts +++ b/app/lib/constants/audio.ts @@ -1,48 +1,41 @@ -import { Audio, type AudioMode, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av'; -import { type RecordingOptions } from 'expo-av/build/Audio'; +import { isIOS } from '../methods/helpers'; +import { AudioQuality, IOSOutputFormat, type RecordingOptions, type AudioMode } from 'expo-audio'; export const RECORDING_EXTENSION = '.aac'; + export const RECORDING_SETTINGS: RecordingOptions = { + extension: RECORDING_EXTENSION, + sampleRate: 44100, + numberOfChannels: 2, + bitRate: isIOS ? 64000 : 128000, android: { - // Settings related to audio encoding. - extension: RECORDING_EXTENSION, - outputFormat: Audio.AndroidOutputFormat.AAC_ADTS, - audioEncoder: Audio.AndroidAudioEncoder.AAC, - // Settings related to audio quality. - sampleRate: Audio.RecordingOptionsPresets.LOW_QUALITY.android.sampleRate, - numberOfChannels: Audio.RecordingOptionsPresets.LOW_QUALITY.android.numberOfChannels, - bitRate: Audio.RecordingOptionsPresets.LOW_QUALITY.android.bitRate + outputFormat: 'aac_adts', + audioEncoder: 'aac' }, ios: { - // Settings related to audio encoding. - extension: RECORDING_EXTENSION, - audioQuality: Audio.IOSAudioQuality.MEDIUM, - outputFormat: Audio.IOSOutputFormat.MPEG4AAC, - // Settings related to audio quality. - sampleRate: Audio.RecordingOptionsPresets.LOW_QUALITY.ios.sampleRate, - numberOfChannels: Audio.RecordingOptionsPresets.LOW_QUALITY.ios.numberOfChannels, - bitRate: Audio.RecordingOptionsPresets.LOW_QUALITY.ios.bitRate + audioQuality: AudioQuality.MEDIUM, + outputFormat: IOSOutputFormat.MPEG4AAC, + linearPCMBitDepth: 16, + linearPCMIsBigEndian: false, + linearPCMIsFloat: false }, - web: {}, - keepAudioActiveHint: true + web: {} }; export const RECORDING_MODE: AudioMode = { - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - staysActiveInBackground: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: false, - interruptionModeIOS: InterruptionModeIOS.DoNotMix, - interruptionModeAndroid: InterruptionModeAndroid.DoNotMix + playsInSilentMode: true, + shouldPlayInBackground: true, + allowsRecording: true, + shouldRouteThroughEarpiece: false, + interruptionMode: 'doNotMix', + interruptionModeAndroid: 'doNotMix' }; export const AUDIO_MODE: AudioMode = { - allowsRecordingIOS: false, - playsInSilentModeIOS: true, - staysActiveInBackground: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: false, - interruptionModeIOS: InterruptionModeIOS.DoNotMix, - interruptionModeAndroid: InterruptionModeAndroid.DoNotMix + playsInSilentMode: true, + shouldPlayInBackground: true, + allowsRecording: false, + shouldRouteThroughEarpiece: false, + interruptionMode: 'doNotMix', + interruptionModeAndroid: 'doNotMix' }; diff --git a/app/lib/methods/AudioManager.test.ts b/app/lib/methods/AudioManager.test.ts new file mode 100644 index 00000000000..9996b0b1eaf --- /dev/null +++ b/app/lib/methods/AudioManager.test.ts @@ -0,0 +1,255 @@ +jest.mock('../database/services/Message', () => ({ + getMessageById: jest.fn(() => Promise.resolve(null)) +})); + +jest.mock('./getFilePathAudio', () => ({ + getFilePathAudio: jest.fn(() => 'file://x') +})); + +import type AudioManagerInstance from './AudioManager'; +import type { AudioStatus } from 'expo-audio'; +import type { TMessageModel } from '~/definitions'; + +interface IAudioManagerSetup { + AudioManager: typeof AudioManagerInstance; + players: any[]; +} + +const setup = (): IAudioManagerSetup => { + jest.resetModules(); + const players: any[] = []; + const createAudioPlayer = require('expo-audio').createAudioPlayer as jest.Mock; + createAudioPlayer.mockImplementation(() => { + const player = { + play: jest.fn(), + pause: jest.fn(), + stop: jest.fn(), + replay: jest.fn(), + setPlaying: jest.fn(), + setLooping: jest.fn(), + setMuted: jest.fn(), + setVolume: jest.fn(), + shouldCorrectPitch: false, + setPlaybackRate: jest.fn(function (this: { shouldCorrectPitch: boolean }) { + return this.shouldCorrectPitch; + }), + seekTo: jest.fn(), + release: jest.fn(), + remove: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + removeListener: jest.fn(), + getStatusAsync: jest.fn(() => Promise.resolve()), + playing: false, + looping: false, + muted: false, + volume: 1, + playbackRate: 1, + currentTime: 0, + duration: 0, + loaded: false, + progress: 0, + buffering: false, + keepAlive: false + }; + players.push(player); + return player; + }); + const AudioManager = require('./AudioManager').default; + return { AudioManager, players }; +}; + +describe('AudioManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loadAudio caches the player keyed by msgId/rid/uri', async () => { + const { AudioManager, players } = setup(); + const key1 = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + const key2 = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + + expect(key1).toBe(key2); + expect(players).toHaveLength(1); + }); + + it('loadAudio returns different keys for different uris', async () => { + const { AudioManager, players } = setup(); + const key1 = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + const key2 = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://b.mp3' }); + + expect(key1).not.toBe(key2); + expect(players).toHaveLength(2); + }); + + it('unloadRoomAudios releases only players for the given rid', async () => { + const { AudioManager, players } = setup(); + await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + await AudioManager.loadAudio({ rid: 'room-2', uri: 'file://b.mp3' }); + const [player1, player2] = players; + + await AudioManager.unloadRoomAudios('room-1'); + + expect(player1.release).toHaveBeenCalled(); + expect(player2.release).not.toHaveBeenCalled(); + }); + + it('unloadRoomAudios matches by exact rid segment, not substring', async () => { + const { AudioManager, players } = setup(); + await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://room-10/x.mp3' }); + await AudioManager.loadAudio({ rid: 'room-10', uri: 'file://x.mp3' }); + const [playerSubstring, playerExact] = players; + + await AudioManager.unloadRoomAudios('room-1'); + + expect(playerSubstring.release).toHaveBeenCalled(); + expect(playerExact.release).not.toHaveBeenCalled(); + }); + + it('onEnd releases the player when didJustFinish is true', async () => { + const { AudioManager, players } = setup(); + const key = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + AudioManager.addAudioRendered(key); + + await AudioManager.onEnd(key, { isLoaded: true, didJustFinish: true, currentTime: 5, duration: 5 } as AudioStatus); + + expect(players[0].release).toHaveBeenCalled(); + }); + + it('setPositionAsync seeks the player', async () => { + const { AudioManager, players } = setup(); + const key = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + + await AudioManager.setPositionAsync(key, 10); + + expect(players[0].seekTo).toHaveBeenCalledWith(10); + }); + + // On iOS setPlaybackRate maps to AVPlayer.rate, and a non-zero rate starts playback + it('setRate does not touch an idle player, so loading never starts playback', async () => { + const { AudioManager, players } = setup(); + const key = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + + AudioManager.setRate(key, 1); + + expect(players[0].setPlaybackRate).not.toHaveBeenCalled(); + expect(players[0].play).not.toHaveBeenCalled(); + }); + + it('playAudio applies the rate stored while the player was idle', async () => { + const { AudioManager, players } = setup(); + const key = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + AudioManager.setRate(key, 1.5); + + await AudioManager.playAudio(key); + + expect(players[0].setPlaybackRate).toHaveBeenCalledWith(1.5, 'medium'); + expect(players[0].setPlaybackRate).toHaveLastReturnedWith(true); + expect(players[0].play).toHaveBeenCalled(); + }); + + it('setRate applies immediately to the audio currently playing', async () => { + const { AudioManager, players } = setup(); + const key = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + await AudioManager.playAudio(key); + players[0].setPlaybackRate.mockClear(); + + AudioManager.setRate(key, 2); + + expect(players[0].setPlaybackRate).toHaveBeenCalledWith(2, 'medium'); + expect(players[0].setPlaybackRate).toHaveLastReturnedWith(true); + }); + + it('setRate leaves other loaded audios untouched while one is playing', async () => { + const { AudioManager, players } = setup(); + const playingKey = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + const idleKey = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://b.mp3' }); + await AudioManager.playAudio(playingKey); + + AudioManager.setRate(idleKey, 2); + + expect(players[1].setPlaybackRate).not.toHaveBeenCalled(); + expect(players[1].play).not.toHaveBeenCalled(); + }); + + it('a recreated player seeks to the saved position before the rate is applied', async () => { + const { AudioManager, players } = setup(); + const key = await AudioManager.loadAudio({ rid: 'room-1', uri: 'file://a.mp3' }); + AudioManager.setRate(key, 1.5); + await AudioManager.setPositionAsync(key, 7); + await AudioManager.onEnd(key, { isLoaded: true, didJustFinish: true, currentTime: 5, duration: 5 } as AudioStatus); + + await AudioManager.playAudio(key); + + const recreated = players[1]; + const [seekOrder] = recreated.seekTo.mock.invocationCallOrder; + const [rateOrder] = recreated.setPlaybackRate.mock.invocationCallOrder; + const [playOrder] = recreated.play.mock.invocationCallOrder; + expect(recreated.setPlaybackRate).toHaveBeenCalledWith(1.5, 'medium'); + expect(recreated.setPlaybackRate).toHaveLastReturnedWith(true); + expect(seekOrder).toBeLessThan(rateOrder); + expect(rateOrder).toBeLessThan(playOrder); + }); + + it('recreates the next completed audio when replaying a sequence', async () => { + const { AudioManager, players } = setup(); + const firstKey = AudioManager.loadAudio({ msgId: 'a', rid: 'room', uri: 'file://x' }); + const nextKey = AudioManager.loadAudio({ msgId: 'b', rid: 'room', uri: 'file://x' }); + const nextMessage = { id: 'b', attachments: [{ audio_url: '/x', audio_type: 'audio/mp3' }] } as TMessageModel; + jest + .spyOn(AudioManager, 'getNextAudioMessage') + .mockImplementation(msgId => Promise.resolve(msgId === 'a' ? nextMessage : null)); + const finished = { isLoaded: true, didJustFinish: true } as AudioStatus; + + await AudioManager.playAudio(firstKey); + await AudioManager.onEnd(firstKey, finished); + expect(players[1].play).toHaveBeenCalledTimes(1); + await AudioManager.onEnd(nextKey, finished); + + await AudioManager.playAudio(firstKey); + await AudioManager.onEnd(firstKey, finished); + + expect(players).toHaveLength(4); + expect(players[3].play).toHaveBeenCalledTimes(1); + + await AudioManager.onEnd(nextKey, finished); + AudioManager.removeAudioRendered(nextKey); + await AudioManager.playAudio(firstKey); + await AudioManager.onEnd(firstKey, finished); + expect(players).toHaveLength(5); + }); + + it('room cleanup removes completed audio state and releases live players only in that room', async () => { + const { AudioManager, players } = setup(); + const completedKey = AudioManager.loadAudio({ msgId: 'a', rid: 'room', uri: 'file://x' }); + const liveKey = AudioManager.loadAudio({ msgId: 'b', rid: 'room', uri: 'file://x' }); + const otherKey = AudioManager.loadAudio({ msgId: 'c', rid: 'other', uri: 'file://x' }); + const callback = jest.fn(); + AudioManager.setOnPlaybackStatusUpdate(completedKey, callback); + AudioManager.setRate(completedKey, 2); + await AudioManager.onEnd(completedKey, { isLoaded: true, didJustFinish: true } as AudioStatus); + + await AudioManager.unloadRoomAudios('room'); + + expect(players[0].release).toHaveBeenCalledTimes(1); + expect(players[1].release).toHaveBeenCalledTimes(1); + expect(players[2].release).not.toHaveBeenCalled(); + for (const field of [ + 'audioQueue', + 'audioUris', + 'audioPositions', + 'audioRates', + 'audioSubscriptions', + 'audioCallbacks', + 'audioMeta' + ] as const) { + expect(AudioManager[field]).not.toHaveProperty([completedKey]); + expect(AudioManager[field]).not.toHaveProperty([liveKey]); + } + expect(AudioManager['audioUris'][otherKey]).toBe('file://x'); + expect(AudioManager['audioMeta'][otherKey]).toEqual({ msgId: 'c', rid: 'other' }); + jest.spyOn(AudioManager, 'getNextAudioMessage').mockResolvedValue({ id: 'a', attachments: [{}] } as TMessageModel); + const playAudio = jest.spyOn(AudioManager, 'playAudio'); + await AudioManager.playNextAudioInSequence(liveKey); + expect(playAudio).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/AudioManager.ts b/app/lib/methods/AudioManager.ts index fbd2a388147..9972160441c 100644 --- a/app/lib/methods/AudioManager.ts +++ b/app/lib/methods/AudioManager.ts @@ -1,4 +1,4 @@ -import { type AVPlaybackStatus, Audio } from 'expo-av'; +import { createAudioPlayer, setAudioModeAsync, type AudioPlayer, type AudioStatus } from 'expo-audio'; import { Q } from '@nozbe/watermelondb'; import dayjs from '../dayjs'; @@ -6,18 +6,31 @@ import { getMessageById } from '../database/services/Message'; import database from '../database'; import { getFilePathAudio } from './getFilePathAudio'; import { type TMessageModel } from '~/definitions'; -import { AUDIO_MODE } from '../constants/audio'; import { emitter } from './helpers'; +import log from './helpers/log'; +import { AUDIO_MODE } from '../constants/audio'; const getAudioKey = ({ msgId, rid, uri }: { msgId?: string; rid: string; uri: string }) => `${msgId}-${rid}-${uri}`; class AudioManagerClass { - private audioQueue: { [audioKey: string]: Audio.Sound }; + private audioQueue: { [audioKey: string]: AudioPlayer }; + private audioUris: { [audioKey: string]: string }; + private audioPositions: { [audioKey: string]: number }; + private audioRates: { [audioKey: string]: number }; + private audioSubscriptions: { [audioKey: string]: () => void }; + private audioCallbacks: { [audioKey: string]: (status: AudioStatus) => void }; + private audioMeta: { [audioKey: string]: { msgId?: string; rid: string } }; private audioPlaying: string; private audiosRendered: Set; constructor() { this.audioQueue = {}; + this.audioUris = {}; + this.audioPositions = {}; + this.audioRates = {}; + this.audioSubscriptions = {}; + this.audioCallbacks = {}; + this.audioMeta = {}; this.audioPlaying = ''; this.audiosRendered = new Set(); } @@ -30,72 +43,119 @@ class AudioManagerClass { this.audiosRendered.delete(audioKey); }; - async loadAudio({ msgId, rid, uri }: { rid: string; msgId?: string; uri: string }): Promise { + loadAudio({ msgId, rid, uri }: { rid: string; msgId?: string; uri: string }): string { const audioKey = getAudioKey({ msgId, rid, uri }); this.addAudioRendered(audioKey); + this.audioUris[audioKey] = uri; + this.audioMeta[audioKey] = { msgId, rid }; if (this.audioQueue[audioKey]) { return audioKey; } - const { sound } = await Audio.Sound.createAsync({ uri }, { androidImplementation: 'MediaPlayer' }); + + const sound = createAudioPlayer({ uri }); this.audioQueue[audioKey] = sound; return audioKey; } async playAudio(audioKey: string) { if (this.audioPlaying) { - await this.pauseAudio(); + this.pauseAudio(); + } + + // If player was released, recreate it + if (!this.audioQueue[audioKey] && this.audioUris[audioKey]) { + const sound = createAudioPlayer({ uri: this.audioUris[audioKey] }); + this.audioQueue[audioKey] = sound; + + if (this.audioPositions[audioKey] !== undefined) { + await sound.seekTo(this.audioPositions[audioKey]); + } + + // Re-register the callback if it exists + if (this.audioCallbacks[audioKey]) { + const sub = sound.addListener('playbackStatusUpdate', status => { + this.onPlaybackStatusUpdate(audioKey, status, this.audioCallbacks[audioKey]); + }); + if (sub) this.audioSubscriptions[audioKey] = () => sub.remove?.(); + } + } + + try { + await setAudioModeAsync(AUDIO_MODE); + this.audioPlaying = audioKey; + this.setRate(audioKey, this.audioRates[audioKey]); + this.audioQueue[audioKey]?.play(); + emitter.emit('audioFocused', audioKey); + } catch (error) { + log(error); } - await Audio.setAudioModeAsync(AUDIO_MODE); - await this.audioQueue[audioKey]?.playAsync(); - this.audioPlaying = audioKey; - emitter.emit('audioFocused', audioKey); } - async pauseAudio() { + pauseAudio(): void { if (this.audioPlaying) { - await this.audioQueue[this.audioPlaying]?.pauseAsync(); + this.audioQueue[this.audioPlaying]?.pause(); this.audioPlaying = ''; } } async setPositionAsync(audioKey: string, time: number) { + this.audioPositions[audioKey] = time; try { - await this.audioQueue[audioKey]?.setPositionAsync(time); - } catch { - // Do nothing + await this.audioQueue[audioKey]?.seekTo(time); + } catch (error) { + log(error); } } - async setRateAsync(audioKey: string, value = 1.0) { + setRate(audioKey: string, value = 1.0): void { + this.audioRates[audioKey] = value; + if (!audioKey || this.audioPlaying !== audioKey) { + return; + } + try { - await this.audioQueue[audioKey].setRateAsync(value, true); - } catch { - // Do nothing + const player = this.audioQueue[audioKey]; + if (!player) return; + player.shouldCorrectPitch = true; + player.setPlaybackRate(value, 'medium'); + } catch (error) { + log(error); } } - onPlaybackStatusUpdate(audioKey: string, status: AVPlaybackStatus, callback: (status: AVPlaybackStatus) => void) { + onPlaybackStatusUpdate(audioKey: string, status: AudioStatus, callback: (status: AudioStatus) => void): void { if (status) { callback(status); this.onEnd(audioKey, status); } } - setOnPlaybackStatusUpdate(audioKey: string, callback: (status: AVPlaybackStatus) => void): void { - return this.audioQueue[audioKey]?.setOnPlaybackStatusUpdate(status => - this.onPlaybackStatusUpdate(audioKey, status, callback) - ); + setOnPlaybackStatusUpdate(audioKey: string, callback: (status: AudioStatus) => void): void { + this.audioCallbacks[audioKey] = callback; + this.audioSubscriptions[audioKey]?.(); + const sub = this.audioQueue[audioKey]?.addListener('playbackStatusUpdate', status => { + this.onPlaybackStatusUpdate(audioKey, status, callback); + }); + if (sub) this.audioSubscriptions[audioKey] = () => sub.remove?.(); } - async onEnd(audioKey: string, status: AVPlaybackStatus) { + async onEnd(audioKey: string, status: AudioStatus): Promise { + if (!this.audioQueue[audioKey]) { + return; + } + if (status.isLoaded && status.didJustFinish) { try { - await this.audioQueue[audioKey]?.stopAsync(); + this.audioSubscriptions[audioKey]?.(); + delete this.audioSubscriptions[audioKey]; + this.audioQueue[audioKey].release(); + delete this.audioQueue[audioKey]; + this.audioPositions[audioKey] = 0; this.audioPlaying = ''; emitter.emit('audioFocused', ''); await this.playNextAudioInSequence(audioKey); - } catch { - // do nothing + } catch (error) { + log(error); } } } @@ -127,7 +187,6 @@ class AudioManagerClass { } else { whereClause.push(Q.where('rid', rid), Q.where('tmid', null)); } - const [message] = await db .get('messages') .query(...whereClause) @@ -143,7 +202,7 @@ class AudioManagerClass { const nextMessage = await this.getNextAudioMessage(msgId, rid); if (nextMessage && nextMessage.attachments) { const nextAudioInSeqKey = this.getNextAudioKey({ message: nextMessage, rid }); - if (nextAudioInSeqKey && this.audioQueue?.[nextAudioInSeqKey] && this.audiosRendered.has(nextAudioInSeqKey)) { + if (nextAudioInSeqKey && this.audioUris[nextAudioInSeqKey] && this.audiosRendered.has(nextAudioInSeqKey)) { await this.playAudio(nextAudioInSeqKey); } } @@ -153,20 +212,23 @@ class AudioManagerClass { if (!rid) { return; } - const regExp = new RegExp(rid); - const roomAudioKeysLoaded = Object.keys(this.audioQueue).filter(audioKey => regExp.test(audioKey)); + const roomAudioKeysLoaded = Object.keys(this.audioMeta).filter(audioKey => this.audioMeta[audioKey].rid === rid); const roomAudiosLoaded = roomAudioKeysLoaded.map(key => this.audioQueue[key]); try { - await Promise.all( - roomAudiosLoaded.map(async audio => { - await audio?.stopAsync(); - await audio?.unloadAsync(); - }) - ); - } catch { - // Do nothing - } - roomAudioKeysLoaded.forEach(key => delete this.audioQueue[key]); + await Promise.all(roomAudiosLoaded.map(audio => audio?.release())); + } catch (error) { + log(error); + } + roomAudioKeysLoaded.forEach(key => { + this.audioSubscriptions[key]?.(); + delete this.audioSubscriptions[key]; + delete this.audioCallbacks[key]; + delete this.audioQueue[key]; + delete this.audioUris[key]; + delete this.audioPositions[key]; + delete this.audioRates[key]; + delete this.audioMeta[key]; + }); this.audioPlaying = ''; } } diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index 4aa6636b53d..e79e64861df 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -74,7 +74,7 @@ export interface Spec extends TurboModule { /** * Plays the outgoing-call ringback (dialtone) on the voice-communication audio path so it * follows the active comm device (earpiece/speaker/BT) instead of the music stream. - * Idempotent: a second call while playing is a no-op. iOS no-op (CallKit + AVAudioSession handle routing for expo-av). + * Idempotent: a second call while playing is a no-op. iOS no-op (CallKit + AVAudioSession handle routing for expo-audio). */ startRingback(): Promise; diff --git a/app/lib/services/voip/playCallEndedSound.test.ts b/app/lib/services/voip/playCallEndedSound.test.ts index 577fefa1697..cbaae6f71cc 100644 --- a/app/lib/services/voip/playCallEndedSound.test.ts +++ b/app/lib/services/voip/playCallEndedSound.test.ts @@ -6,156 +6,140 @@ jest.mock('~/lib/methods/helpers/log', () => ({ default: (...args: unknown[]) => mockLog(...args) })); -// Mock expo-av at the test boundary, matching the style used in the VoIP services directory. -const mockLoadAsync = jest.fn(() => Promise.resolve()); -const mockPlayAsync = jest.fn(() => Promise.resolve()); -const mockUnloadAsync = jest.fn(() => Promise.resolve()); -type MockPlaybackStatus = { isLoaded: boolean; didJustFinish?: boolean }; -let capturedPlaybackStatusUpdate: ((status: MockPlaybackStatus) => void) | null = null; - -const mockSetOnPlaybackStatusUpdate = jest.fn((cb: (status: MockPlaybackStatus) => void) => { - capturedPlaybackStatusUpdate = cb; -}); +// Access the global mock from jest.setup.js - it provides a consistent mock player +// eslint-disable-next-line @typescript-eslint/no-var-requires +const expoAudio = require('expo-audio'); -const mockSoundInstance = { - loadAsync: mockLoadAsync, - playAsync: mockPlayAsync, - unloadAsync: mockUnloadAsync, - setOnPlaybackStatusUpdate: mockSetOnPlaybackStatusUpdate -}; - -jest.mock('expo-av', () => ({ - Audio: { - Sound: jest.fn(() => mockSoundInstance) - } -})); +const mockCreateAudioPlayer = expoAudio.createAudioPlayer as jest.Mock; + +const getActualPlayer = () => mockCreateAudioPlayer.mock.results[0].value; beforeEach(() => { jest.clearAllMocks(); - capturedPlaybackStatusUpdate = null; resetPlayCallEndedSoundForTesting(); mockLog.mockClear(); + mockCreateAudioPlayer.mockClear(); }); describe('playCallEndedSound', () => { - it('triggers playback when invoked', async () => { - await playCallEndedSound(); + it('creates a player and starts playback when invoked', () => { + playCallEndedSound(); - expect(mockLoadAsync).toHaveBeenCalledTimes(1); - expect(mockPlayAsync).toHaveBeenCalledTimes(1); + expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1); + expect(getActualPlayer().play).toHaveBeenCalledTimes(1); }); - it('registers a playback status callback', async () => { - await playCallEndedSound(); + it('registers a playback status listener', () => { + playCallEndedSound(); - expect(mockSetOnPlaybackStatusUpdate).toHaveBeenCalledTimes(1); + expect(getActualPlayer().addListener).toHaveBeenCalledWith('playbackStatusUpdate', expect.any(Function)); }); - it('unloads the player when didJustFinish fires', async () => { - await playCallEndedSound(); + it('removes the player when didJustFinish fires', () => { + playCallEndedSound(); + + const actualPlayer = getActualPlayer(); + const statusCallback = actualPlayer.addListener.mock.calls.find( + ([event]: [string, unknown]) => event === 'playbackStatusUpdate' + )?.[1]; + expect(statusCallback).toBeDefined(); - expect(capturedPlaybackStatusUpdate).not.toBeNull(); - capturedPlaybackStatusUpdate!({ isLoaded: true, didJustFinish: true }); + statusCallback({ isLoaded: true, didJustFinish: true }); - expect(mockUnloadAsync).toHaveBeenCalledTimes(1); + expect(actualPlayer.release).toHaveBeenCalledTimes(1); }); - it('does not unload when didJustFinish is false', async () => { - await playCallEndedSound(); + it('does not remove when didJustFinish is false', () => { + playCallEndedSound(); - capturedPlaybackStatusUpdate!({ isLoaded: true, didJustFinish: false }); + const actualPlayer = getActualPlayer(); + const addListenerCalls = actualPlayer.addListener.mock.calls; + const statusCallback = addListenerCalls.find(([event]: [string, unknown]) => event === 'playbackStatusUpdate')?.[1]; + statusCallback?.({ isLoaded: true, didJustFinish: false }); - expect(mockUnloadAsync).not.toHaveBeenCalled(); + expect(actualPlayer.release).not.toHaveBeenCalled(); }); - it('double-invocation while first is in flight does not produce overlapping playback', async () => { - // First invocation — loadAsync resolves immediately in mock but we can test - // the coalescing: a second call while the first play is underway must not call - // loadAsync/playAsync a second time. - let resolveLoad!: () => void; - mockLoadAsync.mockImplementationOnce( - () => - new Promise(res => { - resolveLoad = res; - }) - ); - - // Start first invocation (hangs at loadAsync) - const first = playCallEndedSound(); + it('double-invocation while first is in flight does not produce overlapping playback', () => { + // Start first invocation + playCallEndedSound(); // Second invocation while first is still loading — must be a no-op - const second = playCallEndedSound(); + playCallEndedSound(); - // Resolve the first load - resolveLoad(); - await first; - await second; - - // loadAsync and playAsync must each have been called exactly once - expect(mockLoadAsync).toHaveBeenCalledTimes(1); - expect(mockPlayAsync).toHaveBeenCalledTimes(1); + // createAudioPlayer and play must each have been called exactly once (coalescing) + expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1); + expect(getActualPlayer().play).toHaveBeenCalledTimes(1); }); - it('allows a new invocation after the previous cue completes', async () => { - await playCallEndedSound(); + it('allows a new invocation after the previous cue completes', () => { + playCallEndedSound(); + // Simulate completion - capturedPlaybackStatusUpdate!({ isLoaded: true, didJustFinish: true }); + const actualPlayer = getActualPlayer(); + const addListenerCalls = actualPlayer.addListener.mock.calls; + const statusCallback = addListenerCalls.find(([event]: [string, unknown]) => event === 'playbackStatusUpdate')?.[1]; + statusCallback?.({ isLoaded: true, didJustFinish: true }); // Reset mocks to count fresh calls - mockLoadAsync.mockClear(); - mockPlayAsync.mockClear(); - mockSetOnPlaybackStatusUpdate.mockClear(); - capturedPlaybackStatusUpdate = null; + mockCreateAudioPlayer.mockClear(); - await playCallEndedSound(); + playCallEndedSound(); - expect(mockLoadAsync).toHaveBeenCalledTimes(1); - expect(mockPlayAsync).toHaveBeenCalledTimes(1); + expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1); + expect(getActualPlayer().play).toHaveBeenCalledTimes(1); }); - it('watchdog releases the lock if didJustFinish never fires', async () => { + it('watchdog releases the lock if didJustFinish never fires', () => { jest.useFakeTimers(); try { - await playCallEndedSound(); + playCallEndedSound(); + + const actualPlayer = getActualPlayer(); // didJustFinish never fires; lock would be permanent without the watchdog. - expect(mockUnloadAsync).not.toHaveBeenCalled(); + expect(actualPlayer.release).not.toHaveBeenCalled(); jest.advanceTimersByTime(5000); - // Watchdog forces unload + lock release. - expect(mockUnloadAsync).toHaveBeenCalledTimes(1); + // Watchdog forces remove + lock release. + expect(actualPlayer.release).toHaveBeenCalledTimes(1); // Subsequent invocation is allowed (lock cleared). - mockLoadAsync.mockClear(); - mockPlayAsync.mockClear(); - await playCallEndedSound(); - expect(mockLoadAsync).toHaveBeenCalledTimes(1); - expect(mockPlayAsync).toHaveBeenCalledTimes(1); + mockCreateAudioPlayer.mockClear(); + playCallEndedSound(); + expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(1); + expect(getActualPlayer().play).toHaveBeenCalledTimes(1); } finally { jest.useRealTimers(); } }); - it('didJustFinish clears the watchdog so it does not double-unload', async () => { + it('didJustFinish clears the watchdog so it does not double-remove', () => { jest.useFakeTimers(); try { - await playCallEndedSound(); - capturedPlaybackStatusUpdate!({ isLoaded: true, didJustFinish: true }); - expect(mockUnloadAsync).toHaveBeenCalledTimes(1); + playCallEndedSound(); + + const actualPlayer = getActualPlayer(); + const addListenerCalls = actualPlayer.addListener.mock.calls; + const statusCallback = addListenerCalls.find(([event]: [string, unknown]) => event === 'playbackStatusUpdate')?.[1]; + statusCallback?.({ isLoaded: true, didJustFinish: true }); + expect(actualPlayer.release).toHaveBeenCalledTimes(1); // Watchdog must not fire after natural completion. jest.advanceTimersByTime(10000); - expect(mockUnloadAsync).toHaveBeenCalledTimes(1); + expect(actualPlayer.release).toHaveBeenCalledTimes(1); } finally { jest.useRealTimers(); } }); - it('calls log when loadAsync throws', async () => { + it('calls log when createAudioPlayer throws', () => { const err = new Error('E_LOAD_FAILED'); - mockLoadAsync.mockRejectedValueOnce(err); + mockCreateAudioPlayer.mockImplementationOnce(() => { + throw err; + }); - await playCallEndedSound(); + playCallEndedSound(); expect(mockLog).toHaveBeenCalledWith(err); }); diff --git a/app/lib/services/voip/playCallEndedSound.ts b/app/lib/services/voip/playCallEndedSound.ts index a7625e8df6d..c1d6276ca7d 100644 --- a/app/lib/services/voip/playCallEndedSound.ts +++ b/app/lib/services/voip/playCallEndedSound.ts @@ -1,10 +1,11 @@ -import { Audio } from 'expo-av'; +import { createAudioPlayer } from 'expo-audio'; import log from '~/lib/methods/helpers/log'; // Module-scoped state so it survives React tree unmounts and is safe to call // fire-and-forget from any termination path. let isPlaying = false; +let currentPlayer: ReturnType | null = null; let watchdogTimer: ReturnType | null = null; // Cue is ~1.5s; 5s is generous slack before we assume the player is wedged. @@ -12,6 +13,7 @@ const WATCHDOG_MS = 5000; function releaseLock(): void { isPlaying = false; + currentPlayer = null; if (watchdogTimer != null) { clearTimeout(watchdogTimer); watchdogTimer = null; @@ -28,7 +30,7 @@ function releaseLock(): void { * the player or the audio session is interrupted indefinitely), so future * cues aren't permanently blocked. */ -export async function playCallEndedSound(): Promise { +export function playCallEndedSound(): void { if (isPlaying) { return; } @@ -36,26 +38,22 @@ export async function playCallEndedSound(): Promise { isPlaying = true; try { - const sound = new Audio.Sound(); + currentPlayer = createAudioPlayer(require('~/containers/Ringer/call-ended.mp3')); - sound.setOnPlaybackStatusUpdate(status => { + currentPlayer.addListener('playbackStatusUpdate', status => { if (status.isLoaded && status.didJustFinish) { + const player = currentPlayer; releaseLock(); - sound.unloadAsync().catch(() => { - // best-effort unload - }); + player?.release(); } }); - // eslint-disable-next-line @typescript-eslint/no-require-imports - await sound.loadAsync(require('~/containers/Ringer/call-ended.mp3')); - await sound.playAsync(); + currentPlayer.play(); watchdogTimer = setTimeout(() => { + const player = currentPlayer; releaseLock(); - sound.unloadAsync().catch(() => { - // best-effort unload - }); + player?.release(); }, WATCHDOG_MS); } catch (error) { // Never throw — this is fire-and-forget diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 34efe2abff5..96dc0530556 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -88,7 +88,7 @@ jest.mock('i18n-js', () => ({ default: { t: (k: string) => k } })); -jest.mock('../../lib/methods/helpers/info', () => ({ +jest.mock('~/lib/methods/helpers/info', () => ({ showConfirmationAlert: jest.fn(({ onPress }: { onPress: () => void }) => onPress()) })); diff --git a/app/views/AttachmentView.test.tsx b/app/views/AttachmentView.test.tsx index 701b0cb9375..d44fed8c0d1 100644 --- a/app/views/AttachmentView.test.tsx +++ b/app/views/AttachmentView.test.tsx @@ -18,11 +18,6 @@ jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) })); -jest.mock('expo-av', () => ({ - ResizeMode: { CONTAIN: 'contain' }, - Video: () => null -})); - jest.mock('expo-file-system/legacy', () => ({ deleteAsync: jest.fn() })); diff --git a/app/views/AttachmentView.tsx b/app/views/AttachmentView.tsx index 52e3368c460..5c821df148e 100644 --- a/app/views/AttachmentView.tsx +++ b/app/views/AttachmentView.tsx @@ -1,7 +1,6 @@ import { CameraRoll } from '@react-native-camera-roll/camera-roll'; import { useHeaderHeight } from '@react-navigation/elements'; -import { ResizeMode, Video } from 'expo-av'; -import { useCallback, useLayoutEffect, useRef, useState, type Dispatch, type SetStateAction, type ReactElement } from 'react'; +import { useCallback, useLayoutEffect, useState, type Dispatch, type SetStateAction, type ReactElement } from 'react'; import { PermissionsAndroid, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { shallowEqual } from 'react-redux'; @@ -12,13 +11,14 @@ import RCActivityIndicator from '../containers/ActivityIndicator'; import AltTextLabel from '../containers/AltTextLabel'; import * as HeaderButton from '../containers/Header/components/HeaderButton'; import { ImageViewer } from '../containers/ImageViewer'; +import VideoPlayer from '../containers/VideoPlayer'; import { LISTENER } from '../containers/Toast'; import { type IAttachment } from '../definitions'; import I18n from '../i18n'; import { useAltTextSupported } from '../lib/hooks/useAltTextSupported'; import { useAppSelector } from '../lib/hooks/useAppSelector'; import { useAppNavigation, useAppRoute } from '../lib/hooks/navigation'; -import { encodeAttachmentUrl, formatAttachmentUrl, isAndroid, showErrorAlert } from '../lib/methods/helpers'; +import { encodeAttachmentUrl, formatAttachmentUrl, isAndroid } from '../lib/methods/helpers'; import { fileDownload } from '../lib/methods/helpers/fileDownload'; import EventEmitter from '../lib/methods/helpers/events'; import { getUserSelector } from '../selectors/login'; @@ -35,11 +35,9 @@ const RenderContent = ({ attachment: IAttachment; altText?: string; }) => { - const videoRef = useRef ); } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8d92458e05e..4b134051245 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -33,9 +33,6 @@ PODS: - DoubleConversion (1.1.6) - EXApplication (7.0.8): - ExpoModulesCore - - EXAV (16.0.8): - - ExpoModulesCore - - ReactCommon/turbomodule/core - EXConstants (18.0.12): - ExpoModulesCore - EXNotifications (0.32.15): @@ -75,6 +72,8 @@ PODS: - ExpoModulesCore - ExpoAsset (12.0.12): - ExpoModulesCore + - ExpoAudio (0.4.9): + - ExpoModulesCore - ExpoCamera (17.0.10): - ExpoModulesCore - ZXingObjC/OneD @@ -133,6 +132,8 @@ PODS: - ExpoModulesCore - ExpoSystemUI (6.0.9): - ExpoModulesCore + - ExpoVideo (3.0.16): + - ExpoModulesCore - ExpoVideoThumbnails (10.0.8): - ExpoModulesCore - ExpoWebBrowser (15.0.10): @@ -3598,12 +3599,12 @@ DEPENDENCIES: - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EXApplication (from `../node_modules/expo-application/ios`) - - EXAV (from `../node_modules/expo-av/ios`) - EXConstants (from `../node_modules/expo-constants/ios`) - EXNotifications (from `../node_modules/expo-notifications/ios`) - Expo (from `../node_modules/expo`) - ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`) - ExpoAsset (from `../node_modules/expo-asset/ios`) + - ExpoAudio (from `../node_modules/expo-audio/ios`) - ExpoCamera (from `../node_modules/expo-camera/ios`) - ExpoDevice (from `../node_modules/expo-device/ios`) - ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`) @@ -3616,6 +3617,7 @@ DEPENDENCIES: - ExpoModulesCore (from `../node_modules/expo-modules-core`) - "ExpoQuickLook (from `../node_modules/@magrinj/expo-quick-look/ios`)" - ExpoSystemUI (from `../node_modules/expo-system-ui/ios`) + - ExpoVideo (from `../node_modules/expo-video/ios`) - ExpoVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`) - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`) - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) @@ -3770,8 +3772,6 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" EXApplication: :path: "../node_modules/expo-application/ios" - EXAV: - :path: "../node_modules/expo-av/ios" EXConstants: :path: "../node_modules/expo-constants/ios" EXNotifications: @@ -3782,6 +3782,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-apple-authentication/ios" ExpoAsset: :path: "../node_modules/expo-asset/ios" + ExpoAudio: + :path: "../node_modules/expo-audio/ios" ExpoCamera: :path: "../node_modules/expo-camera/ios" ExpoDevice: @@ -3806,6 +3808,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@magrinj/expo-quick-look/ios" ExpoSystemUI: :path: "../node_modules/expo-system-ui/ios" + ExpoVideo: + :path: "../node_modules/expo-video/ios" ExpoVideoThumbnails: :path: "../node_modules/expo-video-thumbnails/ios" ExpoWebBrowser: @@ -4028,12 +4032,12 @@ SPEC CHECKSUMS: BVLinearGradient: 7815a70ab485b7b155186dd0cc836363e0288cad DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f - EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea EXConstants: 805f35b1b295c542ca6acce836f21a1f9ee104d5 EXNotifications: 983f04ad4ad879b181179e326bf220541e478386 Expo: 6118272de314ed3d4bc74de3b1229b8f5b324ef1 ExpoAppleAuthentication: 9413ae9a5e631a424cc04c1b13b0694dbab7d594 ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18 + ExpoAudio: d98f922b17a945cd33f8462d086278dbd810c537 ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0 @@ -4046,6 +4050,7 @@ SPEC CHECKSUMS: ExpoModulesCore: 91a57f1d109cf53fe58d44fcf6b68a777561549a ExpoQuickLook: 7ac65cd559a74619ea41e13df20e9d7a61e79ea6 ExpoSystemUI: 2ad325f361a2fcd96a464e8574e19935c461c9cc + ExpoVideo: 8d9e8a1acd0c4c210e538b4355e34ae8ff5e04f7 ExpoVideoThumbnails: 503a79271416c8723f04b55ea4737282513ebe4f ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52 fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 diff --git a/jest.setup.js b/jest.setup.js index b92b8cd0ed6..84d08c3bede 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -96,121 +96,54 @@ jest.mock('expo-font', () => ({ __esModule: true })); -jest.mock('expo-av', () => { - const InterruptionModeAndroid = { - DoNotMix: 1, - DuckOthers: 2 - }; - const InterruptionModeIOS = { - DoNotMix: 1, - DuckOthers: 2, - MixWithOthers: 3 +jest.mock('expo-audio', () => { + const recorderState = { + canRecord: true, + isRecording: false, + durationMillis: 0, + mediaServicesDidReset: false, + url: null }; return { - Audio: { - getPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted', granted: true, canAskAgain: true })), - requestPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted', granted: true, canAskAgain: true })), - setAudioModeAsync: jest.fn(() => Promise.resolve()), - Recording: jest.fn(() => ({ - prepareToRecordAsync: jest.fn(() => Promise.resolve()), - startAsync: jest.fn(() => Promise.resolve()), - stopAndUnloadAsync: jest.fn(() => Promise.resolve()), - setOnRecordingStatusUpdate: jest.fn(), - getStatusAsync: jest.fn(() => Promise.resolve()) - })), - Sound: { - createAsync: jest.fn(() => - Promise.resolve({ - sound: { - setOnPlaybackStatusUpdate: jest.fn(), - playAsync: jest.fn(() => Promise.resolve()), - pauseAsync: jest.fn(() => Promise.resolve()), - stopAsync: jest.fn(() => Promise.resolve()), - unloadAsync: jest.fn(() => Promise.resolve()), - getStatusAsync: jest.fn(() => Promise.resolve()), - setPositionAsync: jest.fn(() => Promise.resolve()) - }, - status: {} - }) - ), - create: jest.fn(() => ({ - setOnPlaybackStatusUpdate: jest.fn(), - playAsync: jest.fn(() => Promise.resolve()), - pauseAsync: jest.fn(() => Promise.resolve()), - stopAsync: jest.fn(() => Promise.resolve()), - unloadAsync: jest.fn(() => Promise.resolve()), - getStatusAsync: jest.fn(() => Promise.resolve()), - setPositionAsync: jest.fn(() => Promise.resolve()), - loadAsync: jest.fn(() => Promise.resolve()) - })) - }, - RecordingStatus: { - StatusDict: {} - }, - AudioStatus: { - StatusDict: {} - }, - AndroidOutputFormat: { - AAC_ADTS: 0 - }, - AndroidAudioEncoder: { - AAC: 0 - }, - IOSAudioQuality: { - LOW: 0, - MEDIUM: 1, - HIGH: 2 - }, - IOSOutputFormat: { - MPEG4AAC: 0 - }, - RecordingOptionsPresets: { - LOW_QUALITY: { - android: { - extension: '.aac', - outputFormat: 0, - audioEncoder: 0, - sampleRate: 16000, - numberOfChannels: 1, - bitRate: 64000 - }, - ios: { - extension: '.aac', - audioQuality: 1, - outputFormat: 0, - sampleRate: 16000, - numberOfChannels: 1, - bitRate: 64000 - }, - web: {} - }, - HIGH_QUALITY: { - android: { - extension: '.aac', - outputFormat: 0, - audioEncoder: 0, - sampleRate: 48000, - numberOfChannels: 2, - bitRate: 128000 - }, - ios: { - extension: '.aac', - audioQuality: 1, - outputFormat: 0, - sampleRate: 48000, - numberOfChannels: 2, - bitRate: 128000 - }, - web: {} - } - } - }, - InterruptionModeAndroid, - InterruptionModeIOS + PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' }, + AudioQuality: { MEDIUM: 0x40 }, + IOSOutputFormat: { MPEG4AAC: 'aac ' }, + createAudioPlayer: jest.fn(() => ({ + play: jest.fn(), + pause: jest.fn(), + setPlaybackRate: jest.fn(), + seekTo: jest.fn(() => Promise.resolve()), + release: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + playing: false, + loop: false, + isLoaded: false, + shouldCorrectPitch: false + })), + setAudioModeAsync: jest.fn(() => Promise.resolve()), + requestRecordingPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted', granted: true, canAskAgain: true })), + getRecordingPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted', granted: true, canAskAgain: true })), + useAudioRecorder: jest.fn(() => ({ + uri: null, + stop: jest.fn(() => Promise.resolve()), + record: jest.fn(), + prepareToRecordAsync: jest.fn(() => Promise.resolve()), + getStatus: jest.fn(() => ({ ...recorderState })) + })), + useAudioRecorderState: jest.fn(() => ({ ...recorderState })) }; }); +jest.mock('expo-video', () => ({ + useVideoPlayer: jest.fn(() => ({ + play: jest.fn(), + pause: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })) + })), + VideoView: jest.fn(() => null) +})); + jest.mock('./app/lib/methods/search', () => ({ search: () => [] })); diff --git a/package.json b/package.json index 4987b072153..84369eb3150 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "expo": "^54.0.0", "expo-apple-authentication": "~8.0.8", - "expo-av": "~16.0.8", + "expo-audio": "~0.4.9", "expo-camera": "~17.0.10", "expo-device": "^8.0.10", "expo-document-picker": "~14.0.8", @@ -77,6 +77,7 @@ "expo-notifications": "~0.32.15", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", + "expo-video": "~3.0.16", "expo-video-thumbnails": "~10.0.8", "expo-web-browser": "~15.0.10", "hoist-non-react-statics": "3.3.2", diff --git a/patches/expo-audio+0.4.9.patch b/patches/expo-audio+0.4.9.patch new file mode 100644 index 00000000000..7b6f7a2961e --- /dev/null +++ b/patches/expo-audio+0.4.9.patch @@ -0,0 +1,24 @@ +diff --git a/node_modules/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt b/node_modules/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt +index c9fd366..5c74acf 100644 +--- a/node_modules/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt ++++ b/node_modules/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt +@@ -26,6 +26,7 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource + import androidx.media3.exoplayer.dash.DashMediaSource + import androidx.media3.exoplayer.hls.HlsMediaSource + import androidx.media3.exoplayer.smoothstreaming.SsMediaSource ++import androidx.media3.extractor.DefaultExtractorsFactory + import androidx.media3.exoplayer.source.MediaSource + import androidx.media3.exoplayer.source.ProgressiveMediaSource + import expo.modules.interfaces.permissions.Permissions +@@ -553,7 +554,10 @@ class AudioModule : Module() { + CONTENT_TYPE_SS -> SsMediaSource.Factory(factory) + CONTENT_TYPE_DASH -> DashMediaSource.Factory(factory) + CONTENT_TYPE_HLS -> HlsMediaSource.Factory(factory) +- CONTENT_TYPE_OTHER -> ProgressiveMediaSource.Factory(factory) ++ CONTENT_TYPE_OTHER -> ProgressiveMediaSource.Factory( ++ factory, ++ DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true) ++ ) + else -> throw IllegalStateException("Unsupported type: $type") + }.createMediaSource(mediaItem) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae670e202a..9b5d6798369 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,9 +129,9 @@ importers: expo-apple-authentication: specifier: ~8.0.8 version: 8.0.8(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0)) - expo-av: - specifier: ~16.0.8 - version: 16.0.8(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-audio: + specifier: ~0.4.9 + version: 0.4.9(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-camera: specifier: ~17.0.10 version: 17.0.10(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -165,6 +165,9 @@ importers: expo-system-ui: specifier: ~6.0.9 version: 6.0.9(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0)) + expo-video: + specifier: ~3.0.16 + version: 3.0.16(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-video-thumbnails: specifier: ~10.0.8 version: 10.0.8(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) @@ -4476,16 +4479,12 @@ packages: react: '*' react-native: '*' - expo-av@16.0.8: - resolution: {integrity: sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==} + expo-audio@0.4.9: + resolution: {integrity: sha512-J4mMYEt2mqRqqwmSsXFylMGlrNWa+MbCzGl1IZBs+smvPAMJ3Ni8fNplzCQ0I9RnRzygKhRwJNpnAVL+n4MuyA==} peerDependencies: expo: '*' react: '*' react-native: '*' - react-native-web: '*' - peerDependenciesMeta: - react-native-web: - optional: true expo-camera@17.0.10: resolution: {integrity: sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==} @@ -4596,6 +4595,13 @@ packages: peerDependencies: expo: '*' + expo-video@3.0.16: + resolution: {integrity: sha512-H1HlxcHGomZItqisGfW3YL/G9BHtNBfVSimDJcLuyxyU87wFnV8loO9tCjuhufkfh/aTa2sW5BYAjLjg9DvnBQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-web-browser@15.0.10: resolution: {integrity: sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==} peerDependencies: @@ -12568,7 +12574,7 @@ snapshots: transitivePeerDependencies: - supports-color - expo-av@16.0.8(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + expo-audio@0.4.9(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: expo: 54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: 19.1.0 @@ -12681,6 +12687,12 @@ snapshots: dependencies: expo: 54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-video@3.0.16(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) + expo-web-browser@15.0.10(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0)): dependencies: expo: 54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)