From 2491440fd875ee45e45db31cb50249cf5426bb57 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 12 Mar 2026 10:48:20 +0100 Subject: [PATCH] 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 --- apps/matrix/apps/mobile/app.json | 2 +- apps/matrix/apps/mobile/eas.json | 2 +- apps/matrix/apps/mobile/package.json | 2 +- .../mobile/src/components/VoiceMessage.tsx | 78 ++++++++----------- .../mobile/src/components/VoiceRecorder.tsx | 44 ++++++----- pnpm-lock.yaml | 20 ++--- 6 files changed, 67 insertions(+), 81 deletions(-) diff --git a/apps/matrix/apps/mobile/app.json b/apps/matrix/apps/mobile/app.json index ad128d2c5..f16f88740 100644 --- a/apps/matrix/apps/mobile/app.json +++ b/apps/matrix/apps/mobile/app.json @@ -42,7 +42,7 @@ "plugins": [ "expo-router", "expo-secure-store", - "expo-av", + "expo-audio", [ "expo-image-picker", { diff --git a/apps/matrix/apps/mobile/eas.json b/apps/matrix/apps/mobile/eas.json index d78458f97..1a0c7abe4 100644 --- a/apps/matrix/apps/mobile/eas.json +++ b/apps/matrix/apps/mobile/eas.json @@ -27,7 +27,7 @@ "extends": "base", "autoIncrement": true, "ios": { - "image": "latest" + "image": "default" } } }, diff --git a/apps/matrix/apps/mobile/package.json b/apps/matrix/apps/mobile/package.json index 30b1d836b..9ec55a9df 100644 --- a/apps/matrix/apps/mobile/package.json +++ b/apps/matrix/apps/mobile/package.json @@ -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", diff --git a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx index 6874c96da..85dba87bb 100644 --- a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx +++ b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx @@ -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(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) { `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 ? ( ) : playing ? ( @@ -83,12 +61,18 @@ export default function VoiceMessage({ uri, duration, isOwn }: Props) { {/* Waveform / progress bar */} - - + + - {formatDuration(playing || position > 0 ? position : totalDuration)} + {formatDuration(playing || currentTimeMs > 0 ? currentTimeMs : durationMs)} ); diff --git a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx index 8dd22f771..511cf5ee8 100644 --- a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx +++ b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx @@ -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; @@ -9,7 +14,7 @@ interface Props { } export default function VoiceRecorder({ onSend, onCancel }: Props) { - const recordingRef = useRef(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 */} `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' : ''}` + } > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf0462226..5fac18e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: