TeaWeb/shared/js/ui/frames/video/Renderer.tsx

806 lines
30 KiB
TypeScript

import * as React from "react";
import {useCallback, useContext, useEffect, useRef, useState} from "react";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
import {Registry} from "tc-shared/events";
import {
ChannelVideoEvents, ChannelVideoInfo,
ChannelVideoStreamState, getVideoStreamMap,
kLocalVideoId, makeVideoAutoplay,
VideoStreamState,
VideoSubscribeInfo
} from "./Definitions";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import ResizeObserver from "resize-observer-polyfill";
import {LogCategory, logTrace, logWarn} from "tc-shared/log";
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
import {Spotlight, SpotlightDimensions, SpotlightDimensionsContext} from "./RendererSpotlight";
import * as _ from "lodash";
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
import {tra} from "tc-shared/i18n/localize";
const SubscribeContext = React.createContext<VideoSubscribeInfo>(undefined);
const EventContext = React.createContext<Registry<ChannelVideoEvents>>(undefined);
const HandlerIdContext = React.createContext<string>(undefined);
export const VideoIdContext = React.createContext<string>(undefined);
export const RendererVideoEventContext = EventContext;
const cssStyle = require("./Renderer.scss");
const ExpendArrow = React.memo(() => {
const events = useContext(EventContext);
const [ expended, setExpended ] = useState(() => {
events.fire("query_expended");
return false;
});
events.reactUse("notify_expended", event => setExpended(event.expended), undefined, []);
return (
<div className={cssStyle.expendArrow} onClick={() => events.fire("action_toggle_expended", { expended: !expended })}>
<ClientIconRenderer icon={ClientIcon.DoubleArrow} className={cssStyle.icon} />
</div>
);
});
const VideoViewerCount = React.memo(() => {
const videoId = useContext(VideoIdContext);
const events = useContext(EventContext);
if(videoId !== kLocalVideoId) {
/* Currently one we can see our own video viewer */
return null;
}
const [ viewer, setViewer ] = useState<{ camera: number | undefined, screen: number | undefined }>(() => {
events.fire("query_viewer_count");
return { screen: undefined, camera: undefined };
});
events.reactUse("notify_viewer_count", event => setViewer({ camera: event.camera, screen: event.screen }));
let info = [];
if(typeof viewer.camera === "number") {
info.push(
<div className={cssStyle.entry} key={"camera"} title={tra("{} Camera viewers", viewer.camera)}>
<div className={cssStyle.value}>{viewer.camera}</div>
<ClientIconRenderer icon={ClientIcon.VideoMuted} className={cssStyle.icon} />
</div>
);
}
if(typeof viewer.screen === "number") {
info.push(
<div className={cssStyle.entry} key={"screen"} title={tra("{} Screen viewers", viewer.screen)}>
<div className={cssStyle.value}>{viewer.screen}</div>
<ClientIconRenderer icon={ClientIcon.ShareScreen} className={cssStyle.icon} />
</div>
);
}
if(info.length === 0) {
/* We're not streaming any video */
return null;
}
return (
<div
className={cssStyle.videoViewerCount}
onClick={() => events.fire("action_show_viewers")}
onDoubleClick={event => event.preventDefault()}
>
{info}
</div>
)
});
const VideoClientInfo = React.memo((props: { videoId: string }) => {
const events = useContext(EventContext);
const handlerId = useContext(HandlerIdContext);
const [ info, setInfo ] = useState<"loading" | ChannelVideoInfo>(() => {
events.fire("query_video_info", { videoId: props.videoId });
return "loading";
});
const [ statusIcon, setStatusIcon ] = useState<ClientIcon>(ClientIcon.PlayerOff);
events.reactUse("notify_video_info", event => {
if(event.videoId === props.videoId) {
setInfo(event.info);
setStatusIcon(event.info.statusIcon);
}
});
events.reactUse("notify_video_info_status", event => {
if(event.videoId === props.videoId) {
setStatusIcon(event.statusIcon);
}
});
let clientName;
if(info === "loading") {
clientName = (
<div className={cssStyle.name} key={"loading"}>
<Translatable>loading</Translatable> {props.videoId} <LoadingDots />
</div>
);
} else {
clientName = <ClientTag clientName={info.clientName} clientUniqueId={info.clientUniqueId} clientId={info.clientId} handlerId={handlerId} className={cssStyle.name} key={"loaded"} />;
}
return (
<div className={joinClassList(cssStyle.info, props.videoId === kLocalVideoId && cssStyle.local)}>
<ClientIconRenderer icon={statusIcon} className={cssStyle.icon} />
{clientName}
</div>
);
});
const VideoSubscribeContextProvider = (props: { children?: React.ReactElement | React.ReactElement[] }) => {
const events = useContext(EventContext);
const [ subscribeInfo, setSubscribeInfo ] = useState<VideoSubscribeInfo>(() => {
events.fire("query_subscribe_info");
return {
totalSubscriptions: 0,
subscribedStreams: {
screen: 0,
camera: 0
},
subscribeLimits: {},
maxSubscriptions: undefined
};
});
events.reactUse("notify_subscribe_info", event => setSubscribeInfo(event.info));
return (
<SubscribeContext.Provider value={subscribeInfo}>
{props.children}
</SubscribeContext.Provider>
);
}
const canSubscribe = (subscribeInfo: VideoSubscribeInfo, target: VideoBroadcastType) : boolean => {
if(typeof subscribeInfo.maxSubscriptions === "number" && subscribeInfo.maxSubscriptions <= subscribeInfo.totalSubscriptions) {
return false;
}
return typeof subscribeInfo.subscribeLimits[target] !== "number" || subscribeInfo.subscribeLimits[target] > subscribeInfo.subscribedStreams[target];
};
const VideoGeneralAvailableRenderer = (props: { videoId: string, haveScreen: boolean, haveCamera: boolean, className?: string }) => {
const events = useContext(EventContext);
const subscribeInfo = useContext(SubscribeContext);
if((props.haveCamera && canSubscribe(subscribeInfo, "camera")) || (props.haveScreen && canSubscribe(subscribeInfo, "screen"))) {
return (
<div className={cssStyle.text + " " + props.className} key={"video-muted"}>
<div className={cssStyle.videoAvailable}>
<Translatable>Video available</Translatable>
<div className={cssStyle.buttons}>
<div className={cssStyle.button2} onClick={() => events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: undefined, muted: false })}>
<Translatable>Watch</Translatable>
</div>
</div>
</div>
</div>
);
} else {
return (
<div className={cssStyle.text + " " + props.className} key={"limit-reached"}>
<div className={cssStyle.videoAvailable}>
<Translatable>Stream subscribe limit reached</Translatable>
{/* TODO: Name the failed permission */}
</div>
</div>
);
}
};
const VideoStreamAvailableRenderer = (props: { videoId: string, mode: VideoBroadcastType , className?: string }) => {
const events = useContext(EventContext);
const subscribeInfo = useContext(SubscribeContext);
if(canSubscribe(subscribeInfo, props.mode)) {
return (
<div className={cssStyle.text + " " + props.className} key={"video-muted"}>
<div className={cssStyle.videoAvailable}>
<Translatable>Video available</Translatable>
<div className={cssStyle.buttons}>
<div className={cssStyle.button2} onClick={() => events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: props.mode, muted: false })}>
<Translatable>Watch</Translatable>
</div>
<div className={cssStyle.button2} key={"ignore"} onClick={() => events.fire("action_dismiss", { videoId: props.videoId, broadcastType: props.mode })}>
<Translatable>Ignore</Translatable>
</div>
</div>
</div>
</div>
);
} else {
return (
<div className={cssStyle.text + " " + props.className} key={"limit-reached"}>
<div className={cssStyle.videoAvailable}>
<Translatable>Stream subscribe limit reached</Translatable>
{/* TODO: Name the failed permission */}
</div>
</div>
);
}
};
const MediaStreamVideoRenderer = React.memo((props: { stream: MediaStream | undefined, className: string, title: string }) => {
const refVideo = useRef<HTMLVideoElement>();
useEffect(() => {
let cancelAutoplay;
const video = refVideo.current;
if(props.stream) {
video.style.opacity = "1";
video.srcObject = props.stream;
video.muted = true;
cancelAutoplay = makeVideoAutoplay(video);
} else {
video.style.opacity = "0";
}
return () => {
const video = refVideo.current;
if(video) {
video.onpause = undefined;
video.onended = undefined;
}
if(cancelAutoplay) {
cancelAutoplay();
}
}
}, [ props.stream ]);
return (
<video ref={refVideo} className={cssStyle.video + " " + props.className} title={props.title} />
)
});
const VideoStreamPlayer = React.memo((props: { videoId: string, streamType: VideoBroadcastType, className?: string }) => {
const events = useContext(EventContext);
const [ state, setState ] = useState<VideoStreamState>(() => {
events.fire("query_video_stream", { videoId: props.videoId, broadcastType: props.streamType });
return { state: "disconnected", }
});
events.reactUse("notify_video_stream", event => {
if(event.videoId === props.videoId && event.broadcastType === props.streamType) {
setState(event.state);
}
});
switch (state.state) {
case "disconnected":
return (
<div className={cssStyle.text} key={"no-video-stream"}>
<div>
<Translatable>No video stream</Translatable>
</div>
</div>
);
case "connecting":
return (
<div className={cssStyle.text} key={"info-initializing"}>
<div>
<Translatable>connecting</Translatable> <LoadingDots />
</div>
</div>
);
case "connected":
const streamMap = getVideoStreamMap();
if(typeof streamMap[state.streamObjectId] === "undefined") {
return (
<div className={cssStyle.text} key={"missing-stream-object"}>
<div>
<Translatable>Missing stream object</Translatable>
</div>
</div>
);
}
return (
<MediaStreamVideoRenderer
stream={streamMap[state.streamObjectId]}
className={props.className}
title={props.streamType === "camera" ? tr("Camera") : tr("Screen")}
key={"connected"}
/>
);
case "failed":
return (
<div className={joinClassList(cssStyle.text, cssStyle.error)} key={"error"}>
<div><Translatable>Stream replay failed</Translatable></div>
</div>
);
case "available":
return (
<div className={cssStyle.text} key={"no-video-stream"}>
<div><Translatable>Video available</Translatable></div>
</div>
);
}
});
const VideoPlayer = React.memo((props: { videoId: string, cameraState: ChannelVideoStreamState, screenState: ChannelVideoStreamState }) => {
const streamElements = [];
const streamClasses = [ cssStyle.videoPrimary, cssStyle.videoSecondary ];
if(props.cameraState === "none" && props.screenState === "none") {
/* No video available. Will be handled bellow */
} else if(props.cameraState !== "streaming" && props.screenState !== "streaming") {
/* We're not streaming any video nor we don't have any video. Show general show video button. */
streamElements.push(
<VideoGeneralAvailableRenderer
key={"video-available"}
videoId={props.videoId}
haveCamera={props.cameraState !== "none"}
haveScreen={props.screenState !== "none"}
className={streamClasses.pop_front()}
/>
);
} else {
if(props.screenState === "available") {
streamElements.push(
<VideoStreamAvailableRenderer
key={"video-available-screen"}
videoId={props.videoId}
mode={"screen"}
className={streamClasses.pop_front()}
/>
);
} else if(props.screenState === "streaming") {
streamElements.push(
<VideoStreamPlayer key={"stream-screen"} videoId={props.videoId} streamType={"screen"} className={streamClasses.pop_front()} />
);
}
if(props.cameraState === "available") {
streamElements.push(
<VideoStreamAvailableRenderer
key={"video-available-camera"}
videoId={props.videoId}
mode={"camera"}
className={streamClasses.pop_front()}
/>
);
} else if(props.cameraState === "streaming") {
streamElements.push(
<VideoStreamPlayer key={"stream-camera"} videoId={props.videoId} streamType={"camera"} className={streamClasses.pop_front()} />
);
}
}
if(streamElements.length === 0){
return (
<div className={cssStyle.text} key={"no-video-stream"}>
<div>
{props.videoId === kLocalVideoId ?
<Translatable key={"own"}>You're not broadcasting video</Translatable> :
<Translatable key={"general"}>No Video</Translatable>
}
</div>
</div>
);
}
return <>{streamElements}</>;
});
const VideoToggleButton = React.memo((props: { videoId: string, broadcastType: VideoBroadcastType, target: boolean }) => {
const events = useContext(EventContext);
let title;
let icon: ClientIcon;
if(props.broadcastType === "camera") {
title = props.target ? useTr("Unmute screen video") : useTr("Mute screen video");
icon = ClientIcon.ShareScreen;
} else {
title = props.target ? useTr("Unmute camera video") : useTr("Mute camera video");
icon = ClientIcon.VideoMuted;
}
return (
<div className={joinClassList(cssStyle.iconContainer, cssStyle.toggle, !props.target && cssStyle.disabled)}
onClick={() => events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: props.broadcastType, muted: props.target })}
title={title}
>
<ClientIconRenderer className={cssStyle.icon} icon={icon} />
</div>
)
});
const VideoControlButtons = React.memo((props: {
videoId: string,
cameraState: ChannelVideoStreamState,
screenState: ChannelVideoStreamState,
isSpotlight: boolean,
fullscreenMode: "none" | "unavailable" | "set"
}) => {
const events = useContext(EventContext);
let buttons = [];
if(props.videoId !== kLocalVideoId) {
switch (props.screenState) {
case "available":
case "ignored":
buttons.push(
<VideoToggleButton videoId={props.videoId} target={false} broadcastType={"screen"} key={"screen-disabled"} />
);
break;
case "streaming":
buttons.push(
<VideoToggleButton videoId={props.videoId} target={true} broadcastType={"screen"} key={"screen-enabled"} />
);
break;
case "none":
default:
break;
}
switch (props.cameraState) {
case "available":
case "ignored":
buttons.push(
<VideoToggleButton videoId={props.videoId} target={false} broadcastType={"camera"} key={"camera-disabled"} />
);
break;
case "streaming":
buttons.push(
<VideoToggleButton videoId={props.videoId} target={true} broadcastType={"camera"} key={"camera-enabled"} />
);
break;
case "none":
default:
break;
}
}
buttons.push(
<div className={cssStyle.iconContainer + " " + (props.fullscreenMode === "unavailable" ? cssStyle.hidden : "")}
key={"spotlight"}
onClick={() => {
if(props.isSpotlight) {
events.fire("action_set_fullscreen", { videoId: props.fullscreenMode === "set" ? undefined : props.videoId });
} else {
events.fire("action_toggle_spotlight", { videoIds: [ props.videoId ], expend: true, enabled: true });
events.fire("action_focus_spotlight", { });
}
}}
title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")}
>
<ClientIconRenderer className={cssStyle.icon} icon={ClientIcon.Fullscreen} />
</div>
);
return (
<div className={cssStyle.actionIcons}>
{buttons}
</div>
);
});
export const VideoContainer = React.memo((props: { isSpotlight: boolean }) => {
const videoId = useContext(VideoIdContext);
const events = useContext(EventContext);
const refContainer = useRef<HTMLDivElement>();
const fullscreenCapable = "requestFullscreen" in HTMLElement.prototype;
const [ isFullscreen, setFullscreen ] = useState(false);
const [ cameraState, setCameraState ] = useState<ChannelVideoStreamState>("none");
const [ screenState, setScreenState ] = useState<ChannelVideoStreamState>(() => {
events.fire("query_video", { videoId: videoId });
return "none";
});
events.reactUse("notify_video", event => {
if(event.videoId === videoId) {
setCameraState(event.cameraStream);
setScreenState(event.screenStream);
}
});
useEffect(() => {
if(!isFullscreen) { return; }
if(document.fullscreenElement !== refContainer.current) {
setFullscreen(false);
return;
}
const listener = () => {
if(document.fullscreenElement !== refContainer.current) {
setFullscreen(false);
}
};
document.addEventListener("fullscreenchange", listener);
return () => document.removeEventListener("fullscreenchange", listener);
}, [ isFullscreen ]);
events.reactUse("action_set_fullscreen", event => {
if(event.videoId === videoId) {
if(!refContainer.current) { return; }
refContainer.current.requestFullscreen().then(() => {
setFullscreen(true);
}).catch(error => {
logWarn(LogCategory.GENERAL, tr("Failed to request fullscreen: %o"), error);
});
} else {
if(document.fullscreenElement === refContainer.current) {
document.exitFullscreen().then(undefined);
}
setFullscreen(false);
}
});
return (
<div
className={cssStyle.videoContainer + " " + cssStyle.outlined}
onDoubleClick={() => {
if(isFullscreen) {
events.fire("action_set_fullscreen", { videoId: undefined });
} else if(props.isSpotlight) {
events.fire("action_set_fullscreen", { videoId: videoId });
} else {
events.fire("action_toggle_spotlight", { videoIds: [ videoId ], expend: true, enabled: true });
events.fire("action_focus_spotlight", { });
}
}}
onContextMenu={event => {
const streamType = (event.target as HTMLElement).getAttribute("x-stream-type");
event.preventDefault();
spawnContextMenu({
pageY: event.pageY,
pageX: event.pageX
}, [
{
type: "normal",
label: tr("Popout Video"),
icon: ClientIcon.Fullscreen,
click: () => {
events.fire("action_set_pip", { videoId: videoId, broadcastType: streamType as any });
},
visible: !!streamType && "requestPictureInPicture" in HTMLVideoElement.prototype
},
{
type: "normal",
label: isFullscreen ? tr("Release fullscreen") : tr("Show in fullscreen"),
icon: ClientIcon.Fullscreen,
click: () => {
events.fire("action_set_fullscreen", { videoId: isFullscreen ? undefined : videoId });
}
},
{
type: "normal",
label: props.isSpotlight ? tr("Release spotlight") : tr("Put client in spotlight"),
icon: ClientIcon.Fullscreen,
click: () => {
events.fire("action_toggle_spotlight", { videoIds: [ videoId ], expend: true, enabled: !props.isSpotlight });
events.fire("action_focus_spotlight", { });
}
}
]);
}}
ref={refContainer}
>
<VideoPlayer videoId={videoId} cameraState={cameraState} screenState={screenState} />
<VideoClientInfo videoId={videoId} />
<VideoViewerCount />
<VideoControlButtons
videoId={videoId}
cameraState={cameraState}
screenState={screenState}
isSpotlight={props.isSpotlight}
fullscreenMode={fullscreenCapable ? isFullscreen ? "set" : "none" : "unavailable"}
/>
</div>
);
});
const VideoBarArrow = React.memo((props: { direction: "left" | "right", shown: boolean, containerRef: React.RefObject<HTMLDivElement> }) => {
const events = useContext(EventContext);
return (
<div className={cssStyle.arrow + " " + cssStyle[props.direction] + " " + (props.shown ? "" : cssStyle.hidden)} ref={props.containerRef}>
<div className={cssStyle.iconContainer} onClick={() => events.fire("action_video_scroll", { direction: props.direction })}>
<ClientIconRenderer icon={ClientIcon.SimpleArrow} className={cssStyle.icon} />
</div>
</div>
);
});
const VideoBar = React.memo(() => {
const events = useContext(EventContext);
const refVideos = useRef<HTMLDivElement>();
const refArrowRight = useRef<HTMLDivElement>();
const refArrowLeft = useRef<HTMLDivElement>();
const [ arrowLeftShown, setArrowLeftShown ] = useState(false);
const [ arrowRightShown, setArrowRightShown ] = useState(false);
const [ videos, setVideos ] = useState<string[]>(() => {
events.fire("query_videos");
return [];
});
events.reactUse("notify_videos", event => setVideos(event.videoIds));
const updateScrollButtons = useCallback(() => {
const container = refVideos.current;
if(!container) { return; }
const rightEndReached = container.scrollLeft + container.clientWidth + 1 >= container.scrollWidth;
const leftEndReached = container.scrollLeft <= .9;
setArrowLeftShown(!leftEndReached);
setArrowRightShown(!rightEndReached);
}, [ refVideos ]);
events.reactUse("action_video_scroll", event => {
const container = refVideos.current;
const arrowLeft = refArrowLeft.current;
const arrowRight = refArrowRight.current;
if(!container || !arrowLeft || !arrowRight) {
return;
}
const children = [...container.children] as HTMLElement[];
if(event.direction === "left") {
const currentCutOff = container.scrollLeft;
const element = children.filter(element => element.offsetLeft >= currentCutOff)
.sort((a, b) => a.offsetLeft - b.offsetLeft)[0];
container.scrollLeft = (element.offsetLeft + element.clientWidth) - (container.clientWidth - arrowRight.clientWidth);
} else {
const currentCutOff = container.scrollLeft + container.clientWidth;
const element = children.filter(element => element.offsetLeft <= currentCutOff)
.sort((a, b) => a.offsetLeft - b.offsetLeft)
.last();
container.scrollLeft = element.offsetLeft - arrowLeft.clientWidth;
}
updateScrollButtons();
}, undefined, [ updateScrollButtons ]);
useEffect(() => {
updateScrollButtons();
}, [ videos ]);
useEffect(() => {
const animationRequest = { current: 0 };
const observer = new ResizeObserver(() => {
if(animationRequest.current) {
return;
}
animationRequest.current = requestAnimationFrame(() => {
animationRequest.current = 0;
updateScrollButtons();
})
});
observer.observe(refVideos.current);
return () => observer.disconnect();
}, [ refVideos ]);
return (
<div className={cssStyle.videoBar}>
<div className={cssStyle.videos} ref={refVideos}>
{videos.map(videoId => (
<ErrorBoundary key={videoId}>
<VideoIdContext.Provider value={videoId}>
<VideoContainer isSpotlight={false} />
</VideoIdContext.Provider>
</ErrorBoundary>
))}
</div>
<VideoBarArrow direction={"left"} containerRef={refArrowLeft} shown={arrowLeftShown} />
<VideoBarArrow direction={"right"} containerRef={refArrowRight} shown={arrowRightShown} />
</div>
)
});
const PanelContainer = (props: { children }) => {
const refSpotlightContainer = useRef<HTMLDivElement>();
const [ spotlightDimensions, setSpotlightDimensions ] = useState<SpotlightDimensions>({ width: 1200, height: 900 });
useEffect(() => {
const resizeObserver = new ResizeObserver(entries => {
const entry = entries.last();
const newDimensions = { height: entry.contentRect.height, width: entry.contentRect.width };
if(newDimensions.width === 0) {
/* div most likely got removed or something idk... */
return;
}
if(_.isEqual(newDimensions, spotlightDimensions)) {
return;
}
setSpotlightDimensions(newDimensions);
logTrace(LogCategory.VIDEO, tr("New spotlight dimensions: %o"), entry.contentRect);
});
resizeObserver.observe(refSpotlightContainer.current);
return () => resizeObserver.disconnect();
}, []);
return (
<SpotlightDimensionsContext.Provider value={spotlightDimensions}>
<div className={cssStyle.panel}>
{props.children}
</div>
<div className={cssStyle.heightProvider}>
<div className={cssStyle.videoBar} />
<div className={cssStyle.spotlight} ref={refSpotlightContainer} />
</div>
</SpotlightDimensionsContext.Provider>
);
}
const VisibilityHandler = React.memo((props: {
children
}) => {
const events = useContext(EventContext);
const [ streamingCount, setStreamingCount ] = useState<number>(() => {
events.fire("query_videos");
return 0;
});
const [ expanded, setExpanded ] = useState<boolean>(() => {
events.fire("query_expended");
return false;
})
events.reactUse("notify_videos", event => setStreamingCount(event.videoActiveCount), []);
events.reactUse("notify_expended", event => setExpanded(event.expended), []);
return (
<div className={joinClassList(cssStyle.container, streamingCount === 0 && cssStyle.hidden, expanded && cssStyle.expended)}>
{props.children}
</div>
)
});
export const ChannelVideoRenderer = React.memo((props: { handlerId: string, events: Registry<ChannelVideoEvents> }) => {
return (
<EventContext.Provider value={props.events}>
<HandlerIdContext.Provider value={props.handlerId}>
<VisibilityHandler>
<PanelContainer>
<VideoSubscribeContextProvider>
<VideoBar />
<ExpendArrow />
<ErrorBoundary>
<Spotlight />
</ErrorBoundary>
</VideoSubscribeContextProvider>
</PanelContainer>
</VisibilityHandler>
</HandlerIdContext.Provider>
</EventContext.Provider>
);
});