mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
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);
|
||||
|
||||
Reference in new issue
Block a user