Merge pull request #20806 from JoVictorNunes/webcam-patch-0724

fix(webcam): fixes related to graphql subscriptions, et al.
This commit is contained in:
Anton Georgiev 2024-08-09 10:53:31 -04:00 committed by GitHub
commit 46de3f7efd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 148 additions and 254 deletions

View File

@ -12,8 +12,6 @@ import LocatedErrorBoundary from '/imports/ui/components/common/error-boundary/l
import CustomUsersSettings from '/imports/ui/components/join-handler/custom-users-settings/component';
import MeetingClient from '/client/meetingClient';
import GraphqlToMakeVarAdapterManager from '/imports/ui/components/components-data/graphqlToMakeVarAdapterManager/component';
const STARTUP_CRASH_METADATA = { logCode: 'app_startup_crash', logMessage: 'Possible startup crash' };
const APP_CRASH_METADATA = { logCode: 'app_crash', logMessage: 'Possible app crash' };
@ -27,11 +25,9 @@ const Main: React.FC = () => {
<LocatedErrorBoundary Fallback={ErrorScreen} logMetadata={APP_CRASH_METADATA}>
<ConnectionManager>
<PresenceManager>
<GraphqlToMakeVarAdapterManager>
<CustomUsersSettings>
<MeetingClient />
</CustomUsersSettings>
</GraphqlToMakeVarAdapterManager>
<CustomUsersSettings>
<MeetingClient />
</CustomUsersSettings>
</PresenceManager>
</ConnectionManager>
</LocatedErrorBoundary>

View File

@ -22,11 +22,11 @@ import useUserChangedLocalSettings from '../../services/settings/hooks/useUserCh
import { PINNED_PAD_SUBSCRIPTION } from '../notes/queries';
import connectionStatus from '../../core/graphql/singletons/connectionStatus';
import useDeduplicatedSubscription from '../../core/hooks/useDeduplicatedSubscription';
import VideoStreamsState from '../video-provider/state';
import useSettings from '../../services/settings/hooks/useSettings';
import { SETTINGS } from '../../services/settings/enums';
import { useStorageKey } from '../../services/storage/hooks';
import useMuteMicrophone from '../audio/audio-graphql/hooks/useMuteMicrophone';
import { useVideoStreamsCount } from '../video-provider/hooks';
const currentUserEmoji = (currentUser) => (currentUser
? {
@ -128,7 +128,7 @@ const AppContainer = (props) => {
const isSharedNotesPinnedFromGraphql = !!pinnedPadData
&& pinnedPadData.sharedNotes[0]?.sharedNotesExtId === NOTES_CONFIG.id;
const isSharedNotesPinned = sharedNotesInput?.isPinned && isSharedNotesPinnedFromGraphql;
const isThereWebcam = VideoStreamsState.getStreams().length > 0;
const isThereWebcam = useVideoStreamsCount() > 0;
const muteMicrophone = useMuteMicrophone();
const isScreenSharingEnabled = useIsScreenSharingEnabled();
const isExternalVideoEnabled = useIsExternalVideoEnabled();

View File

@ -219,7 +219,7 @@ const BreakoutJoinConfirmationContainer: React.FC = () => {
data: breakoutData,
} = useDeduplicatedSubscription<GetBreakoutDataResponse>(getBreakoutData);
const exitVideo = useExitVideo(true);
const { streams: videoStreams } = useStreams();
const videoStreams = useStreams();
const storeVideoDevices = () => {
VideoService.storeDeviceIds(videoStreams);
};

View File

@ -161,7 +161,7 @@ const BreakoutRoom: React.FC<BreakoutRoomProps> = ({
}, [breakouts]);
const exitVideo = useExitVideo();
const { streams } = useStreams();
const streams = useStreams();
return (
<Styled.Panel

View File

@ -1,49 +0,0 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import VideoStreamAdapter from '/imports/ui/components/video-provider/adapter';
interface GraphqlToMakeVarAdapterManagerProps {
children: React.ReactNode;
}
export interface AdapterProps extends GraphqlToMakeVarAdapterManagerProps {
onReady: (key:string) => void;
}
const GraphqlToMakeVarAdapterManager: React.FC<GraphqlToMakeVarAdapterManagerProps> = ({ children }) => {
const [adapterLoaded, setAdapterLoaded] = useState(false);
const loadedComponents = useRef<{
[key: string]: number;
}>({});
const adapterComponents = useRef([
VideoStreamAdapter,
]);
const onReady = useCallback((key: string) => {
loadedComponents.current[key] = 1;
if (Object.keys(loadedComponents.current).length >= adapterComponents.current.length) {
setAdapterLoaded(true);
}
}, []);
const nestAdapters = useMemo(() => {
return adapterComponents.current.reduce((acc, Component) => (
<Component onReady={onReady}>
{acc}
</Component>
), <span />);
}, []);
return (
<>
{nestAdapters}
{adapterLoaded ? children : null}
</>
);
};
export default GraphqlToMakeVarAdapterManager;

View File

@ -3,7 +3,6 @@ import { useMutation } from '@apollo/client';
import { UPDATE_CONNECTION_ALIVE_AT } from './mutations';
import { getStatus, handleAudioStatsEvent, startMonitoringNetwork } from '/imports/ui/components/connection-status/service';
import connectionStatus from '../../core/graphql/singletons/connectionStatus';
import { useGetStats } from '../video-provider/hooks';
import getBaseUrl from '/imports/ui/core/utils/getBaseUrl';
@ -14,8 +13,6 @@ const ConnectionStatus = () => {
const [updateConnectionAliveAtM] = useMutation(UPDATE_CONNECTION_ALIVE_AT);
const getVideoStreamsStats = useGetStats();
const handleUpdateConnectionAliveAt = () => {
const startTime = performance.now();
fetch(
@ -66,7 +63,7 @@ const ConnectionStatus = () => {
if (STATS_ENABLED) {
window.addEventListener('audiostats', handleAudioStatsEvent);
startMonitoringNetwork(getVideoStreamsStats);
startMonitoringNetwork();
}
return () => {

View File

@ -3,7 +3,6 @@ import { CONNECTION_STATUS_REPORT_SUBSCRIPTION } from '../queries';
import Service from '../service';
import Component from './component';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import { useGetStats } from '../../video-provider/hooks';
import useDeduplicatedSubscription from '/imports/ui/core/hooks/useDeduplicatedSubscription';
import { useReactiveVar } from '@apollo/client';
import connectionStatus from '/imports/ui/core/graphql/singletons/connectionStatus';
@ -16,14 +15,11 @@ const ConnectionStatusContainer = (props) => {
const newtworkData = useReactiveVar(connectionStatus.getNetworkDataVar());
const getVideoStreamsStats = useGetStats();
return (
<Component
{...props}
connectionData={connectionData}
amIModerator={amIModerator}
getVideoStreamsStats={getVideoStreamsStats}
networkData={newtworkData}
/>
);

View File

@ -5,6 +5,7 @@ import Session from '/imports/ui/services/storage/in-memory';
import { notify } from '/imports/ui/services/notification';
import AudioService from '/imports/ui/components/audio/service';
import ScreenshareService from '/imports/ui/components/screenshare/service';
import VideoService from '/imports/ui/components/video-provider/service';
import connectionStatus from '../../core/graphql/singletons/connectionStatus';
const intlMessages = defineMessages({
@ -210,8 +211,8 @@ const getAudioData = async () => {
* @returns An Object containing video data for all video peers and screenshare
* peer
*/
const getVideoData = async (getVideoStreamsStats) => {
const camerasData = await getVideoStreamsStats() || {};
const getVideoData = async () => {
const camerasData = await VideoService.getStats() || {};
const screenshareData = await ScreenshareService.getStats() || {};
@ -226,10 +227,10 @@ const getVideoData = async (getVideoStreamsStats) => {
* For audio, this will get information about the mic/listen-only stream.
* @returns An Object containing all this data.
*/
const getNetworkData = async (getVideoStreamsStats) => {
const getNetworkData = async () => {
const audio = await getAudioData();
const video = await getVideoData(getVideoStreamsStats);
const video = await getVideoData();
const user = {
time: new Date(),
@ -401,11 +402,11 @@ export function getStatus(levels, value) {
* Start monitoring the network data.
* @return {Promise} A Promise that resolves when process started.
*/
export async function startMonitoringNetwork(getVideoStreamsStats) {
let previousData = await getNetworkData(getVideoStreamsStats);
export async function startMonitoringNetwork() {
let previousData = await getNetworkData();
setInterval(async () => {
const data = await getNetworkData(getVideoStreamsStats);
const data = await getNetworkData();
const {
outbound: audioCurrentUploadRate,

View File

@ -1,74 +0,0 @@
import { useEffect, useRef } from 'react';
import { throttle } from 'radash';
import logger from '/imports/startup/client/logger';
import { VIDEO_STREAMS_SUBSCRIPTION } from './queries';
import { VideoStreamsResponse } from './types';
import { setStreams } from './state';
import { AdapterProps } from '../components-data/graphqlToMakeVarAdapterManager/component';
import createUseSubscription from '/imports/ui/core/hooks/createUseSubscription';
import { VIDEO_TYPES } from './enums';
const throttledSetStreams = throttle({ interval: 500 }, setStreams);
type SubscriptionData = VideoStreamsResponse['user_camera'][number];
const useVideoStreamsSubscription = createUseSubscription(
VIDEO_STREAMS_SUBSCRIPTION,
{},
true,
);
const VideoStreamAdapter: React.FC<AdapterProps> = ({
onReady,
children,
}) => {
const ready = useRef(false);
const { data, loading, errors } = useVideoStreamsSubscription();
useEffect(() => {
if (loading) return;
if (errors) {
errors.forEach((error) => {
logger.error({
logCode: 'video_stream_sub_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, 'Video streams subscription failed.');
});
}
if (!data) {
throttledSetStreams([]);
return;
}
const streams = (data as SubscriptionData[]).map(({ streamId, user, voice }) => ({
stream: streamId,
deviceId: streamId.split('_')[3],
name: user.name,
nameSortable: user.nameSortable,
userId: user.userId,
user,
floor: voice?.floor ?? false,
lastFloorTime: voice?.lastFloorTime ?? '0',
voice,
type: VIDEO_TYPES.STREAM,
}));
throttledSetStreams(streams);
}, [data]);
useEffect(() => {
if (!ready.current) {
ready.current = true;
onReady('VideoStreamAdapter');
}
}, [loading]);
return children;
};
export default VideoStreamAdapter;

View File

@ -20,7 +20,6 @@ import VideoService from './service';
import { Output } from '/imports/ui/components/layout/layoutTypes';
import { VideoItem } from './types';
import { debounce } from '/imports/utils/debounce';
import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
import useSettings from '/imports/ui/services/settings/hooks/useSettings';
import { SETTINGS } from '/imports/ui/services/settings/enums';
import { useStorageKey } from '/imports/ui/services/storage/hooks';
@ -64,7 +63,7 @@ const VideoProviderContainer: React.FC<VideoProviderContainerProps> = (props) =>
VideoService.applyCameraProfile,
CAMERA_QUALITY_THR_DEBOUNCE,
{ leading: false, trailing: true },
);
) as typeof VideoService.applyCameraProfile;
const { data: currentMeeting } = useMeeting((m) => ({
usersPolicies: m.usersPolicies,
@ -89,6 +88,7 @@ const VideoProviderContainer: React.FC<VideoProviderContainerProps> = (props) =>
totalNumberOfStreams,
totalNumberOfOtherStreams,
} = useVideoStreams();
VideoService.updateActivePeers(streams);
let usersVideo: VideoItem[] = streams;
@ -167,7 +167,7 @@ const VideoProviderContainer: React.FC<VideoProviderContainerProps> = (props) =>
exitVideo={exitVideo}
lockUser={lockUser}
stopVideo={stopVideo}
applyCameraProfile={applyCameraProfile as (peer: WebRtcPeer, profileId: string) => void}
applyCameraProfile={applyCameraProfile}
myRole={myRole}
/>
);

View File

@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useRef,
} from 'react';
import {
useReactiveVar,
@ -22,22 +23,24 @@ import {
getConnectingStream,
setVideoState,
useConnectingStream,
streams,
getVideoState,
} from '../state';
} from '/imports/ui/components/video-provider/state';
import {
OWN_VIDEO_STREAMS_QUERY,
GRID_USERS_SUBSCRIPTION,
VIEWERS_IN_WEBCAM_COUNT_SUBSCRIPTION,
} from '../queries';
import videoService from '../service';
import { CAMERA_BROADCAST_STOP } from '../mutations';
VIDEO_STREAMS_SUBSCRIPTION,
} from '/imports/ui/components/video-provider/queries';
import videoService from '/imports/ui/components/video-provider/service';
import { CAMERA_BROADCAST_STOP } from '/imports/ui/components/video-provider/mutations';
import {
GridItem,
StreamItem,
GridUsersResponse,
OwnVideoStreamsResponse,
} from '../types';
StreamSubscriptionData,
Stream,
} from '/imports/ui/components/video-provider/types';
import { DesktopPageSizes, MobilePageSizes } from '/imports/ui/Types/meetingClientSettings';
import logger from '/imports/startup/client/logger';
import useDeduplicatedSubscription from '/imports/ui/core/hooks/useDeduplicatedSubscription';
@ -47,11 +50,54 @@ import { SETTINGS } from '/imports/ui/services/settings/enums';
import { useStorageKey } from '/imports/ui/services/storage/hooks';
import ConnectionStatus from '/imports/ui/core/graphql/singletons/connectionStatus';
import { VIDEO_TYPES } from '/imports/ui/components/video-provider/enums';
import createUseSubscription from '/imports/ui/core/hooks/createUseSubscription';
const FILTER_VIDEO_STATS = [
'outbound-rtp',
'inbound-rtp',
];
const useVideoStreamsSubscription = createUseSubscription(
VIDEO_STREAMS_SUBSCRIPTION,
{},
true,
);
export const useStreams = () => {
const { data, loading, errors } = useVideoStreamsSubscription();
const streams = useRef<Stream[]>([]);
if (loading) return streams.current;
if (errors) {
errors.forEach((error) => {
logger.error({
logCode: 'video_stream_sub_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, 'Video streams subscription failed.');
});
}
if (!data) {
streams.current = [];
return streams.current;
}
const mappedStreams = (data as StreamSubscriptionData[]).map(({ streamId, user, voice }) => ({
stream: streamId,
deviceId: streamId.split('_')[3],
name: user.name,
nameSortable: user.nameSortable,
userId: user.userId,
user,
floor: voice?.floor ?? false,
lastFloorTime: voice?.lastFloorTime ?? '0',
voice,
type: VIDEO_TYPES.STREAM,
}));
streams.current = mappedStreams;
return streams.current;
};
export const useStatus = () => {
const { isConnected, isConnecting } = useVideoState();
@ -87,13 +133,13 @@ export const useIsUserLocked = () => {
};
export const useVideoStreamsCount = () => {
const { streams } = useStreams();
const streams = useStreams();
return streams.length;
};
export const useLocalVideoStreamsCount = () => {
const { streams } = useStreams();
const streams = useStreams();
const localStreams = streams.filter((vs) => videoService.isLocalStream(vs.stream));
return localStreams.length;
@ -248,26 +294,25 @@ export const useIsPaginationEnabled = () => {
return myPageSize > 0 && paginationEnabled;
};
export const useStreams = () => {
const videoStreams = useReactiveVar(streams);
return { streams: videoStreams };
};
export const useGridUsers = (exceptUserIds: string[], visibleStreamCount: number) => {
export const useGridUsers = (visibleStreamCount: number) => {
const gridSize = useGridSize();
const isGridEnabled = useStorageKey('isGridEnabled');
const gridItems = useRef<GridItem[]>([]);
const {
data: gridData,
error: gridError,
loading: gridLoading,
} = useSubscription<GridUsersResponse>(
GRID_USERS_SUBSCRIPTION,
{
variables: { exceptUserIds, limit: Math.max(gridSize - visibleStreamCount, 0) },
variables: { limit: Math.max(gridSize - visibleStreamCount, 0) },
skip: !isGridEnabled,
},
);
if (gridLoading) return gridItems.current;
if (gridError) {
logger.error({
logCode: 'grid_users_sub_error',
@ -278,21 +323,21 @@ export const useGridUsers = (exceptUserIds: string[], visibleStreamCount: number
}, 'Grid users subscription failed.');
}
let gridUsers: GridItem[] = [];
if (gridData) {
const newGridUsers = gridData.user.map((user) => ({
...user,
type: VIDEO_TYPES.GRID,
}));
gridUsers = newGridUsers;
gridItems.current = newGridUsers;
} else {
gridItems.current = [];
}
return gridUsers;
return gridItems.current;
};
export const useSharedDevices = () => {
const { streams } = useStreams();
const streams = useStreams();
const devices = streams
.filter((s) => videoService.isLocalStream(s.stream))
.map((vs) => vs.deviceId);
@ -339,7 +384,7 @@ export const useGridSize = () => {
export const useVideoStreams = () => {
const { viewParticipantsWebcams } = useSettings(SETTINGS.DATA_SAVING) as { viewParticipantsWebcams?: boolean };
const { currentVideoPageIndex, numberOfPages } = useVideoState();
const { streams: videoStreams } = useStreams();
const videoStreams = useStreams();
const connectingStream = useConnectingStream(videoStreams);
const myPageSize = useMyPageSize();
const isPaginationEnabled = useIsPaginationEnabled();
@ -382,10 +427,7 @@ export const useVideoStreams = () => {
streams = sortVideoStreams(streams, DEFAULT_SORTING);
}
const gridUsers = useGridUsers(
videoStreams.map((s) => s.userId),
streams.length,
);
const gridUsers = useGridUsers(streams.length);
return {
streams,
@ -396,7 +438,7 @@ export const useVideoStreams = () => {
};
export const useHasVideoStream = () => {
const { streams } = useStreams();
const streams = useStreams();
const connectingStream = useConnectingStream();
return !!connectingStream || streams.some((s) => videoService.isLocalStream(s.stream));
};
@ -485,53 +527,6 @@ export const useStopVideo = () => {
}, [cameraBroadcastStop]);
};
export const useActivePeers = () => {
const videoData = useVideoStreams();
if (!videoData) return null;
const { streams: activeVideoStreams } = videoData;
if (!activeVideoStreams) return null;
const activePeers: Record<string, RTCPeerConnection> = {};
activeVideoStreams.forEach((stream) => {
if (videoService.webRtcPeersRef()[stream.stream]) {
activePeers[stream.stream] = videoService.webRtcPeersRef()[stream.stream].peerConnection;
}
});
return activePeers;
};
export const useGetStats = () => {
const peers = useActivePeers();
return useCallback(async () => {
if (!peers) return null;
const stats: Record<string, unknown> = {};
await Promise.all(
Object.keys(peers).map(async (peerId) => {
const peerStats = await peers[peerId].getStats();
const videoStats: Record<string, unknown> = {};
peerStats.forEach((stat) => {
if (FILTER_VIDEO_STATS.includes(stat.type)) {
videoStats[stat.type] = stat;
}
});
stats[peerId] = videoStats;
}),
);
return stats;
}, [peers]);
};
export const useShouldRenderPaginationToggle = () => {
const myPageSize = useMyPageSize();
const {

View File

@ -57,11 +57,13 @@ export const VIEWERS_IN_WEBCAM_COUNT_SUBSCRIPTION = gql`
`;
export const GRID_USERS_SUBSCRIPTION = gql`
subscription GridUsers($exceptUserIds: [String]!, $limit: Int!) {
subscription GridUsers($limit: Int!) {
user(
where: {
userId: {
_nin: $exceptUserIds,
cameras_aggregate: {
count: {
predicate: { _eq: 0 },
},
},
},
limit: $limit,

View File

@ -18,11 +18,16 @@ import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
import { Constraints2 } from '/imports/ui/Types/meetingClientSettings';
import MediaStreamUtils from '/imports/utils/media-stream-utils';
import Session from '/imports/ui/services/storage/in-memory';
import type { Stream } from './types';
import type { Stream, StreamItem } from './types';
import { VIDEO_TYPES } from './enums';
const TOKEN = '_';
const FILTER_VIDEO_STATS = [
'outbound-rtp',
'inbound-rtp',
];
class VideoService {
public isMobile: boolean;
@ -40,6 +45,8 @@ class VideoService {
private deviceId: string | null = null;
private activePeers: Record<string, RTCPeerConnection>;
private readonly clientSessionUUID: string;
constructor() {
@ -60,6 +67,7 @@ class VideoService {
}
this.webRtcPeersRef = {};
this.activePeers = {};
}
static fetchNumberOfDevices(devices: MediaDeviceInfo[]) {
@ -474,6 +482,39 @@ class VideoService {
getPrefix() {
return `${Auth.userID}${TOKEN}${this.clientSessionUUID}`;
}
updateActivePeers(streams: StreamItem[]) {
const activePeers: Record<string, RTCPeerConnection> = {};
streams.forEach((vs) => {
if (this.webRtcPeersRef[vs.stream]) {
activePeers[vs.stream] = this.webRtcPeersRef[vs.stream].peerConnection;
}
});
this.activePeers = activePeers;
}
async getStats() {
const stats: Record<string, unknown> = {};
await Promise.all(
Object.keys(this.activePeers).map(async (peerId) => {
const peerStats = await this.activePeers[peerId].getStats();
const videoStats: Record<string, unknown> = {};
peerStats.forEach((stat) => {
if (FILTER_VIDEO_STATS.includes(stat.type)) {
videoStats[stat.type] = stat;
}
});
stats[peerId] = videoStats;
}),
);
return stats;
}
}
const videoService = new VideoService();
@ -507,7 +548,7 @@ export default {
updatePeerDictionaryReference: (
newRef: Record<string, WebRtcPeer>,
) => videoService.updatePeerDictionaryReference(newRef),
webRtcPeersRef: () => videoService.webRtcPeersRef,
getWebRtcPeersRef: () => videoService.webRtcPeersRef,
isMobile: videoService.isMobile,
notify: (message: string) => notify(message, 'error', 'video'),
applyCameraProfile: VideoService.applyCameraProfile,
@ -516,4 +557,6 @@ export default {
getRoleViewer: VideoService.getRoleViewer,
getPrefix: videoService.getPrefix.bind(videoService),
isPinEnabled: VideoService.isPinEnabled,
updateActivePeers: (streams: StreamItem[]) => videoService.updateActivePeers(streams),
getStats: () => videoService.getStats(),
};

View File

@ -52,14 +52,6 @@ const setConnectingStream = (stream: ConnectingStream | null) => {
const getConnectingStream = () => connectingStream();
const streams = makeVar<Stream[]>([]);
const setStreams = (vs: Stream[]) => {
streams(vs);
};
const getStreams = () => streams();
export {
useVideoState,
setVideoState,
@ -67,9 +59,6 @@ export {
useConnectingStream,
getConnectingStream,
setConnectingStream,
setStreams,
getStreams,
streams,
};
export default {
@ -79,7 +68,4 @@ export default {
useConnectingStream,
getConnectingStream,
setConnectingStream,
setStreams,
getStreams,
streams,
};

View File

@ -6,13 +6,13 @@ import { VIDEO_TYPES } from './enums';
const DEFAULT_SORTING_MODE = 'LOCAL_ALPHABETICAL';
// connecting last -> pin first
// pin first, ignore connecting streams
export const sortPin = (s1: StreamItem, s2: StreamItem) => {
if (s1.type === VIDEO_TYPES.CONNECTING) {
return 1;
return 0;
}
if (s2.type === VIDEO_TYPES.CONNECTING) {
return -1;
return 0;
}
if (s1.user.pinned) {
return -1;
@ -24,13 +24,13 @@ export const sortPin = (s1: StreamItem, s2: StreamItem) => {
export const mandatorySorting = (s1: StreamItem, s2: StreamItem) => sortPin(s1, s2);
// connecting last -> lastFloorTime (descending)
// lastFloorTime (descending), ignore connecting streams
export const sortVoiceActivity = (s1: StreamItem, s2: StreamItem) => {
if (s1.type === VIDEO_TYPES.CONNECTING) {
return 1;
return 0;
}
if (s2.type === VIDEO_TYPES.CONNECTING) {
return -1;
return 0;
}
if (s2.lastFloorTime < s1.lastFloorTime) {
return -1;

View File

@ -74,3 +74,4 @@ export type Stream = {
export type StreamItem = Stream | ConnectingStream;
export type GridItem = GridUser & { type: typeof VIDEO_TYPES.GRID };
export type VideoItem = StreamItem | GridItem;
export type StreamSubscriptionData = VideoStreamsResponse['user_camera'][number];