From 0afc129a3e1d403e9de8d4f79ea5dba5ec4839fe Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 3 Aug 2026 17:23:20 -0700 Subject: [PATCH 1/3] RG-T117 Fixing Expo 56 AV issue --- __mocks__/expo-audio.ts | 25 +++ __mocks__/expo-av.ts | 36 ---- app.config.ts | 1 + docs/audio-stream-refactoring.md | 156 ++++-------------- jest-setup.ts | 10 ++ package.json | 5 +- .../call-video-feeds/video-player-modal.tsx | 17 +- src/services/__tests__/audio.service.test.ts | 104 ++++++------ .../notification-sound.service.test.ts | 40 ++--- src/services/audio.service.ts | 44 ++--- src/services/notification-sound.service.ts | 66 ++++---- .../app/__tests__/audio-stream-store.test.ts | 103 ++++++------ .../livekit-store-room-switch.test.ts | 10 +- .../app/__tests__/livekit-store.test.ts | 17 +- src/stores/app/audio-stream-store.ts | 143 ++++++++-------- src/stores/app/livekit-store.ts | 45 +++-- yarn.lock | 10 +- 17 files changed, 366 insertions(+), 466 deletions(-) delete mode 100644 __mocks__/expo-av.ts diff --git a/__mocks__/expo-audio.ts b/__mocks__/expo-audio.ts index fab5b47b..2b3a3750 100644 --- a/__mocks__/expo-audio.ts +++ b/__mocks__/expo-audio.ts @@ -1,6 +1,31 @@ // Mock for expo-audio to understand the PermissionStatus structure export const getRecordingPermissionsAsync = jest.fn(); export const requestRecordingPermissionsAsync = jest.fn(); +export const setAudioModeAsync = jest.fn().mockResolvedValue(undefined); + +const createMockAudioPlayer = () => ({ + id: 'mock-audio-player', + isLoaded: true, + isBuffering: false, + playing: false, + muted: false, + loop: false, + volume: 1, + currentStatus: { + isLoaded: true, + isBuffering: false, + playing: false, + didJustFinish: false, + error: null, + }, + play: jest.fn(), + pause: jest.fn(), + seekTo: jest.fn().mockResolvedValue(undefined), + remove: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), +}); + +export const createAudioPlayer = jest.fn(createMockAudioPlayer); // Default mock implementation getRecordingPermissionsAsync.mockResolvedValue({ diff --git a/__mocks__/expo-av.ts b/__mocks__/expo-av.ts deleted file mode 100644 index 500b5988..00000000 --- a/__mocks__/expo-av.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Mock for expo-av -export const Audio = { - setAudioModeAsync: jest.fn().mockResolvedValue(undefined), - Sound: class MockSound { - static createAsync = jest.fn().mockResolvedValue({ - sound: new this(), - status: { isLoaded: true }, - }); - - playAsync = jest.fn().mockResolvedValue({ status: { isPlaying: true } }); - stopAsync = jest.fn().mockResolvedValue({ status: { isPlaying: false } }); - unloadAsync = jest.fn().mockResolvedValue(undefined); - setVolumeAsync = jest.fn().mockResolvedValue(undefined); - }, - setIsEnabledAsync: jest.fn().mockResolvedValue(undefined), - getPermissionsAsync: jest.fn().mockResolvedValue({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - }), - requestPermissionsAsync: jest.fn().mockResolvedValue({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - }), -}; - -export const InterruptionModeIOS = { - MixWithOthers: 0, - DoNotMix: 1, - DuckOthers: 2, -}; - -export const AVPlaybackSource = {}; diff --git a/app.config.ts b/app.config.ts index d19084f1..15049635 100644 --- a/app.config.ts +++ b/app.config.ts @@ -229,6 +229,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ microphonePermission: 'Allow Resgrid Unit to access the microphone for audio input used in PTT and calls.', }, ], + 'expo-video', 'react-native-ble-manager', '@livekit/react-native-expo-plugin', '@config-plugins/react-native-webrtc', diff --git a/docs/audio-stream-refactoring.md b/docs/audio-stream-refactoring.md index feab356f..61135aeb 100644 --- a/docs/audio-stream-refactoring.md +++ b/docs/audio-stream-refactoring.md @@ -1,148 +1,56 @@ -# Audio Stream Store Refactoring +# Audio Stream Store ## Overview -The audio stream store has been refactored to use `expo-av` instead of `expo-audio` to resolve issues with playing remote MP3 streams over the internet in the new Expo architecture. +The audio stream store uses `expo-audio`, the audio package supported by the current Expo SDK. Remote streams are created with `createAudioPlayer()` and their loading, buffering, playback, completion, and error states are observed through `playbackStatusUpdate` events. -## Key Changes +## Player lifecycle -### 1. Replaced expo-audio with expo-av - -**Before:** -```typescript -import { type AudioPlayer, createAudioPlayer } from 'expo-audio'; -``` - -**After:** ```typescript -import { Audio, type AVPlaybackSource, type AVPlaybackStatus } from 'expo-av'; -``` - -### 2. Updated Audio Player Management - -**Before:** -- Used `createAudioPlayer()` function -- Audio player instance stored as `AudioPlayer` - -**After:** -- Uses `Audio.Sound.createAsync()` method -- Audio player instance stored as `Audio.Sound` - -### 3. Enhanced Audio Configuration +import { createAudioPlayer, setAudioModeAsync, type AudioPlayer } from 'expo-audio'; + +await setAudioModeAsync({ + allowsRecording: false, + shouldPlayInBackground: true, + playsInSilentMode: true, + interruptionMode: 'duckOthers', + shouldRouteThroughEarpiece: false, +}); -Added proper audio mode configuration for streaming: +const player: AudioPlayer = createAudioPlayer(stream.Url, { + updateInterval: 1000, + keepAudioSessionActive: true, + preferredForwardBufferDuration: 5, +}); -```typescript -await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - staysActiveInBackground: true, - playsInSilentModeIOS: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: false, +player.addListener('playbackStatusUpdate', (status) => { + // Synchronize playback and buffering state and handle status.error. }); +player.play(); ``` -### 4. Improved State Management +Call `player.pause()` followed by `player.remove()` when replacing or stopping a stream. `remove()` releases the native player and its listeners. -Added new state properties for better stream status tracking: +## State -```typescript -interface AudioStreamState { - // ... existing properties - isLoading: boolean; // Track loading state - isBuffering: boolean; // Track buffering state - soundObject: Audio.Sound | null; // Sound instance -} -``` - -### 5. Better Error Handling +`useAudioStreamStore` exposes the available streams, the current stream and player, loading and buffering flags, and the `playStream`, `stopStream`, and `cleanup` operations. The public store API remains stable for UI consumers. -Enhanced error handling with proper cleanup and status updates. +## Dependencies -## Installation - -Make sure you have `expo-av` installed: +Use Expo's SDK-aware installer when changing audio dependencies: ```bash -yarn add expo-av +yarn expo install expo-audio ``` -## Usage Example +Run `yarn expo install --check` after dependency changes to ensure every native package matches the installed Expo SDK. -```typescript -import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; - -const MyComponent = () => { - const { - availableStreams, - isLoadingStreams, - currentStream, - isPlaying, - isLoading, - isBuffering, - fetchAvailableStreams, - playStream, - stopStream, - } = useAudioStreamStore(); - - useEffect(() => { - fetchAvailableStreams(); - }, []); - - const handlePlay = async (stream) => { - try { - await playStream(stream); - } catch (error) { - console.error('Failed to play stream:', error); - } - }; - - // ... render logic -}; -``` +## Configuration -## Benefits - -1. **Better Remote Streaming Support**: `expo-av` provides more robust support for remote MP3 streams -2. **Improved Audio Configuration**: Proper audio mode settings for background playback and silent mode -3. **Enhanced Error Handling**: Better error recovery and cleanup -4. **Loading States**: More granular loading and buffering states for better UX -5. **Memory Management**: Proper cleanup of audio resources - -## Migration Notes - -If you were using the previous audio stream store: - -1. Replace any direct `audioPlayer` references with `soundObject` -2. Update any custom audio handling code to use `expo-av` APIs -3. The store API remains largely the same, so most usage code should work without changes +The app config enables background audio and declares microphone permissions for PTT and LiveKit calls. Runtime microphone permissions are checked without activating a competing audio session so permission handling does not race LiveKit or CallKeep. ## Troubleshooting -### Common Issues - -1. **Audio not playing on iOS in silent mode**: Make sure `playsInSilentModeIOS: true` is set -2. **Buffering issues**: The store now properly tracks buffering state - use `isBuffering` to show loading indicators -3. **Background playback**: Ensure your app has proper background audio permissions configured - -### Audio Permissions - -Make sure your app's configuration includes proper audio permissions: - -**app.json/app.config.js:** -```json -{ - "expo": { - "ios": { - "infoPlist": { - "UIBackgroundModes": ["audio"] - } - }, - "android": { - "permissions": [ - "android.permission.RECORD_AUDIO" - ] - } - } -} -``` +1. For silent-mode playback, verify `playsInSilentMode: true`. +2. For remote stream stalls, inspect the `isBuffering` and `error` fields delivered by `playbackStatusUpdate`. +3. For background playback, verify the platform background-audio configuration and keep the player audio session active. diff --git a/jest-setup.ts b/jest-setup.ts index 58be9263..8fde1fb5 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -239,6 +239,16 @@ jest.mock('nativewind', () => ({ __esModule: true, })); +// Avoid loading expo-video's native module in component tests. +jest.mock('expo-video', () => ({ + VideoView: 'VideoView', + useVideoPlayer: jest.fn(() => ({ + play: jest.fn(), + pause: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + })), +})); + // Mock zod globally to avoid validation schema issues in tests jest.mock('zod', () => ({ z: { diff --git a/package.json b/package.json index d1144fd2..f17667d9 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "expo-asset": "~56.0.21", "expo-audio": "~56.0.13", "expo-auth-session": "~56.0.16", - "expo-av": "16.0.8", "expo-build-properties": "~56.0.24", "expo-clipboard": "~56.0.4", "expo-constants": "~56.0.22", @@ -115,6 +114,7 @@ "expo-status-bar": "~56.0.4", "expo-system-ui": "~56.0.5", "expo-task-manager": "~56.0.24", + "expo-video": "~56.1.4", "expo-web-browser": "~56.0.6", "geojson": "0.5.0", "i18next": "23.14.0", @@ -220,8 +220,7 @@ "exclude": [ "react-native-restart", "lucide-react-native", - "react-native-callkeep", - "expo-av" + "react-native-callkeep" ] } }, diff --git a/src/components/call-video-feeds/video-player-modal.tsx b/src/components/call-video-feeds/video-player-modal.tsx index c627de86..0d938c0e 100644 --- a/src/components/call-video-feeds/video-player-modal.tsx +++ b/src/components/call-video-feeds/video-player-modal.tsx @@ -1,4 +1,4 @@ -import { ResizeMode, Video } from 'expo-av'; +import { useVideoPlayer, VideoView } from 'expo-video'; import { CopyIcon, XIcon } from 'lucide-react-native'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,6 +21,19 @@ interface VideoPlayerModalProps { onCopyUrl: (feed: CallVideoFeedResultData) => void; } +interface NativeVideoPlayerProps { + uri: string; + contentType: 'hls' | 'dash'; +} + +const NativeVideoPlayer: React.FC = ({ uri, contentType }) => { + const player = useVideoPlayer({ uri, contentType }, (videoPlayer) => { + videoPlayer.play(); + }); + + return ; +}; + export const VideoPlayerModal: React.FC = ({ isOpen, onClose, feed, onCopyUrl }) => { const { t } = useTranslation(); @@ -40,7 +53,7 @@ export const VideoPlayerModal: React.FC = ({ isOpen, onCl switch (feed.FeedFormat) { case FeedFormat.HLS: case FeedFormat.DASH: - return