diff --git a/package-lock.json b/package-lock.json index e5a883ede..c9fe1ba22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { "name": "agora-appbuilder-core", - "version": "4.1.15", + "version": "4.1.16", "lockfileVersion": 1 } diff --git a/package.json b/package.json index af39698fd..16fd94c72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agora-appbuilder-core", - "version": "4.1.15", + "version": "4.1.16", "description": "React Native template for RTE app builder", "main": "index.js", "files": [ diff --git a/template/defaultConfig.js b/template/defaultConfig.js index c7469d385..333d7af91 100644 --- a/template/defaultConfig.js +++ b/template/defaultConfig.js @@ -77,8 +77,8 @@ const DefaultConfig = { CHAT_ORG_NAME: '', CHAT_APP_NAME: '', CHAT_URL: '', - CLI_VERSION: '3.1.15', - CORE_VERSION: '4.1.15', + CLI_VERSION: '3.1.16', + CORE_VERSION: '4.1.16', DISABLE_LANDSCAPE_MODE: false, STT_AUTO_START: false, CLOUD_RECORDING_AUTO_START: false, diff --git a/template/src/components/Controls.tsx b/template/src/components/Controls.tsx index 98f1cf400..6faa6d02d 100644 --- a/template/src/components/Controls.tsx +++ b/template/src/components/Controls.tsx @@ -201,7 +201,7 @@ export const WhiteboardListener = () => { ); return () => { - LocalEventEmitter.on( + LocalEventEmitter.off( LocalEventsEnum.WHITEBOARD_ACTIVE_LOCAL, WhiteboardCallBack, ); @@ -227,8 +227,13 @@ export const WhiteboardListener = () => { }; useEffect(() => { - whiteboardActive && currentLayout !== 'pinned' && setLayout('pinned'); - }, []); + if (whiteboardActive) { + dispatch({type: 'UserPin', value: [getWhiteboardUid()]}); + if (currentLayout !== 'pinned') { + setLayout('pinned'); + } + } + }, [whiteboardActive]); const toggleWhiteboard = ( whiteboardActive: boolean, @@ -420,8 +425,13 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { }; useEffect(() => { - whiteboardActive && currentLayout !== 'pinned' && setLayout('pinned'); - }, []); + if (whiteboardActive) { + dispatch({type: 'UserPin', value: [getWhiteboardUid()]}); + if (currentLayout !== 'pinned') { + setLayout('pinned'); + } + } + }, [whiteboardActive]); const WhiteboardCallBack = ({status}) => { if (status) { diff --git a/template/src/components/whiteboard/WhiteboardCanvas.tsx b/template/src/components/whiteboard/WhiteboardCanvas.tsx index 704d13b9b..38f30bd44 100644 --- a/template/src/components/whiteboard/WhiteboardCanvas.tsx +++ b/template/src/components/whiteboard/WhiteboardCanvas.tsx @@ -16,7 +16,7 @@ import { whiteboardContext, whiteboardPaper, } from './WhiteboardConfigure'; -import {StyleSheet, View, Text} from 'react-native'; +import {StyleSheet, View, Text, ActivityIndicator} from 'react-native'; import {RoomPhase, ApplianceNames} from 'white-web-sdk'; import WhiteboardToolBox from './WhiteboardToolBox'; import WhiteboardWidget from './WhiteboardWidget'; @@ -28,7 +28,8 @@ const WhiteboardCanvas: React.FC = ({ showToolbox, }) => { const wbSurfaceRef = useRef(); - const {whiteboardRoom, boardColor} = useContext(whiteboardContext); + const {whiteboardRoom, boardColor, whiteboardRoomState} = + useContext(whiteboardContext); useEffect(function () { if (whiteboardPaper) { @@ -37,12 +38,22 @@ const WhiteboardCanvas: React.FC = ({ } return () => { - // unBindRoom(); + if (whiteboardPaper?.parentElement === wbSurfaceRef?.current) { + wbSurfaceRef?.current?.removeChild(whiteboardPaper); + } }; }, []); + const isSyncing = whiteboardRoomState === RoomPhase.Connecting; + return ( <> + {isSyncing && ( + + + Syncing whiteboard... + + )} {showToolbox && //@ts-ignore @@ -94,6 +105,22 @@ const style = StyleSheet.create({ paddingTop: 50, paddingLeft: 20, }, + syncingOverlay: { + position: 'absolute', + width: '100%', + height: '100%', + backgroundColor: 'rgba(0,0,0,0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 20, + borderRadius: 4, + }, + syncingText: { + color: '#fff', + marginTop: 12, + fontSize: 14, + }, }); export default WhiteboardCanvas; diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index 229c7525a..d6a12fc12 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -123,6 +123,9 @@ const WhiteboardConfigure: React.FC = props => { useEffect(() => { if ( whiteboardRoomState === RoomPhase.Connected && + // In livestream, don't recenter the camera locally when whiteboard gets pinned. + // Followers must inherit the broadcaster's current viewport instead. + !$config.EVENT_MODE && pinnedUid && pinnedUid == whiteboardUidRef.current ) { @@ -141,10 +144,20 @@ const WhiteboardConfigure: React.FC = props => { boardColor: boardColorRemote, whiteboardLastImageUploadPosition: whiteboardLastImageUploadPositionRemote, } = useRoomInfo(); + const shouldUseCursorAdapter = !($config.EVENT_MODE && !isHost); const {currentLayout} = useLayout(); useEffect(() => { try { + const setWritable = + typeof whiteboardRoom?.current?.setWritable === 'function' + ? whiteboardRoom.current.setWritable.bind(whiteboardRoom.current) + : undefined; + + if (!setWritable) { + return; + } + if ( whiteboardRoomState === RoomPhase.Connected && isHost && @@ -157,9 +170,9 @@ const WhiteboardConfigure: React.FC = props => { (activeUids[0] === getWhiteboardUid() || pinnedUid === getWhiteboardUid()) ) { - whiteboardRoom?.current?.setWritable(true); + setWritable(true); } else { - whiteboardRoom?.current?.setWritable(false); + setWritable(false); } } } catch (error) { @@ -170,7 +183,20 @@ const WhiteboardConfigure: React.FC = props => { error, ); } - }, [currentLayout, isHost, whiteboardRoomState, activeUids, pinnedUid]); + // activeUids[0] (the max-slot uid) is the only element checked in the condition above — + // using the full activeUids array would re-run setWritable on every participant join/leave, + // briefly stalling the SDK draw queue and causing cumulative lag for attendees. + }, [currentLayout, isHost, whiteboardRoomState, activeUids?.[0], pinnedUid]); + + useEffect(() => { + if (whiteboardRoomState === RoomPhase.Connected) { + // Netless reads the bound element size for viewport math. Refresh when layout or + // pin state changes (those affect the whiteboard container size). Participant + // count changes do not affect container size in pinned layout, so activeUids.length + // is intentionally excluded to avoid redundant refreshes on every join. + whiteboardRoom.current?.refreshViewSize?.(); + } + }, [whiteboardRoomState, currentLayout, pinnedUid]); const BoardColorChangedCallBack = ({boardColor}) => { setBoardColor(boardColor); @@ -330,11 +356,18 @@ const WhiteboardConfigure: React.FC = props => { const InitState = whiteboardRoomState; try { const index = randomIntFromInterval(0, 9); + const joinStartTs = Date.now(); setWhiteboardRoomState(RoomPhase.Connecting); + console.log('[whiteboard-lag] join:start', { + ts: joinStartTs, + isHost, + eventMode: $config.EVENT_MODE, + whiteboardUid: `${whiteboardUidRef.current}`, + }); logger.log(LogSource.Internals, 'WHITEBOARD', 'Trying to join room'); whiteWebSdkClient.current .joinRoom({ - cursorAdapter: cursorAdapter, + cursorAdapter: shouldUseCursorAdapter ? cursorAdapter : undefined, uid: `${whiteboardUidRef.current}`, uuid: room_uuid, roomToken: room_token, @@ -347,17 +380,125 @@ const WhiteboardConfigure: React.FC = props => { }, }) .then(room => { - logger.log(LogSource.Internals, 'WHITEBOARD', 'Join room successful'); + const joinSuccessTs = Date.now(); + logger.log( + LogSource.Internals, + 'WHITEBOARD', + 'Join room successful', + isHost, + $config.EVENT_MODE, + ); + console.log('[whiteboard-lag] join:success', { + ts: joinSuccessTs, + latencyMs: joinSuccessTs - joinStartTs, + isHost, + eventMode: $config.EVENT_MODE, + whiteboardUid: `${whiteboardUidRef.current}`, + }); whiteboardRoom.current = room; - cursorAdapter.setRoom(room); - whiteboardRoom.current?.setViewMode(ViewMode.Freedom); + if (shouldUseCursorAdapter) { + cursorAdapter.setRoom(room); + } + // In livestream: host who starts the whiteboard is Broadcaster (attendees follow their viewport), + // co-hosts are Followers (follow Broadcaster, auto-switch to Freedom when they interact with the board), + // If no Broadcaster exists in the room (e.g. all hosts dropped and rejoined), first host to join claims it. + // In meeting: everyone gets Freedom (independent viewport). + const noBroadcasterInRoom = + room.state.broadcastState.broadcasterId === undefined; + const viewMode = $config.EVENT_MODE + ? isHost + ? noBroadcasterInRoom + ? ViewMode.Broadcaster + : ViewMode.Follower + : ViewMode.Follower + : ViewMode.Freedom; + console.log('[whiteboard-view-mode] initial', { + isHost, + eventMode: $config.EVENT_MODE, + whiteboardUid: `${whiteboardUidRef.current}`, + broadcasterId: room.state.broadcastState.broadcasterId, + noBroadcasterInRoom, + viewMode, + }); + room.setViewMode(viewMode); + // In livestream, lock camera gestures for followers so touchpad pan/zoom + // cannot kick them out of follower mode into freedom. + room.disableCameraTransform = + $config.EVENT_MODE && viewMode === ViewMode.Follower; + console.log('[whiteboard-lag] viewmode:applied', { + ts: Date.now(), + isHost, + viewMode, + disableCameraTransform: room.disableCameraTransform, + broadcasterId: room.state.broadcastState.broadcasterId, + }); + + // In livestream, if the Broadcaster drops, the next host to detect it claims Broadcaster. + // hasSeenBroadcaster ensures we only react to an actual drop (not the transient + // undefined state during initial room sync before the Broadcaster is propagated). + if ($config.EVENT_MODE && isHost) { + let hasSeenBroadcaster = false; + room.callbacks.on('onRoomStateChanged', modifyState => { + const currentBroadcastState = room.state?.broadcastState; + if (currentBroadcastState?.broadcasterId !== undefined) { + hasSeenBroadcaster = true; + } + // broadcasterId becomes undefined only after a clean disconnect (unmount cleanup + // guarantees this), so this is a reliable signal that the Broadcaster dropped. + if ( + hasSeenBroadcaster && + currentBroadcastState?.broadcasterId === undefined + ) { + console.log('[whiteboard-view-mode] promote-to-broadcaster', { + isHost, + whiteboardUid: `${whiteboardUidRef.current}`, + }); + room.setViewMode(ViewMode.Broadcaster); + room.disableCameraTransform = false; + } + }); + } whiteboardRoom.current?.bindHtmlElement(whiteboardPaper); + console.log('[whiteboard-lag] bindHtmlElement', { + ts: Date.now(), + isHost, + viewMode, + }); + whiteboardRoom.current?.refreshViewSize?.(); + console.log('[whiteboard-lag] refreshViewSize:after-bind', { + ts: Date.now(), + isHost, + viewMode, + }); + if ($config.EVENT_MODE && viewMode === ViewMode.Follower) { + // Late followers can occasionally mount before the broadcaster viewport + // is fully applied. Re-applying follower mode after the first bind/size + // refresh nudges Netless to sync the current broadcaster view immediately. + requestAnimationFrame(() => { + console.log('[whiteboard-lag] follower-resync:start', { + ts: Date.now(), + isHost, + }); + room.refreshViewSize?.(); + room.setViewMode(ViewMode.Follower); + console.log('[whiteboard-lag] follower-resync:done', { + ts: Date.now(), + isHost, + }); + }); + } if (isHost && !isMobileUA()) { whiteboardRoom.current?.setMemberState({ strokeColor: [0, 0, 0], }); } setWhiteboardRoomState(RoomPhase.Connected); + console.log('[whiteboard-lag] roomPhase:connected', { + ts: Date.now(), + isHost, + viewMode, + totalJoinLatencyMs: Date.now() - joinStartTs, + }); }) .catch(err => { setWhiteboardRoomState(InitState); @@ -378,11 +519,13 @@ const WhiteboardConfigure: React.FC = props => { const InitState = whiteboardRoomState; try { setWhiteboardRoomState(RoomPhase.Disconnecting); - whiteboardRoom.current + const room = whiteboardRoom.current; + room ?.disconnect() .then(() => { + room?.bindHtmlElement(null); + whiteboardRoom.current = {} as Room; whiteboardUidRef.current = Date.now(); - whiteboardRoom.current?.bindHtmlElement(null); setWhiteboardRoomState(RoomPhase.Disconnected); }) .catch(err => { @@ -429,6 +572,20 @@ const WhiteboardConfigure: React.FC = props => { } }, [whiteboardActive]); + // Disconnect from whiteboard room when component unmounts (e.g. user leaves the call abruptly) + useEffect(() => { + return () => { + if ( + whiteboardRoom.current && + Object.keys(whiteboardRoom.current)?.length + ) { + whiteboardRoom.current?.bindHtmlElement(null); + whiteboardRoom.current?.disconnect(); + whiteboardRoom.current = {} as Room; + } + }; + }, []); + const getWhiteboardUid = () => { return whiteboardUidRef?.current; }; diff --git a/template/src/components/whiteboard/WhiteboardToolBox.tsx b/template/src/components/whiteboard/WhiteboardToolBox.tsx index b0d065d9a..f56e1721d 100644 --- a/template/src/components/whiteboard/WhiteboardToolBox.tsx +++ b/template/src/components/whiteboard/WhiteboardToolBox.tsx @@ -261,6 +261,8 @@ const WhiteboardToolBox = ({whiteboardRoom}) => { const [isColorContainerHovered, setColorContainerHovered] = useState(false); const [isPencilBtnHovered, setPencilBtnHovered] = useState(false); const [isPencilContainerHovered, setPencilContainerHovered] = useState(false); + const roomStateChangedRef = React.useRef(null); + const clearWhiteboardRef = React.useRef(null); const handleSelect = (applicanceName: ApplianceNames) => { if (applicanceName !== ApplianceNames.selector) { setCursorColor(ColorPickerValues[selectedColor].rgb); @@ -272,17 +274,41 @@ const WhiteboardToolBox = ({whiteboardRoom}) => { }; useEffect(() => { - whiteboardRoom?.current?.callbacks.on('onRoomStateChanged', modifyState => { + roomStateChangedRef.current = modifyState => { setRoomState({ ...whiteboardRoom?.current?.state, ...modifyState, }); - }); - LocalEventEmitter.on(LocalEventsEnum.CLEAR_WHITEBOARD, () => { + }; + clearWhiteboardRef.current = () => { whiteboardRoom.current?.cleanCurrentScene(); setShowWhiteboardClearAllPopup(false); clearAllCallback(); - }); + }; + + whiteboardRoom?.current?.callbacks?.on( + 'onRoomStateChanged', + roomStateChangedRef.current, + ); + LocalEventEmitter.on( + LocalEventsEnum.CLEAR_WHITEBOARD, + clearWhiteboardRef.current, + ); + + return () => { + if (roomStateChangedRef.current) { + whiteboardRoom?.current?.callbacks?.off( + 'onRoomStateChanged', + roomStateChangedRef.current, + ); + } + if (clearWhiteboardRef.current) { + LocalEventEmitter.off( + LocalEventsEnum.CLEAR_WHITEBOARD, + clearWhiteboardRef.current, + ); + } + }; }, []); useEffect(() => { diff --git a/template/src/components/whiteboard/WhiteboardWidget.tsx b/template/src/components/whiteboard/WhiteboardWidget.tsx index e98ef73ee..d1cf6cd86 100644 --- a/template/src/components/whiteboard/WhiteboardWidget.tsx +++ b/template/src/components/whiteboard/WhiteboardWidget.tsx @@ -84,7 +84,7 @@ const WhiteboardWidget = ({whiteboardRoom}) => { isWhiteboardOnFullScreen, } = useContext(whiteboardContext); const { - data: {whiteboard: {room_uuid} = {}}, + data: {isHost, whiteboard: {room_uuid} = {}}, } = useRoomInfo(); const {store} = useContext(StorageContext); @@ -267,7 +267,11 @@ const WhiteboardWidget = ({whiteboardRoom}) => { ) : ( <> )} - {isWebInternal() && !isMobileUA() ? ( + {/* In livestream, hide all whiteboard controls for attendees — they are Followers + and interacting with the board (zoom/pan) would break viewport sync with the host */} + {isWebInternal() && + !isMobileUA() && + !($config.EVENT_MODE && !isHost) ? ( {whiteboardRoom.current?.isWritable ? ( <> diff --git a/template/src/pages/video-call/VideoComponent.tsx b/template/src/pages/video-call/VideoComponent.tsx index 10ccbbe41..56f199017 100644 --- a/template/src/pages/video-call/VideoComponent.tsx +++ b/template/src/pages/video-call/VideoComponent.tsx @@ -12,6 +12,7 @@ import Spacer from '../../atoms/Spacer'; import {useLiveStreamDataContext} from '../../components/contexts/LiveStreamDataContext'; import {useCustomization} from 'customization-implementation'; import useMount from '../../components/useMount'; +import {whiteboardContext} from '../../components/whiteboard/WhiteboardConfigure'; const VideoComponent = () => { const {dispatch} = useContext(DispatchContext); @@ -19,6 +20,7 @@ const VideoComponent = () => { const layoutsData = useLayoutsData(); const {currentLayout, setLayout} = useLayout(); const {activeUids, pinnedUid} = useContent(); + const {whiteboardActive} = useContext(whiteboardContext); const {rtcProps} = useContext(PropsContext); const isDesktop = useIsDesktop(); const {audienceUids, hostUids} = useLiveStreamDataContext(); @@ -58,7 +60,12 @@ const VideoComponent = () => { const currentLayoutRef = useRef(currentLayout); const gridLayoutName = getGridLayoutName(); useEffect(() => { - if (activeUids && activeUids.length === 1 && !isCustomLayoutUsed) { + // When only one participant is visible, reset pinning and revert to grid layout. + // Skip this reset when whiteboard is active: in EVENT_MODE the audience's own uid is + // filtered from activeUids, so activeUids.length === 1 (host only) even while the + // whiteboard uid is being added. Without this guard the effect would clear pinnedUid + // and switch to grid, preventing the whiteboard from appearing in the max/pinned slot. + if (activeUids && activeUids.length === 1 && !isCustomLayoutUsed && !whiteboardActive) { if (pinnedUid) { dispatch({type: 'UserPin', value: [0]}); dispatch({type: 'UserSecondaryPin', value: [0]}); @@ -67,7 +74,7 @@ const VideoComponent = () => { setLayout(gridLayoutName); } } - }, [activeUids, isCustomLayoutUsed]); + }, [activeUids, isCustomLayoutUsed, whiteboardActive]); useEffect(() => { currentLayoutRef.current = currentLayout;