fix(matrix-mobile): migrate from expo-av to expo-audio for SDK 55 compatibility

expo-av was removed in Expo SDK 55, causing 'EXEventEmitter.h' not found
build errors. Migrated VoiceRecorder and VoiceMessage to use expo-audio.
Also changed EAS build image from "latest" (Xcode 26.2 beta) to "default".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-12 10:48:20 +01:00
parent b35b9fd76e
commit 2491440fd8
6 changed files with 67 additions and 81 deletions

View file

@ -42,7 +42,7 @@
"plugins": [
"expo-router",
"expo-secure-store",
"expo-av",
"expo-audio",
[
"expo-image-picker",
{

View file

@ -27,7 +27,7 @@
"extends": "base",
"autoIncrement": true,
"ios": {
"image": "latest"
"image": "default"
}
}
},

View file

@ -21,7 +21,7 @@
"buffer": "^6.0.3",
"events": "^3.3.0",
"expo": "~55.0.5",
"expo-av": "~16.0.8",
"expo-audio": "~55.0.8",
"expo-constants": "~55.0.7",
"expo-document-picker": "~55.0.8",
"expo-file-system": "~55.0.10",

View file

@ -1,6 +1,6 @@
import { useState, useRef } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, Pressable, ActivityIndicator } from 'react-native';
import { Audio } from 'expo-av';
import { useAudioPlayer, useAudioPlayerStatus, setAudioModeAsync } from 'expo-audio';
import { Play, Pause } from 'phosphor-react-native';
interface Props {
@ -17,51 +17,27 @@ function formatDuration(ms: number): string {
}
export default function VoiceMessage({ uri, duration, isOwn }: Props) {
const soundRef = useRef<Audio.Sound | null>(null);
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(false);
const [position, setPosition] = useState(0);
const [totalDuration, setTotalDuration] = useState(duration ?? 0);
const player = useAudioPlayer(uri);
const status = useAudioPlayerStatus(player);
const [initialized, setInitialized] = useState(false);
const progress = totalDuration > 0 ? position / totalDuration : 0;
const currentTimeMs = (status.currentTime ?? 0) * 1000;
const durationMs = (status.duration ?? 0) * 1000 || duration || 0;
const playing = status.playing;
const progress = durationMs > 0 ? currentTimeMs / durationMs : 0;
const handleToggle = async () => {
if (loading) return;
const handleToggle = useCallback(async () => {
if (!initialized) {
await setAudioModeAsync({ playsInSilentMode: true });
setInitialized(true);
}
if (playing) {
await soundRef.current?.pauseAsync();
setPlaying(false);
return;
player.pause();
} else {
player.play();
}
if (soundRef.current) {
await soundRef.current.playAsync();
setPlaying(true);
return;
}
setLoading(true);
try {
await Audio.setAudioModeAsync({ playsInSilentModeIOS: true });
const { sound } = await Audio.Sound.createAsync(
{ uri },
{ shouldPlay: true },
(status) => {
if (!status.isLoaded) return;
setPosition(status.positionMillis);
if (status.durationMillis) setTotalDuration(status.durationMillis);
if (status.didJustFinish) {
setPlaying(false);
setPosition(0);
}
},
);
soundRef.current = sound;
setPlaying(true);
} finally {
setLoading(false);
}
};
}, [player, playing, initialized]);
const iconColor = isOwn ? '#fff' : '#7c6bff';
const barColor = isOwn ? 'rgba(255,255,255,0.5)' : '#2a2a2a';
@ -71,9 +47,11 @@ export default function VoiceMessage({ uri, duration, isOwn }: Props) {
<View className="flex-row items-center gap-3 px-3 py-2.5 min-w-[160px]">
<Pressable
onPress={handleToggle}
className={({ pressed }) => `w-8 h-8 rounded-full items-center justify-center ${pressed ? 'opacity-60' : ''} ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`}
className={({ pressed }) =>
`w-8 h-8 rounded-full items-center justify-center ${pressed ? 'opacity-60' : ''} ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`
}
>
{loading ? (
{status.isBuffering ? (
<ActivityIndicator size={14} color={iconColor} />
) : playing ? (
<Pause size={14} color={iconColor} weight="fill" />
@ -83,12 +61,18 @@ export default function VoiceMessage({ uri, duration, isOwn }: Props) {
</Pressable>
{/* Waveform / progress bar */}
<View className="flex-1 h-1 rounded-full overflow-hidden" style={{ backgroundColor: barColor }}>
<View style={{ width: `${progress * 100}%`, backgroundColor: fillColor }} className="h-full rounded-full" />
<View
className="flex-1 h-1 rounded-full overflow-hidden"
style={{ backgroundColor: barColor }}
>
<View
style={{ width: `${progress * 100}%`, backgroundColor: fillColor }}
className="h-full rounded-full"
/>
</View>
<Text className={`text-xs tabular-nums ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
{formatDuration(playing || position > 0 ? position : totalDuration)}
{formatDuration(playing || currentTimeMs > 0 ? currentTimeMs : durationMs)}
</Text>
</View>
);

View file

@ -1,7 +1,12 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { View, Text, Pressable, Animated, Alert } from 'react-native';
import { Audio } from 'expo-av';
import { Microphone, Stop, Trash, PaperPlaneRight } from 'phosphor-react-native';
import {
useAudioRecorder,
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
} from 'expo-audio';
import { Trash, PaperPlaneRight } from 'phosphor-react-native';
interface Props {
onSend: (uri: string, durationMs: number) => Promise<void>;
@ -9,7 +14,7 @@ interface Props {
}
export default function VoiceRecorder({ onSend, onCancel }: Props) {
const recordingRef = useRef<Audio.Recording | null>(null);
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const [duration, setDuration] = useState(0);
const [sending, setSending] = useState(false);
const pulseAnim = useRef(new Animated.Value(1)).current;
@ -17,12 +22,11 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
useEffect(() => {
startRecording();
// Pulse animation
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, { toValue: 1.3, duration: 600, useNativeDriver: true }),
Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
]),
])
);
pulse.start();
return () => {
@ -33,17 +37,15 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
const startRecording = async () => {
try {
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) {
Alert.alert('Permission required', 'Microphone access is needed to record voice messages.');
onCancel();
return;
}
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY,
);
recordingRef.current = recording;
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
await recorder.prepareToRecordAsync();
recorder.record();
timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000);
} catch (err) {
Alert.alert('Error', 'Could not start recording');
@ -54,17 +56,19 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
const stopRecordingCleanup = async () => {
if (timerRef.current) clearInterval(timerRef.current);
try {
await recordingRef.current?.stopAndUnloadAsync();
} catch { /* ignore */ }
await recorder.stop();
} catch {
/* ignore */
}
};
const handleSend = async () => {
if (!recordingRef.current || sending) return;
if (sending) return;
setSending(true);
if (timerRef.current) clearInterval(timerRef.current);
try {
await recordingRef.current.stopAndUnloadAsync();
const uri = recordingRef.current.getURI();
await recorder.stop();
const uri = recorder.uri;
if (!uri) throw new Error('No recording URI');
await onSend(uri, duration * 1000);
} catch (err) {
@ -90,7 +94,9 @@ export default function VoiceRecorder({ onSend, onCancel }: Props) {
{/* Discard */}
<Pressable
onPress={handleDiscard}
className={({ pressed }) => `w-10 h-10 rounded-full bg-destructive/10 items-center justify-center ${pressed ? 'opacity-60' : ''}`}
className={({ pressed }) =>
`w-10 h-10 rounded-full bg-destructive/10 items-center justify-center ${pressed ? 'opacity-60' : ''}`
}
>
<Trash size={18} color="#ef4444" />
</Pressable>

20
pnpm-lock.yaml generated
View file

@ -2449,9 +2449,9 @@ importers:
expo:
specifier: ~55.0.5
version: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-av:
specifier: ~16.0.8
version: 16.0.8(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-audio:
specifier: ~55.0.8
version: 55.0.8(expo-asset@55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-constants:
specifier: ~55.0.7
version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3)
@ -17578,16 +17578,13 @@ packages:
react: '*'
react-native: '*'
expo-av@16.0.8:
resolution: {integrity: sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==}
expo-audio@55.0.8:
resolution: {integrity: sha512-X61pQSikE2rsP2ZTMFUMThOmgGyYEHcmZpGVMrKJgcYtRCFKuctB/z69dFQPoumL+zTz8qlBoGohjkHVvA9P8A==}
peerDependencies:
expo: '*'
expo-asset: '*'
react: '*'
react-native: '*'
react-native-web: '*'
peerDependenciesMeta:
react-native-web:
optional: true
expo-blur@14.0.3:
resolution: {integrity: sha512-BL3xnqBJbYm3Hg9t/HjNjdeY7N/q8eK5tsLYxswWG1yElISWZmMvrXYekl7XaVCPfyFyz8vQeaxd7q74ZY3Wrw==}
@ -42423,13 +42420,12 @@ snapshots:
- typescript
optional: true
expo-av@16.0.8(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
expo-audio@55.0.8(expo-asset@55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-asset: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0)
optionalDependencies:
react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
expo-blur@14.0.3(expo@52.0.47)(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
dependencies: