Merge pull request #648 from vector-im/dbkr/tiles_for_everyone

Show tiles for members we're trying to connect to
This commit is contained in:
David Baker 2022-10-25 12:56:43 +01:00 committed by GitHub
commit b8af9a0733
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 88 additions and 62 deletions

View file

@ -2,6 +2,7 @@
"{{count}} people connected|one": "{{count}} person connected", "{{count}} people connected|one": "{{count}} person connected",
"{{count}} people connected|other": "{{count}} people connected", "{{count}} people connected|other": "{{count}} people connected",
"{{displayName}}, your call is now ended": "{{displayName}}, your call is now ended", "{{displayName}}, your call is now ended": "{{displayName}}, your call is now ended",
"{{name}} (Connecting...)": "{{name}} (Connecting...)",
"{{name}} is presenting": "{{name}} is presenting", "{{name}} is presenting": "{{name}} is presenting",
"{{name}} is talking…": "{{name}} is talking…", "{{name}} is talking…": "{{name}} is talking…",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",

View file

@ -76,7 +76,6 @@ export function GroupCallView({
toggleScreensharing, toggleScreensharing,
requestingScreenshare, requestingScreenshare,
isScreensharing, isScreensharing,
localScreenshareFeed,
screenshareFeeds, screenshareFeeds,
participants, participants,
unencryptedEventsFromUsers, unencryptedEventsFromUsers,
@ -221,6 +220,7 @@ export function GroupCallView({
client={client} client={client}
roomName={groupCall.room.name} roomName={groupCall.room.name}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
participants={participants}
microphoneMuted={microphoneMuted} microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted} localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted} toggleLocalVideoMuted={toggleLocalVideoMuted}
@ -230,7 +230,6 @@ export function GroupCallView({
onLeave={onLeave} onLeave={onLeave}
toggleScreensharing={toggleScreensharing} toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing} isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds} screenshareFeeds={screenshareFeeds}
roomIdOrAlias={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}
unencryptedEventsFromUsers={unencryptedEventsFromUsers} unencryptedEventsFromUsers={unencryptedEventsFromUsers}

View file

@ -70,6 +70,7 @@ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
groupCall: GroupCall; groupCall: GroupCall;
participants: RoomMember[];
roomName: string; roomName: string;
avatarUrl: string; avatarUrl: string;
microphoneMuted: boolean; microphoneMuted: boolean;
@ -82,14 +83,16 @@ interface Props {
onLeave: () => void; onLeave: () => void;
isScreensharing: boolean; isScreensharing: boolean;
screenshareFeeds: CallFeed[]; screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
roomIdOrAlias: string; roomIdOrAlias: string;
unencryptedEventsFromUsers: Set<string>; unencryptedEventsFromUsers: Set<string>;
hideHeader: boolean; hideHeader: boolean;
} }
export interface Participant { // Represents something that should get a tile on the layout,
// ie. a user's video feed or a screen share feed.
export interface TileDescriptor {
id: string; id: string;
member: RoomMember;
focused: boolean; focused: boolean;
presenter: boolean; presenter: boolean;
callFeed?: CallFeed; callFeed?: CallFeed;
@ -99,6 +102,7 @@ export interface Participant {
export function InCallView({ export function InCallView({
client, client,
groupCall, groupCall,
participants,
roomName, roomName,
avatarUrl, avatarUrl,
microphoneMuted, microphoneMuted,
@ -111,7 +115,6 @@ export function InCallView({
toggleScreensharing, toggleScreensharing,
isScreensharing, isScreensharing,
screenshareFeeds, screenshareFeeds,
localScreenshareFeed,
roomIdOrAlias, roomIdOrAlias,
unencryptedEventsFromUsers, unencryptedEventsFromUsers,
hideHeader, hideHeader,
@ -185,39 +188,48 @@ export function InCallView({
}, [setLayout]); }, [setLayout]);
const items = useMemo(() => { const items = useMemo(() => {
const participants: Participant[] = []; const tileDescriptors: TileDescriptor[] = [];
for (const callFeed of userMediaFeeds) { // one tile for each participants, to start with (we want a tile for everyone we
participants.push({ // think should be in the call, even if we don't have a media feed for them yet)
id: callFeed.stream.id, for (const p of participants) {
callFeed, const userMediaFeed = userMediaFeeds.find((f) => f.userId === p.userId);
focused:
screenshareFeeds.length === 0 && callFeed.userId === activeSpeaker, // NB. this assumes that the same user can't join more than once from multiple
isLocal: callFeed.isLocal(), // devices, but the participants are just RoomMembers, so this assumption is baked
// into GroupCall itself.
tileDescriptors.push({
id: p.userId,
member: p,
callFeed: userMediaFeed,
focused: screenshareFeeds.length === 0 && p.userId === activeSpeaker,
isLocal: p.userId === client.getUserId(),
presenter: false, presenter: false,
}); });
} }
for (const callFeed of screenshareFeeds) { // add the screenshares too
const userMediaItem = participants.find( for (const screenshareFeed of screenshareFeeds) {
(item) => item.callFeed.userId === callFeed.userId const userMediaItem = tileDescriptors.find(
(item) => item.member.userId === screenshareFeed.userId
); );
if (userMediaItem) { if (userMediaItem) {
userMediaItem.presenter = true; userMediaItem.presenter = true;
} }
participants.push({ tileDescriptors.push({
id: callFeed.stream.id, id: screenshareFeed.stream.id,
callFeed, member: userMediaItem?.member,
callFeed: screenshareFeed,
focused: true, focused: true,
isLocal: callFeed.isLocal(), isLocal: screenshareFeed.isLocal(),
presenter: false, presenter: false,
}); });
} }
return participants; return tileDescriptors;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds]); }, [client, participants, userMediaFeeds, activeSpeaker, screenshareFeeds]);
// The maximised participant: either the participant that the user has // The maximised participant: either the participant that the user has
// manually put in fullscreen, or the focused (active) participant if the // manually put in fullscreen, or the focused (active) participant if the
@ -281,7 +293,13 @@ export function InCallView({
return ( return (
<VideoGrid items={items} layout={layout} disableAnimations={isSafari}> <VideoGrid items={items} layout={layout} disableAnimations={isSafari}>
{({ item, ...rest }: { item: Participant; [x: string]: unknown }) => ( {({
item,
...rest
}: {
item: TileDescriptor;
[x: string]: unknown;
}) => (
<VideoTileContainer <VideoTileContainer
key={item.id} key={item.id}
item={item} item={item}

View file

@ -51,7 +51,6 @@ export interface UseGroupCallReturnType {
requestingScreenshare: boolean; requestingScreenshare: boolean;
isScreensharing: boolean; isScreensharing: boolean;
screenshareFeeds: CallFeed[]; screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
localDesktopCapturerSourceId: string; localDesktopCapturerSourceId: string;
participants: RoomMember[]; participants: RoomMember[];
hasLocalParticipant: boolean; hasLocalParticipant: boolean;
@ -68,7 +67,6 @@ interface State {
microphoneMuted: boolean; microphoneMuted: boolean;
localVideoMuted: boolean; localVideoMuted: boolean;
screenshareFeeds: CallFeed[]; screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
localDesktopCapturerSourceId: string; localDesktopCapturerSourceId: string;
isScreensharing: boolean; isScreensharing: boolean;
requestingScreenshare: boolean; requestingScreenshare: boolean;
@ -89,7 +87,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
localVideoMuted, localVideoMuted,
isScreensharing, isScreensharing,
screenshareFeeds, screenshareFeeds,
localScreenshareFeed,
localDesktopCapturerSourceId, localDesktopCapturerSourceId,
participants, participants,
hasLocalParticipant, hasLocalParticipant,
@ -107,7 +104,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
localVideoMuted: false, localVideoMuted: false,
isScreensharing: false, isScreensharing: false,
screenshareFeeds: [], screenshareFeeds: [],
localScreenshareFeed: null,
localDesktopCapturerSourceId: null, localDesktopCapturerSourceId: null,
requestingScreenshare: false, requestingScreenshare: false,
participants: [], participants: [],
@ -135,7 +131,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
microphoneMuted: groupCall.isMicrophoneMuted(), microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(), localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(), isScreensharing: groupCall.isScreensharing(),
localScreenshareFeed: groupCall.localScreenshareFeed,
localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId, localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId,
screenshareFeeds: [...groupCall.screenshareFeeds], screenshareFeeds: [...groupCall.screenshareFeeds],
participants: [...groupCall.participants], participants: [...groupCall.participants],
@ -172,12 +167,11 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
function onLocalScreenshareStateChanged( function onLocalScreenshareStateChanged(
isScreensharing: boolean, isScreensharing: boolean,
localScreenshareFeed: CallFeed, _localScreenshareFeed: CallFeed,
localDesktopCapturerSourceId: string localDesktopCapturerSourceId: string
): void { ): void {
updateState({ updateState({
isScreensharing, isScreensharing,
localScreenshareFeed,
localDesktopCapturerSourceId, localDesktopCapturerSourceId,
}); });
} }
@ -228,7 +222,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
microphoneMuted: groupCall.isMicrophoneMuted(), microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(), localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(), isScreensharing: groupCall.isScreensharing(),
localScreenshareFeed: groupCall.localScreenshareFeed,
localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId, localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId,
screenshareFeeds: [...groupCall.screenshareFeeds], screenshareFeeds: [...groupCall.screenshareFeeds],
participants: [...groupCall.participants], participants: [...groupCall.participants],
@ -412,7 +405,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
requestingScreenshare, requestingScreenshare,
isScreensharing, isScreensharing,
screenshareFeeds, screenshareFeeds,
localScreenshareFeed,
localDesktopCapturerSourceId, localDesktopCapturerSourceId,
participants, participants,
hasLocalParticipant, hasLocalParticipant,

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { FC, useEffect, useRef } from "react"; import React, { FC, useEffect, useRef } from "react";
import { Participant } from "../room/InCallView"; import { TileDescriptor } from "../room/InCallView";
import { useCallFeed } from "./useCallFeed"; import { useCallFeed } from "./useCallFeed";
import { useMediaStreamTrackCount } from "./useMediaStream"; import { useMediaStreamTrackCount } from "./useMediaStream";
@ -24,7 +24,7 @@ import { useMediaStreamTrackCount } from "./useMediaStream";
// only way to a hook on an array // only way to a hook on an array
interface AudioForParticipantProps { interface AudioForParticipantProps {
item: Participant; item: TileDescriptor;
audioContext: AudioContext; audioContext: AudioContext;
audioDestination: AudioNode; audioDestination: AudioNode;
} }
@ -78,7 +78,7 @@ export const AudioForParticipant: FC<AudioForParticipantProps> = ({
}; };
interface AudioContainerProps { interface AudioContainerProps {
items: Participant[]; items: TileDescriptor[];
audioContext: AudioContext; audioContext: AudioContext;
audioDestination: AudioNode; audioDestination: AudioNode;
} }

View file

@ -16,11 +16,12 @@ limitations under the License.
import React, { useState } from "react"; import React, { useState } from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { RoomMember } from "matrix-js-sdk";
import { VideoGrid, useVideoGridLayout } from "./VideoGrid"; import { VideoGrid, useVideoGridLayout } from "./VideoGrid";
import { VideoTile } from "./VideoTile"; import { VideoTile } from "./VideoTile";
import { Button } from "../button"; import { Button } from "../button";
import { Participant } from "../room/InCallView"; import { TileDescriptor } from "../room/InCallView";
export default { export default {
title: "VideoGrid", title: "VideoGrid",
@ -33,10 +34,11 @@ export const ParticipantsTest = () => {
const { layout, setLayout } = useVideoGridLayout(false); const { layout, setLayout } = useVideoGridLayout(false);
const [participantCount, setParticipantCount] = useState(1); const [participantCount, setParticipantCount] = useState(1);
const items: Participant[] = useMemo( const items: TileDescriptor[] = useMemo(
() => () =>
new Array(participantCount).fill(undefined).map((_, i) => ({ new Array(participantCount).fill(undefined).map((_, i) => ({
id: (i + 1).toString(), id: (i + 1).toString(),
member: new RoomMember("!fake:room.id", `@user${i}:fake.dummy`),
focused: false, focused: false,
presenter: false, presenter: false,
})), })),
@ -77,6 +79,7 @@ export const ParticipantsTest = () => {
key={item.id} key={item.id}
name={`User ${item.id}`} name={`User ${item.id}`}
disableSpeakingIndicator={items.length < 3} disableSpeakingIndicator={items.length < 3}
hasFeed={true}
{...rest} {...rest}
/> />
)} )}

View file

@ -23,7 +23,7 @@ import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/typ
import styles from "./VideoGrid.module.css"; import styles from "./VideoGrid.module.css";
import { Layout } from "../room/GridLayoutMenu"; import { Layout } from "../room/GridLayoutMenu";
import { Participant } from "../room/InCallView"; import { TileDescriptor } from "../room/InCallView";
interface TilePosition { interface TilePosition {
x: number; x: number;
@ -36,7 +36,7 @@ interface TilePosition {
interface Tile { interface Tile {
key: Key; key: Key;
order: number; order: number;
item: Participant; item: TileDescriptor;
remove: boolean; remove: boolean;
focused: boolean; focused: boolean;
presenter: boolean; presenter: boolean;
@ -693,12 +693,12 @@ interface ChildrenProperties extends ReactDOMAttributes {
}; };
width: number; width: number;
height: number; height: number;
item: Participant; item: TileDescriptor;
[index: string]: unknown; [index: string]: unknown;
} }
interface VideoGridProps { interface VideoGridProps {
items: Participant[]; items: TileDescriptor[];
layout: Layout; layout: Layout;
disableAnimations?: boolean; disableAnimations?: boolean;
children: (props: ChildrenProperties) => React.ReactNode; children: (props: ChildrenProperties) => React.ReactNode;

View file

@ -26,6 +26,7 @@ import { AudioButton, FullscreenButton } from "../button/Button";
interface Props { interface Props {
name: string; name: string;
hasFeed: Boolean;
speaking?: boolean; speaking?: boolean;
audioMuted?: boolean; audioMuted?: boolean;
videoMuted?: boolean; videoMuted?: boolean;
@ -47,6 +48,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
( (
{ {
name, name,
hasFeed,
speaking, speaking,
audioMuted, audioMuted,
videoMuted, videoMuted,
@ -90,6 +92,8 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
} }
} }
const caption = hasFeed ? name : t("{{name}} (Connecting...)", { name });
return ( return (
<animated.div <animated.div
className={classNames(styles.videoTile, className, { className={classNames(styles.videoTile, className, {
@ -120,7 +124,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
<div className={classNames(styles.infoBubble, styles.memberName)}> <div className={classNames(styles.infoBubble, styles.memberName)}>
{audioMuted && !videoMuted && <MicMutedIcon />} {audioMuted && !videoMuted && <MicMutedIcon />}
{videoMuted && <VideoMutedIcon />} {videoMuted && <VideoMutedIcon />}
<span title={name}>{name}</span> <span title={caption}>{caption}</span>
</div> </div>
))} ))}
<video ref={mediaRef} playsInline disablePictureInPicture /> <video ref={mediaRef} playsInline disablePictureInPicture />

View file

@ -25,10 +25,10 @@ import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile"; import { VideoTile } from "./VideoTile";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal"; import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { Participant } from "../room/InCallView"; import { TileDescriptor } from "../room/InCallView";
interface Props { interface Props {
item: Participant; item: TileDescriptor;
width?: number; width?: number;
height?: number; height?: number;
getAvatar: ( getAvatar: (
@ -41,7 +41,7 @@ interface Props {
disableSpeakingIndicator: boolean; disableSpeakingIndicator: boolean;
maximised: boolean; maximised: boolean;
fullscreen: boolean; fullscreen: boolean;
onFullscreen: (item: Participant) => void; onFullscreen: (item: TileDescriptor) => void;
} }
export function VideoTileContainer({ export function VideoTileContainer({
@ -65,9 +65,8 @@ export function VideoTileContainer({
speaking, speaking,
stream, stream,
purpose, purpose,
member,
} = useCallFeed(item.callFeed); } = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(member); const { rawDisplayName } = useRoomMemberName(item.member);
const [tileRef, mediaRef] = useSpatialMediaStream( const [tileRef, mediaRef] = useSpatialMediaStream(
stream, stream,
audioContext, audioContext,
@ -99,9 +98,10 @@ export function VideoTileContainer({
videoMuted={videoMuted} videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare} screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName} name={rawDisplayName}
hasFeed={Boolean(item.callFeed)}
ref={tileRef} ref={tileRef}
mediaRef={mediaRef} mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)} avatar={getAvatar && getAvatar(item.member, width, height)}
onOptionsPress={onOptionsPress} onOptionsPress={onOptionsPress}
localVolume={localVolume} localVolume={localVolume}
maximised={maximised} maximised={maximised}

View file

@ -16,11 +16,10 @@ limitations under the License.
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes"; import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
interface CallFeedState { interface CallFeedState {
member: RoomMember; callFeed: CallFeed;
isLocal: boolean; isLocal: boolean;
speaking: boolean; speaking: boolean;
videoMuted: boolean; videoMuted: boolean;
@ -32,7 +31,7 @@ interface CallFeedState {
} }
function getCallFeedState(callFeed: CallFeed): CallFeedState { function getCallFeedState(callFeed: CallFeed): CallFeedState {
return { return {
member: callFeed ? callFeed.getMember() : null, callFeed,
isLocal: callFeed ? callFeed.isLocal() : false, isLocal: callFeed ? callFeed.isLocal() : false,
speaking: callFeed ? callFeed.isSpeaking() : false, speaking: callFeed ? callFeed.isSpeaking() : false,
videoMuted: callFeed ? callFeed.isVideoMuted() : true, videoMuted: callFeed ? callFeed.isVideoMuted() : true,

View file

@ -17,27 +17,27 @@ limitations under the License.
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Participant } from "../room/InCallView"; import { TileDescriptor } from "../room/InCallView";
import { useEventTarget } from "../useEvents"; import { useEventTarget } from "../useEvents";
import { useCallFeed } from "./useCallFeed"; import { useCallFeed } from "./useCallFeed";
export function useFullscreen(ref: React.RefObject<HTMLElement>): { export function useFullscreen(ref: React.RefObject<HTMLElement>): {
toggleFullscreen: (participant: Participant) => void; toggleFullscreen: (participant: TileDescriptor) => void;
fullscreenParticipant: Participant | null; fullscreenParticipant: TileDescriptor | null;
} { } {
const [fullscreenParticipant, setFullscreenParticipant] = const [fullscreenParticipant, setFullscreenParticipant] =
useState<Participant | null>(null); useState<TileDescriptor | null>(null);
const { disposed } = useCallFeed(fullscreenParticipant?.callFeed); const { disposed } = useCallFeed(fullscreenParticipant?.callFeed);
const toggleFullscreen = useCallback( const toggleFullscreen = useCallback(
(participant: Participant) => { (tileDes: TileDescriptor) => {
if (fullscreenParticipant) { if (fullscreenParticipant) {
document.exitFullscreen(); document.exitFullscreen();
setFullscreenParticipant(null); setFullscreenParticipant(null);
} else { } else {
try { try {
ref.current.requestFullscreen(); ref.current.requestFullscreen();
setFullscreenParticipant(participant); setFullscreenParticipant(tileDes);
} catch (error) { } catch (error) {
console.warn("Failed to fullscreen:", error); console.warn("Failed to fullscreen:", error);
} }

View file

@ -33,23 +33,33 @@ declare global {
} }
export const useMediaStreamTrackCount = ( export const useMediaStreamTrackCount = (
stream: MediaStream stream: MediaStream | null
): [number, number] => { ): [number, number] => {
const latestAudioTrackCount = stream ? stream.getAudioTracks().length : 0;
const latestVideoTrackCount = stream ? stream.getVideoTracks().length : 0;
const [audioTrackCount, setAudioTrackCount] = useState( const [audioTrackCount, setAudioTrackCount] = useState(
stream.getAudioTracks().length stream ? stream.getAudioTracks().length : 0
); );
const [videoTrackCount, setVideoTrackCount] = useState( const [videoTrackCount, setVideoTrackCount] = useState(
stream.getVideoTracks().length stream ? stream.getVideoTracks().length : 0
); );
const tracksChanged = useCallback(() => { const tracksChanged = useCallback(() => {
setAudioTrackCount(stream.getAudioTracks().length); setAudioTrackCount(stream ? stream.getAudioTracks().length : 0);
setVideoTrackCount(stream.getVideoTracks().length); setVideoTrackCount(stream ? stream.getVideoTracks().length : 0);
}, [stream]); }, [stream]);
useEventTarget(stream, "addtrack", tracksChanged); useEventTarget(stream, "addtrack", tracksChanged);
useEventTarget(stream, "removetrack", tracksChanged); useEventTarget(stream, "removetrack", tracksChanged);
if (
latestAudioTrackCount !== audioTrackCount ||
latestVideoTrackCount !== videoTrackCount
) {
tracksChanged();
}
return [audioTrackCount, videoTrackCount]; return [audioTrackCount, videoTrackCount];
}; };