fix(mobile): migrate expo-av to expo-video and expo-audio

expo-av has no SDK 56 compatible release, and its EXEventEmitter.h
interface was removed from expo-modules-core, so the iOS release
archive fails compiling Pods/EXAV. Move video playback to
expo-video's useVideoPlayer/VideoView, and audio playback and
recording to expo-audio's createAudioPlayer/AudioModule.AudioRecorder,
preserving existing controls, mute/loop/resize behavior, and the
recording audio session configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-31 14:35:51 +01:00
1 parent 5e28117662
commit b8cb2addff
11 files changed
+524 -387

No files matched your search

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
import { useVideoPlayer, VideoView } from 'expo-video';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
@@ -30,7 +30,6 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
urlError,
onFullScreen,
}) => {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -42,22 +41,39 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
// Skip the cache lookup for local file URIs
const isLocalFile = signedUrl?.startsWith('file://');
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
// Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) {
// Log error if load failed
if ('error' in status && status.error) {
console.error(`[ChatVideoPlayer] Video load error:`, status.error);
setIsLoading(false);
}
return;
const player = useVideoPlayer(videoSource ? { uri: videoSource } : null, (setupPlayer) => {
setupPlayer.loop = false;
});
// Swap the player source when the cached local file becomes available,
// without reloading on the initial mount (useVideoPlayer already loaded it).
const mountedSourceRef = useRef(videoSource);
useEffect(() => {
if (videoSource && videoSource !== mountedSourceRef.current) {
mountedSourceRef.current = videoSource;
player.replace({ uri: videoSource });
}
}, [videoSource, player]);
setIsLoading(false);
setIsPlaying(status.isPlaying);
// Handle playback status updates
useEffect(() => {
const statusSubscription = player.addListener('statusChange', ({ status, error }) => {
if (status === 'error') {
if (error) {
console.error(`[ChatVideoPlayer] Video load error:`, error);
}
setIsLoading(false);
return;
}
setIsLoading(status === 'loading');
});
if (status.didJustFinish) {
const playingSubscription = player.addListener('playingChange', ({ isPlaying: playing }) => {
setIsPlaying(playing);
});
const endSubscription = player.addListener('playToEnd', () => {
setHasEnded(true);
setIsPlaying(false);
@@ -65,22 +81,26 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
if (signedUrl && !cachedUri && !isLocalFile) {
cacheVideoFile();
}
}
}, [signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
});
return () => {
statusSubscription.remove();
playingSubscription.remove();
endSubscription.remove();
};
}, [player, signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
if (!videoRef.current) return;
const handleTapToPlay = useCallback(() => {
if (isPlaying) {
await videoRef.current.pauseAsync();
player.pause();
setIsPlaying(false);
} else {
if (hasEnded) {
await videoRef.current.setPositionAsync(0);
player.replay();
setHasEnded(false);
}
await videoRef.current.playAsync();
player.play();
setIsPlaying(true);
// Start caching when playback starts
@@ -88,19 +108,19 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, cachedUri, cacheVideoFile]);
}, [isPlaying, hasEnded, player, signedUrl, cachedUri, cacheVideoFile]);
// Handle full screen
const handleFullScreen = useCallback(() => {
const uri = cachedUri || signedUrl;
if (uri) {
if (videoRef.current && isPlaying) {
videoRef.current.pauseAsync();
if (isPlaying) {
player.pause();
setIsPlaying(false);
}
onFullScreen(uri);
}
}, [cachedUri, signedUrl, isPlaying, onFullScreen]);
}, [cachedUri, signedUrl, isPlaying, player, onFullScreen]);
if (urlError || !videoSource) {
return (
@@ -119,15 +139,11 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
delayLongPress={300}
style={styles.container}
>
<Video
ref={videoRef}
source={{ uri: videoSource }}
<VideoView
player={player}
style={styles.video}
resizeMode={ResizeMode.COVER}
isLooping={false}
shouldPlay={false}
positionMillis={0}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
contentFit="cover"
nativeControls={false}
/>
{/* Loading indicator */}
@@ -1,6 +1,6 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
import { Video, ResizeMode } from 'expo-av';
import { useVideoPlayer, VideoView } from 'expo-video';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -27,6 +27,30 @@ const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
// Use safe area insets for button positioning to avoid notch/dynamic island
const buttonTop = insets.top + 10;
const player = useVideoPlayer(uri ? { uri } : null, (setupPlayer) => {
setupPlayer.loop = false;
});
// Swap the source when a different video is shown, without reloading on
// the initial mount (useVideoPlayer already loaded the first source).
const mountedUriRef = useRef(uri);
useEffect(() => {
if (uri && uri !== mountedUriRef.current) {
mountedUriRef.current = uri;
player.replace({ uri });
}
}, [uri, player]);
// Play while the modal is visible, pause otherwise (there is no
// declarative `shouldPlay` prop on `VideoView`).
useEffect(() => {
if (visible) {
player.play();
} else {
player.pause();
}
}, [visible, player]);
return (
<Modal
visible={visible}
@@ -75,13 +99,11 @@ const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
</TouchableOpacity>
{uri && (
<Video
source={{ uri }}
<VideoView
player={player}
style={{ width: windowWidth, height: windowHeight * 0.8 }}
resizeMode={ResizeMode.CONTAIN}
useNativeControls
shouldPlay={visible}
isLooping={false}
contentFit="contain"
nativeControls
/>
)}
</View>
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'rea
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image';
import { Audio } from 'expo-av';
import { createAudioPlayer, type AudioPlayer } from 'expo-audio';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui';
@@ -237,7 +237,7 @@ const AudioDisplay: React.FC<{
}> = ({ responseSummary, userId, partnerName }) => {
const [audioUrls, setAudioUrls] = useState<Record<string, string>>({});
const [playingId, setPlayingId] = useState<string | null>(null);
const [sounds, setSounds] = useState<Record<string, Audio.Sound>>({});
const [sounds, setSounds] = useState<Record<string, AudioPlayer>>({});
useEffect(() => {
const fetchAudioUrls = async () => {
@@ -266,7 +266,7 @@ const AudioDisplay: React.FC<{
// Cleanup sounds on unmount
return () => {
Object.values(sounds).forEach(sound => {
sound.unloadAsync();
sound.remove();
});
};
}, [responseSummary]);
@@ -283,33 +283,31 @@ const AudioDisplay: React.FC<{
// If already playing this one, pause it
if (playingId === responderId && sounds[responderId]) {
await sounds[responderId].pauseAsync();
sounds[responderId].pause();
setPlayingId(null);
return;
}
// Stop any currently playing audio
if (playingId && sounds[playingId]) {
await sounds[playingId].stopAsync();
sounds[playingId].pause();
await sounds[playingId].seekTo(0);
}
// Play the selected audio
try {
let sound = sounds[responderId];
if (!sound) {
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: audioUrl },
{ shouldPlay: false },
(status) => {
if (status.isLoaded && status.didJustFinish) {
setPlayingId(null);
}
sound = createAudioPlayer({ uri: audioUrl });
sound.addListener('playbackStatusUpdate', (status) => {
if (status.didJustFinish) {
setPlayingId(null);
}
);
sound = newSound;
});
setSounds(prev => ({ ...prev, [responderId]: sound }));
}
await sound.playFromPositionAsync(0);
await sound.seekTo(0);
sound.play();
setPlayingId(responderId);
} catch (err) {
console.error('Failed to play audio:', err);
@@ -28,7 +28,7 @@ function imageMime(uri: string): string {
return "image/jpeg";
}
/** expo-av records .m4a on both platforms; web lands on webm. */
/** expo-audio records .m4a on both platforms; web lands on webm. */
function audioMime(uri: string): string {
const ext = uri.split(".").pop()?.toLowerCase() ?? "m4a";
if (ext === "webm") return "audio/webm";
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Audio } from "expo-av";
import { createAudioPlayer, type AudioPlayer } from "expo-audio";
import { getMediaUrl } from "../../../lib/mediaApi";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { DareProofType } from "../types";
@@ -21,7 +21,7 @@ export interface DareProofViewProps {
export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [sound, setSound] = useState<AudioPlayer | null>(null);
const [playing, setPlaying] = useState(false);
const [loadingAudio, setLoadingAudio] = useState(false);
@@ -35,32 +35,30 @@ export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
}, [proofMediaId, proofType]);
useEffect(() => {
return () => { if (sound) void sound.unloadAsync(); };
return () => { if (sound) sound.remove(); };
}, [sound]);
const togglePlayback = useCallback(async () => {
try {
if (sound) {
const status = await sound.getStatusAsync();
if (status.isLoaded && status.isPlaying) {
await sound.pauseAsync();
if (sound.playing) {
sound.pause();
setPlaying(false);
} else {
await sound.replayAsync();
await sound.seekTo(0);
sound.play();
setPlaying(true);
}
return;
}
setLoadingAudio(true);
const { url: signedUrl } = await getMediaUrl(proofMediaId);
const created = await Audio.Sound.createAsync(
{ uri: signedUrl },
{ shouldPlay: true },
(status) => {
if (status.isLoaded && status.didJustFinish) setPlaying(false);
},
);
setSound(created.sound);
const created = createAudioPlayer({ uri: signedUrl });
created.addListener('playbackStatusUpdate', (status) => {
if (status.didJustFinish) setPlaying(false);
});
created.play();
setSound(created);
setPlaying(true);
} catch {
setFailed(true);