diff --git a/packages/react-bindings/src/hooks/callUtilHooks.ts b/packages/react-bindings/src/hooks/callUtilHooks.ts index f99ef0845d..456925cf19 100644 --- a/packages/react-bindings/src/hooks/callUtilHooks.ts +++ b/packages/react-bindings/src/hooks/callUtilHooks.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useCall } from '../contexts'; import { useIsCallRecordingInProgress } from './callStateHooks'; +import { hasAudio, StreamVideoParticipant } from '@stream-io/video-client'; /** * Custom hook for toggling call recording in a video call. @@ -42,3 +43,48 @@ export const useToggleCallRecording = () => { return { toggleCallRecording, isAwaitingResponse, isCallRecordingInProgress }; }; + +/** + * Custom hook for checking if an audio track is connecting. + * + * This hook checks if the participant has an audio track and if the audio track is unmuted. + * + * @param participant the participant to check. + * @returns true if the audio track is connecting, false otherwise. + */ +export const useIsAudioConnecting = ( + participant: StreamVideoParticipant, +): boolean => { + const audioStream = participant.audioStream; + const hasAudioTrack = hasAudio(participant); + const trackId = audioStream?.getAudioTracks()[0]?.id; + + const [unmuted, setUnmuted] = useState(() => { + const track = audioStream?.getAudioTracks()[0]; + return !!track && !track.muted; + }); + + useEffect(() => { + const track = audioStream?.getAudioTracks()[0]; + if (!track) { + setUnmuted(false); + return; + } + + setUnmuted(!track.muted); + + const handler = () => { + setUnmuted(!track.muted); + }; + + track.addEventListener('mute', handler); + track.addEventListener('unmute', handler); + + return () => { + track.removeEventListener('mute', handler); + track.removeEventListener('unmute', handler); + }; + }, [audioStream, trackId]); + + return hasAudioTrack && !unmuted; +}; diff --git a/packages/react-native-sdk/src/components/Participant/ParticipantView/ParticipantLabel.tsx b/packages/react-native-sdk/src/components/Participant/ParticipantView/ParticipantLabel.tsx index 9bb62875e5..81dfd65072 100644 --- a/packages/react-native-sdk/src/components/Participant/ParticipantView/ParticipantLabel.tsx +++ b/packages/react-native-sdk/src/components/Participant/ParticipantView/ParticipantLabel.tsx @@ -1,5 +1,11 @@ import React, { useMemo } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { + Pressable, + StyleSheet, + Text, + View, + ActivityIndicator, +} from 'react-native'; import { BadNetwork, MicOff, @@ -7,7 +13,11 @@ import { ScreenShareIndicator, VideoSlash, } from '../../../icons'; -import { useCall, useI18n } from '@stream-io/video-react-bindings'; +import { + useCall, + useI18n, + useIsAudioConnecting, +} from '@stream-io/video-react-bindings'; import { ComponentTestIds } from '../../../constants/TestIds'; import { type ParticipantViewProps } from './ParticipantView'; import { Z_INDEX } from '../../../constants'; @@ -55,6 +65,7 @@ export const ParticipantLabel = ({ const isAudioMuted = !hasAudio(participant); const isVideoMuted = !hasVideo(participant); const isTrackPaused = trackType && hasPausedTrack(participant, trackType); + const isAudioConnecting = useIsAudioConnecting(participant); if (trackType === 'screenShareTrack') { const screenShareText = isLocalParticipant @@ -104,6 +115,13 @@ export const ParticipantLabel = ({ ]} > + {isAudioConnecting && ( + + )} {participantLabel} @@ -174,6 +192,10 @@ const useStyles = () => { fontWeight: '400', color: theme.colors.textPrimary, }, + audioConnectingIndicator: { + marginRight: theme.variants.spacingSizes.sm, + justifyContent: 'center', + }, screenShareIconContainer: { marginRight: theme.variants.spacingSizes.sm, justifyContent: 'center', diff --git a/packages/react-sdk/src/core/components/ParticipantView/DefaultParticipantViewUI.tsx b/packages/react-sdk/src/core/components/ParticipantView/DefaultParticipantViewUI.tsx index 3352275856..55a74c6eed 100644 --- a/packages/react-sdk/src/core/components/ParticipantView/DefaultParticipantViewUI.tsx +++ b/packages/react-sdk/src/core/components/ParticipantView/DefaultParticipantViewUI.tsx @@ -1,4 +1,4 @@ -import { ComponentType, forwardRef, useEffect, useState } from 'react'; +import { ComponentType, forwardRef } from 'react'; import { Placement } from '@floating-ui/react'; import { hasAudio, @@ -6,9 +6,12 @@ import { hasScreenShare, hasVideo, SfuModels, - StreamVideoParticipant, } from '@stream-io/video-client'; -import { useCall, useI18n } from '@stream-io/video-react-bindings'; +import { + useCall, + useI18n, + useIsAudioConnecting, +} from '@stream-io/video-react-bindings'; import clsx from 'clsx'; import { @@ -139,8 +142,7 @@ export const ParticipantDetails = ({ const isTrackPaused = trackType !== 'none' ? hasPausedTrack(participant, trackType) : false; - const isAudioTrackUnmuted = useIsTrackUnmuted(participant); - const isAudioConnecting = hasAudioTrack && !isAudioTrackUnmuted; + const isAudioConnecting = useIsAudioConnecting(participant); return ( <> @@ -215,33 +217,3 @@ export const SpeechIndicator = () => { ); }; - -const useIsTrackUnmuted = (participant: StreamVideoParticipant) => { - const audioStream = participant.audioStream; - - const [unmuted, setUnmuted] = useState(() => { - const track = audioStream?.getAudioTracks()[0]; - return !!track && !track.muted; - }); - - useEffect(() => { - const track = audioStream?.getAudioTracks()[0]; - if (!track) return; - - setUnmuted(!track.muted); - - const handler = () => { - setUnmuted(!track.muted); - }; - - track.addEventListener('mute', handler); - track.addEventListener('unmute', handler); - - return () => { - track.removeEventListener('mute', handler); - track.removeEventListener('unmute', handler); - }; - }, [audioStream]); - - return unmuted; -}; diff --git a/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx b/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx index 2a181a074f..81e9bb4120 100644 --- a/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx +++ b/sample-apps/react-native/dogfood/src/components/ActiveCall.tsx @@ -20,6 +20,7 @@ import { BottomControls } from './CallControlls/BottomControls'; import { useOrientation } from '../hooks/useOrientation'; import { Z_INDEX } from '../constants'; import { TopControls } from './CallControlls/TopControls'; +import { AudioConnectingParticipantLabel } from './AudioConnectingParticipantLabel'; import { useLayout } from '../contexts/LayoutContext'; import { useAppGlobalStoreValue } from '../contexts/AppContext'; import DeviceInfo from 'react-native-device-info'; @@ -128,6 +129,7 @@ export const ActiveCall = ({ iOSPiPIncludeLocalParticipantVideo onHangupCallHandler={onHangupCallHandler} CallControls={CustomBottomControls} + ParticipantLabel={AudioConnectingParticipantLabel} landscape={isLandscape} layout={selectedLayout} /> diff --git a/sample-apps/react-native/dogfood/src/components/AudioConnectingParticipantLabel.tsx b/sample-apps/react-native/dogfood/src/components/AudioConnectingParticipantLabel.tsx new file mode 100644 index 0000000000..6fecebc8cc --- /dev/null +++ b/sample-apps/react-native/dogfood/src/components/AudioConnectingParticipantLabel.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { + useIsAudioConnecting, + ParticipantLabel, + type ParticipantLabelProps, + useTheme, +} from '@stream-io/video-react-native-sdk'; + +export const AudioConnectingParticipantLabel = ( + props: ParticipantLabelProps, +) => { + const { participant, trackType } = props; + const { theme } = useTheme(); + const isAudioConnecting = + useIsAudioConnecting(participant) && trackType !== 'screenShareTrack'; + + return ( + + + {isAudioConnecting && ( + + + + Connecting to audio… + + + )} + + ); +}; + +const styles = StyleSheet.create({ + badge: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + paddingHorizontal: 8, + paddingVertical: 4, + marginTop: 4, + borderRadius: 6, + }, + text: { marginLeft: 6, fontSize: 12, fontWeight: '500' }, +});