diff --git a/src/feature/video/components/avatar-more.tsx b/src/feature/video/components/avatar-more.tsx index b08c6c1..ddff3c6 100644 --- a/src/feature/video/components/avatar-more.tsx +++ b/src/feature/video/components/avatar-more.tsx @@ -1,23 +1,30 @@ import { useState, useCallback, useContext } from 'react'; import { Slider, Dropdown, Button } from 'antd'; -import { AudioMutedOutlined, CheckOutlined, MoreOutlined } from '@ant-design/icons'; +import { CheckOutlined, MoreOutlined } from '@ant-design/icons'; import classNames from 'classnames'; import AvatarActionContext from '../context/avatar-context'; import ZoomContext from '../../../context/zoom-context'; import MediaContext from '../../../context/media-context'; import { getAntdDropdownMenu, getAntdItem } from './video-footer-utils'; +import { useSpotlightVideo } from '../hooks/useSpotlightVideo'; interface AvatarMoreProps { className?: string; userId: number; isHover: boolean; } +const isUseVideoPlayer = new URLSearchParams(location.search).get('useVideoPlayer') === '1'; const AvatarMore = (props: AvatarMoreProps) => { const { userId, isHover } = props; const { avatarActionState, dispatch } = useContext(AvatarActionContext); const { mediaStream } = useContext(MediaContext); + const zmClient = useContext(ZoomContext); const [isDropdownVisible, setIsDropdownVisbile] = useState(false); const [isControllingRemoteCamera, setIsControllingRemoteCamera] = useState(false); + useSpotlightVideo(zmClient, mediaStream, (participants) => { + dispatch({ type: 'set-spotlighted-videos', payload: participants }); + }); const actionItem = avatarActionState[`${userId}`]; + const { spotlightedUserList } = avatarActionState; const menu = []; if (actionItem) { if (actionItem.localVolumeAdjust.enabled) { @@ -38,6 +45,27 @@ const AvatarMore = (props: AvatarMoreProps) => { ) ); } + if (isUseVideoPlayer) { + const currentUserId = zmClient.getCurrentUserInfo()?.userId; + const isHostOrManager = zmClient.isHost() || zmClient.isManager(); + if ( + currentUserId === userId && + spotlightedUserList?.find((user) => user.userId === currentUserId) && + spotlightedUserList.length === 1 + ) { + menu.push(getAntdItem('Remove spotlight', 'removeSpotlight')); + } else if (isHostOrManager) { + if (spotlightedUserList && spotlightedUserList.findIndex((user) => user.userId === userId) > -1) { + menu.push(getAntdItem('Remove spotlight', 'removeSpotlight')); + } else { + const user = zmClient.getUser(userId); + if (user?.bVideoOn) { + menu.push(getAntdItem('Add spotlight', 'addSpotlight')); + menu.push(getAntdItem('Replace spotlight', 'replaceSpotlight')); + } + } + } + } const onSliderChange = useCallback( (value: any) => { mediaStream?.adjustUserAudioVolumeLocally(userId, value); @@ -48,6 +76,7 @@ const AvatarMore = (props: AvatarMoreProps) => { const onDropDownVisibleChange = useCallback((visible: boolean) => { setIsDropdownVisbile(visible); }, []); + const onMenuItemClick = useCallback( ({ key }: { key: string }) => { if (key === 'volume') { @@ -63,6 +92,12 @@ const AvatarMore = (props: AvatarMoreProps) => { setIsControllingRemoteCamera(!isControllingRemoteCamera); } else if (key === 'subscribeVideoQuality') { dispatch({ type: 'toggle-video-resolution-adjust', payload: { userId } }); + } else if (key === 'removeSpotlight') { + mediaStream?.removeSpotlightedVideo(userId); + } else if (key === 'addSpotlight') { + mediaStream?.spotlightVideo(userId, false); + } else if (key === 'replaceSpotlight') { + mediaStream?.spotlightVideo(userId, true); } setIsDropdownVisbile(false); }, diff --git a/src/feature/video/components/call-out-modal.tsx b/src/feature/video/components/call-out-modal.tsx index 2cc3965..6a867f1 100644 --- a/src/feature/video/components/call-out-modal.tsx +++ b/src/feature/video/components/call-out-modal.tsx @@ -1,5 +1,7 @@ import { Modal, Select, Input, Checkbox, Form } from 'antd'; +import { useContext } from 'react'; import classNames from 'classnames'; +import ZoomContext from '../../../context/zoom-context'; import './call-out-modal.scss'; interface CallOutModalProps { visible: boolean; @@ -13,6 +15,7 @@ interface CallOutModalProps { const CallOutModal = (props: CallOutModalProps) => { const { visible, phoneCountryList, phoneCallStatus, onPhoneCallClick, onPhoneCallCancel, setVisible } = props; const [form] = Form.useForm(); + const zmClient = useContext(ZoomContext); return ( { } = data; const [, code] = countryCode.split('&&'); if (callme) { - onPhoneCallClick?.(code, phoneNumber, '', { callMe: true }); + onPhoneCallClick?.(code, phoneNumber, zmClient.getCurrentUserInfo().displayName, { callMe: true }); } else { onPhoneCallClick?.(code, phoneNumber, name, { callMe: false, diff --git a/src/feature/video/context/avatar-context.ts b/src/feature/video/context/avatar-context.ts index de53731..be7f2ea 100644 --- a/src/feature/video/context/avatar-context.ts +++ b/src/feature/video/context/avatar-context.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { Participant } from '../../../index-types'; interface FeatureSwitch { toggled: boolean; @@ -14,5 +15,6 @@ interface AvatarSwitch { } export type AvatarContext = { isControllingRemoteCamera?: boolean; + spotlightedUserList?: Participant[]; } & AvatarSwitch; export default React.createContext<{ avatarActionState: AvatarContext; dispatch: React.Dispatch }>(null as any); diff --git a/src/feature/video/hooks/useAvatarAction.ts b/src/feature/video/hooks/useAvatarAction.ts index 0552bb1..81a774a 100644 --- a/src/feature/video/hooks/useAvatarAction.ts +++ b/src/feature/video/hooks/useAvatarAction.ts @@ -69,6 +69,11 @@ const avatarActionReducer = produce((draft, action) => { draft.isControllingRemoteCamera = payload; break; } + case 'set-spotlighted-videos': { + const { payload } = action; + draft.spotlightedUserList = payload; + break; + } case 'toggle-video-resolution-adjust': { const { payload: { userId } diff --git a/src/feature/video/hooks/useRemoteControl.ts b/src/feature/video/hooks/useRemoteControl.tsx similarity index 86% rename from src/feature/video/hooks/useRemoteControl.ts rename to src/feature/video/hooks/useRemoteControl.tsx index f4099ed..f4f15d3 100644 --- a/src/feature/video/hooks/useRemoteControl.ts +++ b/src/feature/video/hooks/useRemoteControl.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { ZoomClient, MediaStream } from '../../../index-types'; import { ApprovedState, RemoteControlAppStatus, RemoteControlSessionStatus } from '@zoom/videosdk'; -import { message, Modal } from 'antd'; +import { message, Modal, Checkbox } from 'antd'; export function useRemoteControl( zmClient: ZoomClient, mediaStream: MediaStream | null, @@ -12,6 +12,7 @@ export function useRemoteControl( const [controllingUser, setControllingUser] = useState<{ userId: number; displayName: string } | null>(null); const isDownloadAppRef = useRef(false); const launchModalRef = useRef(null); + const runAsAdminRef = useRef(null); const onInControllingChange = useCallback((payload: any) => { const { isControlling } = payload; setIsControllingUser(isControlling); @@ -40,14 +41,26 @@ export function useRemoteControl( } Modal.confirm({ title: `${displayName} is requesting remote control of your screen`, - content: isSharingEntireScreen - ? 'In order to control your screen, you must install Zoom Remote Control app with a size of 4 MB to continue. You can regain control at any time by clicking on your screen.' - : 'To be controlled, you must share your entire screen instead of a tab or window. After sharing the entire screen, you’ll be requested again.', + content: isSharingEntireScreen ? ( + <> +
+ In order to control your screen, you must install Zoom Remote Control app with a size of 4 MB to continue. + You can regain control at any time by clicking on your screen. +
+ {navigator.platform?.startsWith('Win') && ( +
+ Enable the RemoteControl App to control of all applications +
+ )} + + ) : ( + 'To be controlled, you must share your entire screen instead of a tab or window. After sharing the entire screen, you’ll be requested again.' + ), okText: isSharingEntireScreen ? 'Approve' : 'Select Entire Screen', cancelText: 'Decline', onOk: async () => { if (isSharingEntireScreen) { - mediaStream?.approveRemoteControl(userId); + mediaStream?.approveRemoteControl(userId, !!runAsAdminRef.current?.input?.checked); } else { await mediaStream?.stopShareScreen(); if (selfShareView) { diff --git a/src/feature/video/hooks/useShare.ts b/src/feature/video/hooks/useShare.ts index 84de92a..3caf62c 100644 --- a/src/feature/video/hooks/useShare.ts +++ b/src/feature/video/hooks/useShare.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect, MutableRefObject } from 'react'; import { useMount, usePrevious, useUnmount } from '../../../hooks'; import { ZoomClient, MediaStream, Participant } from '../../../index-types'; +import { Modal } from 'antd'; export function useShare( zmClient: ZoomClient, mediaStream: MediaStream | null, @@ -49,18 +50,30 @@ export function useShare( const onShareContentChange = useCallback((payload: any) => { setActiveSharingId(payload.userId); }, []); + const onActiveMediaFailed = useCallback(()=>{ + Modal.error({ + title:'Active media failed', + content:'Something went wrong. An unexpected interruption in media capture or insufficient memory occurred. Try refreshing the page to recover.', + okText:'Refresh', + onOk:()=>{ + window.location.reload(); + } + }) + },[]) useEffect(() => { zmClient.on('active-share-change', onActiveShareChange); zmClient.on('share-content-dimension-change', onSharedContentDimensionChange); zmClient.on('user-updated', onCurrentUserUpdate); zmClient.on('peer-share-state-change', onPeerShareChange); zmClient.on('share-content-change', onShareContentChange); + zmClient.on('active-media-failed',onActiveMediaFailed) return () => { zmClient.off('active-share-change', onActiveShareChange); zmClient.off('share-content-dimension-change', onSharedContentDimensionChange); zmClient.off('user-updated', onCurrentUserUpdate); zmClient.off('peer-share-state-change', onPeerShareChange); zmClient.off('share-content-change', onShareContentChange); + zmClient.off('active-media-failed',onActiveMediaFailed) }; }, [ zmClient, @@ -68,7 +81,8 @@ export function useShare( onSharedContentDimensionChange, onCurrentUserUpdate, onPeerShareChange, - onShareContentChange + onShareContentChange, + onActiveMediaFailed ]); const previousIsRecieveSharing = usePrevious(isRecieveSharing); useEffect(() => { diff --git a/src/feature/video/hooks/useSpotlightVideo.ts b/src/feature/video/hooks/useSpotlightVideo.ts new file mode 100644 index 0000000..7f75d75 --- /dev/null +++ b/src/feature/video/hooks/useSpotlightVideo.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { ZoomClient, Participant, MediaStream } from '../../../index-types'; +import { useMount } from '../../../hooks'; +export function useSpotlightVideo( + zmClient: ZoomClient, + mediaStream: MediaStream | null, + fn?: (participants: Participant[], updatedUserIDs?: number[]) => void +) { + const fnRef = useRef(fn); + fnRef.current = fn; + const callback = useCallback( + (updatedParticipants?: number[]) => { + const participants = mediaStream?.getSpotlightedUserList() ?? []; + fnRef.current?.(participants, updatedParticipants); + }, + [mediaStream] + ); + useEffect(() => { + zmClient.on('video-spotlight-change', callback); + return () => { + zmClient.off('video-spotlight-change', callback); + }; + }, [zmClient, callback]); + useMount(() => { + callback(); + }); +} diff --git a/src/feature/video/video-attach.tsx b/src/feature/video/video-attach.tsx index 3c7860b..0d8eb4c 100644 --- a/src/feature/video/video-attach.tsx +++ b/src/feature/video/video-attach.tsx @@ -6,7 +6,8 @@ import React, { DOMAttributes, HTMLAttributes, DetailedHTMLProps, - useCallback + useCallback, + useMemo } from 'react'; import classnames from 'classnames'; import _ from 'lodash'; @@ -27,6 +28,7 @@ import { Participant } from '../../index-types'; import { useOrientation, usePrevious } from '../../hooks'; import { useVideoAspect } from './hooks/useVideoAspectRatio'; import { Radio } from 'antd'; +import { useSpotlightVideo } from './hooks/useSpotlightVideo'; type CustomElement = Partial & { children: any }>; declare global { @@ -39,12 +41,18 @@ declare global { } } } + +function maxVideoCellWidth(orientation: string, totalParticipants: number, spotlighted?: boolean[]) { + return orientation === 'portrait' ? 'none' : `calc(100vw/${Math.min(totalParticipants, spotlighted ? 2 : 4)})`; +} + const VideoContainer: React.FunctionComponent = (props) => { const zmClient = useContext(ZoomContext); const { mediaStream } = useContext(ZoomMediaContext); const shareViewRef = useRef<{ selfShareRef: HTMLCanvasElement | HTMLVideoElement | null }>(null); const videoPlayerListRef = useRef>({}); const [isRecieveSharing, setIsRecieveSharing] = useState(false); + const [spotlightUsers, setSpotlightUsers] = useState(); const [participants, setParticipants] = useState(zmClient.getAllUser()); const [subscribers, setSubscribers] = useState([]); const activeVideo = useActiveVideo(zmClient); @@ -59,7 +67,7 @@ const VideoContainer: React.FunctionComponent = (props) => { label: '90P', value: VideoQuality.Video_90P } ]; const orientation = useOrientation(); - const maxVideoCellWidth = orientation === 'portrait' ? 'none' : `calc(100vw/${Math.min(participants.length, 4)})`; + useParticipantsChange(zmClient, (participants) => { let pageParticipants: Participant[] = []; if (participants.length > 0) { @@ -78,6 +86,9 @@ const VideoContainer: React.FunctionComponent = (props) => setParticipants(pageParticipants); setSubscribers(pageParticipants.filter((user) => user.bVideoOn).map((u) => u.userId)); }); + useSpotlightVideo(zmClient, mediaStream, (p) => { + setSpotlightUsers(p); + }); const setVideoPlayerRef = (userId: number, element: VideoPlayer | null) => { if (element) { videoPlayerListRef.current[`${userId}`] = element; @@ -107,6 +118,19 @@ const VideoContainer: React.FunctionComponent = (props) => }, [videoPlayerListRef, mediaStream] ); + const sortedParticipants = useMemo(() => { + if (spotlightUsers?.length) { + const splightUserIds = spotlightUsers.map((u) => u.userId); + return participants + .filter((user) => !splightUserIds.includes(user.userId)) + .concat( + participants + .filter((user) => splightUserIds.includes(user.userId)) + .map((user) => ({ spotlighted: true, ...user })) + ); + } + return participants; + }, [participants, spotlightUsers]); return (
@@ -119,19 +143,22 @@ const VideoContainer: React.FunctionComponent = (props) =>
    - {participants.map((user) => { + {sortedParticipants.map((user) => { + const maxWidth = maxVideoCellWidth(orientation, participants.length, (user as any).spotlighted); return (
    {avatarActionState?.avatarActionState[user?.userId]?.videoResolutionAdjust?.toggled && ( diff --git a/src/feature/video/video.scss b/src/feature/video/video.scss index 63e0057..52db0cb 100644 --- a/src/feature/video/video.scss +++ b/src/feature/video/video.scss @@ -16,7 +16,7 @@ align-items: center; } &.video-container-in-sharing { - width: 264px; + width: min(20vw, 264px); flex-shrink: 0; } &.single-video-container { @@ -74,6 +74,9 @@ position: relative; flex: 1; margin: 12px; + &.video-cell-spotlight{ + min-width: 50vw; + } .change-video-resolution { position: absolute; }