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)