UNPKG

@100mslive/roomkit-react

Version:

![Banner](https://github.com/100mslive/web-sdks/blob/06c65259912db6ccd8617f2ecb6fef51429251ec/prebuilt-banner.png)

748 lines (722 loc) 28.7 kB
import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useFullscreen, useMedia, usePrevious, useToggle } from 'react-use'; import { HLSPlaybackState, HMSHLSPlayer, HMSHLSPlayerEvents } from '@100mslive/hls-player'; import screenfull from 'screenfull'; import { match, P } from 'ts-pattern'; import { HLSPlaylistType, HMSNotificationTypes, selectAppData, selectHLSState, selectPeerNameByID, selectPollByID, useHMSActions, useHMSNotifications, useHMSStore, useHMSVanillaStore, } from '@100mslive/react-sdk'; import { ColoredHandIcon, GoLiveIcon } from '@100mslive/react-icons'; import { ChatToggle } from '../components/Footer/ChatToggle'; import { HlsStatsOverlay } from '../components/HlsStatsOverlay'; import { HMSVideoPlayer } from '../components/HMSVideo'; import { FullScreenButton } from '../components/HMSVideo/FullscreenButton'; import { HLSAutoplayBlockedPrompt } from '../components/HMSVideo/HLSAutoplayBlockedPrompt'; import { HLSCaptionSelector } from '../components/HMSVideo/HLSCaptionSelector'; import { HLSQualitySelector } from '../components/HMSVideo/HLSQualitySelector'; import { HLSViewTitle } from '../components/HMSVideo/MwebHLSViewTitle'; import { HMSPlayerContext } from '../components/HMSVideo/PlayerContext'; import { LeaveRoom } from '../components/Leave/LeaveRoom'; import { ToastManager } from '../components/Toast/ToastManager'; import { Button } from '../../Button'; import { IconButton } from '../../IconButton'; import { Box, Flex } from '../../Layout'; import { Loading } from '../../Loading'; import { Text } from '../../Text'; import { config, theme, useTheme } from '../../Theme'; import { Tooltip } from '../../Tooltip'; import { WaitingView } from './WaitingView'; import { useSidepaneToggle } from '../components/AppData/useSidepane'; import { useRoomLayoutConferencingScreen } from '../provider/roomLayoutProvider/hooks/useRoomLayoutScreen'; import { useIsLandscape, useKeyboardHandler } from '../common/hooks'; import { APP_DATA, EMOJI_REACTION_TYPE, POLL_STATE, POLL_VIEWS, SIDE_PANE_OPTIONS } from '../common/constants'; let hlsPlayer; const toastMap = {}; const ToggleChat = ({ isFullScreen = false }) => { const { elements } = useRoomLayoutConferencingScreen(); const sidepane = useHMSStore(selectAppData(APP_DATA.sidePane)); const showChat = !!elements?.chat; const isMobile = useMedia(config.media.md); const hmsActions = useHMSActions(); useEffect(() => { match({ sidepane, isMobile, showChat, isFullScreen }) .with({ isFullScreen: true }, () => { hmsActions.setAppData(APP_DATA.sidePane, ''); }) .with({ isMobile: true, showChat: true, sidepane: P.when(value => !value) }, () => { hmsActions.setAppData(APP_DATA.sidePane, SIDE_PANE_OPTIONS.CHAT); }) .with({ showChat: false, isMobile: true, sidepane: SIDE_PANE_OPTIONS.CHAT }, () => { hmsActions.setAppData(APP_DATA.sidePane, ''); }) .otherwise(() => { //do nothing }); }, [sidepane, isMobile, showChat, hmsActions, isFullScreen]); return null; }; const HLSView = () => { const videoRef = useRef(null); const hlsViewRef = useRef(); const { elements } = useRoomLayoutConferencingScreen(); const hlsState = useHMSStore(selectHLSState); const enablHlsStats = useHMSStore(selectAppData(APP_DATA.hlsStats)); const notification = useHMSNotifications(HMSNotificationTypes.POLL_STOPPED); const hmsActions = useHMSActions(); const { themeType } = useTheme(); const [streamEnded, setStreamEnded] = useState(false); let [hlsStatsState, setHlsStatsState] = useState(null); const hlsUrl = hlsState.variants[0]?.url; const [availableLayers, setAvailableLayers] = useState([]); const [isVideoLive, setIsVideoLive] = useState(true); const [isCaptionEnabled, setIsCaptionEnabled] = useState(true); const [hasCaptions, setHasCaptions] = useState(false); const [currentSelectedQuality, setCurrentSelectedQuality] = useState(null); const [isHlsAutoplayBlocked, setIsHlsAutoplayBlocked] = useState(false); const [hoverControlsVisible, setHoverControlsVisible] = useState({ seekForward: false, pausePlay: false, seekBackward: false, }); const [isPaused, setIsPaused] = useState(false); const [show, toggle] = useToggle(false); const lastHlsUrl = usePrevious(hlsUrl); const vanillaStore = useHMSVanillaStore(); const [controlsVisible, setControlsVisible] = useState(true); const [isUserSelectedAuto, setIsUserSelectedAuto] = useState(true); const [qualityDropDownOpen, setQualityDropDownOpen] = useState(false); const controlsRef = useRef(null); const controlsTimerRef = useRef(); const [seekProgress, setSeekProgress] = useState(false); const isFullScreenSupported = screenfull.isEnabled; const toggleChat = useSidepaneToggle(SIDE_PANE_OPTIONS.CHAT); const showChat = !!elements?.chat; const isMobile = useMedia(config.media.md); const isLandscape = useIsLandscape(); const isFullScreen = useFullscreen(hlsViewRef, show, { onClose: () => toggle(false), }); const [showLoader, setShowLoader] = useState(false); // FIXME: move this logic to player controller in next release useEffect(() => { /** * @type {HTMLVideoElement} videoEl */ const videoEl = videoRef.current; const showLoader = () => setShowLoader(true); const hideLoader = () => setShowLoader(false); videoEl?.addEventListener('playing', hideLoader); videoEl?.addEventListener('waiting', showLoader); return () => { videoEl?.removeEventListener('playing', hideLoader); videoEl?.removeEventListener('waiting', showLoader); }; }, []); useEffect(() => { if (streamEnded && lastHlsUrl !== hlsUrl) { setStreamEnded(false); } }, [hlsUrl, streamEnded, lastHlsUrl]); useEffect(() => { if (!notification) return; const toastID = toastMap?.[notification.data.id]; if (toastID) { ToastManager.removeToast(toastMap[notification.data.id]); delete toastMap[notification.data.id]; } }, [notification]); useEffect(() => { const videoElem = videoRef.current; const setStreamEndedCallback = () => { setStreamEnded(true); // no point keeping the callback attached once the streaming is ended videoElem?.removeEventListener('ended', setStreamEndedCallback); }; videoElem?.addEventListener('ended', setStreamEndedCallback); return () => { videoElem?.removeEventListener('ended', setStreamEndedCallback); }; }, [hlsUrl]); const handleQuality = useCallback( quality => { if (hlsPlayer) { setIsUserSelectedAuto(quality.height?.toString().toLowerCase() === 'auto'); hlsPlayer?.setLayer(quality); } }, [availableLayers], //eslint-disable-line ); /** * initialize HMSHLSPlayer and add event listeners. */ useEffect(() => { let videoEl = videoRef.current; const manifestLoadedHandler = ({ layers }) => { setAvailableLayers(layers); setHasCaptions(hlsPlayer?.hasCaptions()); }; const layerUpdatedHandler = ({ layer }) => { setCurrentSelectedQuality(layer); }; const metadataLoadedHandler = ({ payload, ...rest }) => { const parsePayload = str => { try { return JSON.parse(str); } catch (e) { return str; } }; const duration = rest.duration; const parsedPayload = parsePayload(payload); // check if poll happened if (parsedPayload.startsWith('poll:')) { const pollId = parsedPayload.substr(parsedPayload.indexOf(':') + 1); const poll = vanillaStore.getState(selectPollByID(pollId)); const pollStartedBy = vanillaStore.getState(selectPeerNameByID(poll.startedBy)) || 'Participant'; // launch poll if (!toastMap[pollId]) { const toastID = ToastManager.addToast({ title: `${pollStartedBy} started a ${poll.type}: ${poll.title}`, action: ( <Button onClick={() => { hmsActions.setAppData(APP_DATA.pollState, { [POLL_STATE.pollInView]: pollId, [POLL_STATE.view]: POLL_VIEWS.VOTE, }); hmsActions.setAppData(APP_DATA.sidePane, SIDE_PANE_OPTIONS.POLLS); }} variant="standard" css={{ backgroundColor: '$surface_bright', fontWeight: '$semiBold', color: '$on_surface_high', p: '$xs $md', }} > {poll.type === 'quiz' ? 'Answer' : 'Vote'} </Button> ), duration: Infinity, }); toastMap[pollId] = toastID; } return; } switch (parsedPayload.type) { case EMOJI_REACTION_TYPE: window.showFlyingEmoji?.({ emojiId: parsedPayload?.emojiId, senderId: parsedPayload?.senderId }); break; default: { const toast = { title: `Payload from timed Metadata ${parsedPayload}`, duration: duration || 3000, }; console.debug('Added toast ', JSON.stringify(toast)); ToastManager.addToast(toast); break; } } }; const handleError = data => { console.error('[HLSView] error in hls', `${data}`); }; const handleNoLongerLive = ({ isLive }) => { setIsVideoLive(isLive); }; const playbackEventHandler = data => { setIsPaused(data.state === HLSPlaybackState.paused); setHoverControlsVisible({ ...hoverControlsVisible, pausePlay: true, }); setTimeout(() => { setHoverControlsVisible({ ...hoverControlsVisible, pausePlay: false, }); }, 2000); }; const captionEnabledEventHandler = isCaptionEnabled => { setIsCaptionEnabled(isCaptionEnabled); }; const handleAutoplayBlock = data => setIsHlsAutoplayBlocked(!!data); if (videoEl && hlsUrl) { hlsPlayer = new HMSHLSPlayer(hlsUrl, videoEl); hlsPlayer.on(HMSHLSPlayerEvents.SEEK_POS_BEHIND_LIVE_EDGE, handleNoLongerLive); hlsPlayer.on(HMSHLSPlayerEvents.TIMED_METADATA_LOADED, metadataLoadedHandler); hlsPlayer.on(HMSHLSPlayerEvents.ERROR, handleError); hlsPlayer.on(HMSHLSPlayerEvents.PLAYBACK_STATE, playbackEventHandler); hlsPlayer.on(HMSHLSPlayerEvents.CAPTION_ENABLED, captionEnabledEventHandler); hlsPlayer.on(HMSHLSPlayerEvents.AUTOPLAY_BLOCKED, handleAutoplayBlock); hlsPlayer.on(HMSHLSPlayerEvents.MANIFEST_LOADED, manifestLoadedHandler); hlsPlayer.on(HMSHLSPlayerEvents.LAYER_UPDATED, layerUpdatedHandler); return () => { hlsPlayer.off(HMSHLSPlayerEvents.SEEK_POS_BEHIND_LIVE_EDGE, handleNoLongerLive); hlsPlayer.off(HMSHLSPlayerEvents.ERROR, handleError); hlsPlayer.off(HMSHLSPlayerEvents.TIMED_METADATA_LOADED, metadataLoadedHandler); hlsPlayer.off(HMSHLSPlayerEvents.PLAYBACK_STATE, playbackEventHandler); hlsPlayer.off(HMSHLSPlayerEvents.CAPTION_ENABLED, captionEnabledEventHandler); hlsPlayer.off(HMSHLSPlayerEvents.AUTOPLAY_BLOCKED, handleAutoplayBlock); hlsPlayer.off(HMSHLSPlayerEvents.MANIFEST_LOADED, manifestLoadedHandler); hlsPlayer.off(HMSHLSPlayerEvents.LAYER_UPDATED, layerUpdatedHandler); hlsPlayer.reset(); }; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [hlsUrl, vanillaStore, hmsActions]); /** * initialize and subscribe to hlsState */ useEffect(() => { const onHLSStats = state => setHlsStatsState(state); if (enablHlsStats) { hlsPlayer?.on(HMSHLSPlayerEvents.STATS, onHLSStats); } else { hlsPlayer?.off(HMSHLSPlayerEvents.STATS, onHLSStats); } return () => { hlsPlayer?.off(HMSHLSPlayerEvents.STATS, onHLSStats); }; }, [enablHlsStats]); const unblockAutoPlay = async () => { try { await hlsPlayer.play(); setIsHlsAutoplayBlocked(false); } catch (error) { console.error('Tried to unblock Autoplay failed with', error.message); } }; const sfnOverlayClose = () => { hmsActions.setAppData(APP_DATA.hlsStats, !enablHlsStats); }; useEffect(() => { if (controlsVisible && isFullScreen && !qualityDropDownOpen) { if (controlsTimerRef.current) { clearTimeout(controlsTimerRef.current); } } if (!isFullScreen && controlsTimerRef.current) { clearTimeout(controlsTimerRef.current); } controlsTimerRef.current = setTimeout(() => { if (!seekProgress) { setControlsVisible(false); } }, 5000); return () => { if (controlsTimerRef.current) { clearTimeout(controlsTimerRef.current); } }; }, [controlsVisible, isFullScreen, seekProgress, qualityDropDownOpen]); const onSeekTo = useCallback( seek => { match({ isLandscape, isMobile, seek }) .with({ seek: -10, isMobile: false, isLandscape: false }, () => { setHoverControlsVisible({ ...hoverControlsVisible, seekBackward: true }); setTimeout(() => { setHoverControlsVisible({ ...hoverControlsVisible, seekBackward: false, }); }, 1000); }) .with({ seek: 10, isMobile: false, isLandscape: false }, () => { setHoverControlsVisible({ ...hoverControlsVisible, seekForward: true }); setTimeout(() => { setHoverControlsVisible({ ...hoverControlsVisible, seekForward: false, }); }, 1000); }) .otherwise(() => null); hlsPlayer?.seekTo(videoRef.current?.currentTime + seek); }, [hoverControlsVisible, isLandscape, isMobile], ); const onDoubleClickHandler = useCallback( event => { if (!(isMobile || isLandscape) || hlsState?.variants[0]?.playlist_type !== HLSPlaylistType.DVR) { return; } const sidePercentage = (event.screenX * 100) / event.target.clientWidth; // there is space for pause/unpause button if (sidePercentage < 45) { setHoverControlsVisible({ ...hoverControlsVisible, seekBackward: true, }); onSeekTo(-10); } else { setHoverControlsVisible({ ...hoverControlsVisible, seekForward: true, }); onSeekTo(10); } setTimeout(() => { setHoverControlsVisible({ ...hoverControlsVisible, seekForward: false, seekBackward: false, }); }, 1000); }, [hlsState?.variants, hoverControlsVisible, isLandscape, isMobile, onSeekTo], ); const onClickHandler = useCallback(async () => { match({ isMobile, isLandscape, playlist_type: hlsState?.variants[0]?.playlist_type }) .with({ playlist_type: HLSPlaylistType.DVR, isMobile: false, isLandscape: false }, async () => { if (isPaused) { await hlsPlayer?.play(); } else { hlsPlayer?.pause(); } }) .when( ({ isMobile, isLandscape }) => isMobile || isLandscape, () => { setControlsVisible(value => !value); if (controlsTimerRef.current) { clearTimeout(controlsTimerRef.current); } }, ) .otherwise(() => null); }, [hlsState?.variants, isLandscape, isMobile, isPaused]); const onHoverHandler = useCallback( event => { event.preventDefault(); if (isMobile || isLandscape) { return; } if (event.type === 'mouseenter' || qualityDropDownOpen) { setControlsVisible(true); return; } if (event.type === 'mouseleave' && !seekProgress) { setControlsVisible(false); } else if (!controlsVisible && event.type === 'mousemove') { setControlsVisible(true); if (controlsTimerRef.current) { clearTimeout(controlsTimerRef.current); } } }, [controlsVisible, isLandscape, isMobile, qualityDropDownOpen, seekProgress], ); const keyHandler = useKeyboardHandler(isPaused, hlsPlayer); if (!hlsUrl || streamEnded) { return ( <> <ToggleChat /> {hlsViewRef.current && (isMobile || isLandscape) && ( <Box css={{ position: 'fixed', left: '$4', top: '$4', zIndex: 11 }}> <LeaveRoom screenType="hls_live_streaming" container={hlsViewRef.current} /> </Box> )} <Flex key="hls-viewer" id={`hls-viewer-${themeType}`} ref={hlsViewRef} direction={isMobile || isLandscape ? 'column' : 'row'} justify="center" css={{ flex: isLandscape ? '2 1 0' : '1 1 0', }} > {streamEnded ? ( <WaitingView icon={<ColoredHandIcon height={56} width={56} />} title="Stream has ended" subtitle="Have a nice day!" /> ) : ( <WaitingView icon={<GoLiveIcon height={56} width={56} style={{ color: 'white' }} />} title="Stream yet to start" subtitle="Sit back and relax" /> )} </Flex> </> ); } return ( <Flex key="hls-viewer" id={`hls-viewer-${themeType}`} ref={hlsViewRef} direction={isMobile || isLandscape ? 'column' : 'row'} justify="center" css={{ flex: isLandscape ? '2 1 0' : '1 1 0', transition: 'all 0.3s ease-in-out', }} > {hlsViewRef.current && (isMobile || isLandscape) && ( <Box css={{ position: 'fixed', left: '$4', top: '$4', zIndex: 11 }}> <LeaveRoom screenType="hls_live_streaming" container={hlsViewRef.current} /> </Box> )} <HMSPlayerContext.Provider value={{ hlsPlayer }}> {hlsStatsState?.url && enablHlsStats && !(isMobile || isLandscape) ? ( <HlsStatsOverlay hlsStatsState={hlsStatsState} onClose={sfnOverlayClose} /> ) : null} <Flex id="hls-player-container" align="center" justify="center" css={{ size: '100%', margin: '0 auto', '@md': { height: 'auto', }, outline: 'none', }} onKeyDown={async event => { if (hlsState?.variants[0]?.playlist_type === HLSPlaylistType.DVR) { await keyHandler(event); } }} tabIndex="0" > {!(isMobile || isLandscape) && ( <HLSAutoplayBlockedPrompt open={isHlsAutoplayBlocked} unblockAutoPlay={unblockAutoPlay} /> )} {showLoader && ( <Flex align="center" justify="center" css={{ position: 'absolute', }} > <Loading width={72} height={72} /> </Flex> )} <HMSVideoPlayer.Root ref={videoRef} onMouseEnter={onHoverHandler} onMouseMove={onHoverHandler} onMouseLeave={onHoverHandler} onClick={onClickHandler} isFullScreen={isFullScreen} onDoubleClick={e => { onDoubleClickHandler(e); }} > <> {!(isMobile || isLandscape) && ( <Flex align="center" justify="between" css={{ position: 'absolute', bg: `${ hoverControlsVisible.pausePlay || hoverControlsVisible.seekBackward || hoverControlsVisible.seekForward ? '#00000066' : '' }`, display: 'inline-flex', gap: '$2', zIndex: 1, size: '100%', }} > {!showLoader && hlsState?.variants[0]?.playlist_type === HLSPlaylistType.DVR && ( <HMSVideoPlayer.PlayPauseSeekControls.Overlay isPaused={isPaused} showControls={controlsVisible} hoverControlsVisible={hoverControlsVisible} /> )} </Flex> )} {isMobile || isLandscape ? ( <> {!showLoader && hlsState?.variants[0]?.playlist_type === HLSPlaylistType.DVR && ( <HMSVideoPlayer.PlayPauseSeekControls.Overlay isPaused={isPaused} showControls={controlsVisible} hoverControlsVisible={hoverControlsVisible} /> )} <Flex ref={controlsRef} direction="column" justify="start" align="start" css={{ position: 'absolute', top: '0', left: '0', width: '100%', flexShrink: 0, zIndex: 1, visibility: controlsVisible ? `` : `hidden`, opacity: controlsVisible ? `1` : '0', }} > <HMSVideoPlayer.Controls.Root css={{ p: '$4 $8', }} onClick={e => e.stopPropagation()} > <HMSVideoPlayer.Controls.Right> {(isLandscape || (isMobile && isFullScreen)) && showChat && ( <ChatToggle onClick={() => { if (isFullScreen) { toggle(); } // toggle and closing fullscreen takes few ms, to make it synced we are calling settimeout setTimeout(() => { toggleChat(); }, 0); }} /> )} {hasCaptions && !isHlsAutoplayBlocked && <HLSCaptionSelector isEnabled={isCaptionEnabled} />} {hlsViewRef.current && availableLayers.length > 0 && !isHlsAutoplayBlocked ? ( <HLSQualitySelector layers={availableLayers} onOpenChange={setQualityDropDownOpen} open={qualityDropDownOpen} selection={currentSelectedQuality} onQualityChange={handleQuality} isAuto={isUserSelectedAuto} containerRef={hlsViewRef.current} /> ) : null} <HLSAutoplayBlockedPrompt open={isHlsAutoplayBlocked} unblockAutoPlay={unblockAutoPlay} /> </HMSVideoPlayer.Controls.Right> </HMSVideoPlayer.Controls.Root> </Flex> </> ) : null} {controlsVisible && ( <Flex ref={controlsRef} direction={isMobile ? 'columnReverse' : 'column'} justify="end" align="start" css={{ position: 'absolute', bottom: isFullScreen && hlsState?.variants[0]?.playlist_type === HLSPlaylistType.DVR ? '$8' : '0', left: '0', zIndex: 1, background: isMobile || isLandscape ? '' : `linear-gradient(180deg, ${theme.colors.background_dim.value}00 29.46%, ${theme.colors.background_dim.value}A3 100%);`, width: '100%', pt: '$8', flexShrink: 0, transition: 'visibility 0s 0.5s, opacity 0.5s linear', }} > {hlsState?.variants[0]?.playlist_type === HLSPlaylistType.DVR ? ( <HMSVideoPlayer.Progress seekProgress={seekProgress} setSeekProgress={setSeekProgress} /> ) : null} <HMSVideoPlayer.Controls.Root css={{ p: '$4 $8', }} onClick={e => e.stopPropagation()} > <HMSVideoPlayer.Controls.Left> {!(isMobile || isLandscape) && ( <> {hlsState?.variants[0]?.playlist_type === HLSPlaylistType.DVR ? ( <> <HMSVideoPlayer.PlayPauseSeekControls.Button isPaused={isPaused} onSeekTo={onSeekTo} /> {!isVideoLive ? <HMSVideoPlayer.Duration /> : null} </> ) : null} <HMSVideoPlayer.Volume /> </> )} <IconButton css={{ px: '$2' }} onClick={async e => { e.stopPropagation(); await hlsPlayer?.seekToLivePosition(); setIsVideoLive(true); }} key="jump-to-live_btn" data-testid="jump-to-live_btn" > <Tooltip title={isVideoLive ? 'Live' : 'Go to Live'} side="top"> <Flex justify="center" gap={2} align="center"> <Box css={{ height: '$4', width: '$4', background: isVideoLive ? '$alert_error_default' : '$on_primary_medium', r: '$1', }} /> <Text variant="$body1" css={{ c: isVideoLive ? '$on_surface_high' : '$on_surface_medium', fontWeight: '$semiBold', }} > {isVideoLive ? 'LIVE' : 'GO LIVE'} </Text> </Flex> </Tooltip> </IconButton> {(isMobile || isLandscape) && !isVideoLive && hlsState?.variants[0]?.playlist_type === HLSPlaylistType.DVR ? ( <HMSVideoPlayer.Duration /> ) : null} </HMSVideoPlayer.Controls.Left> <HMSVideoPlayer.Controls.Right> {hasCaptions && !(isMobile || isLandscape) && <HLSCaptionSelector isEnabled={isCaptionEnabled} />} {availableLayers.length > 0 && !(isMobile || isLandscape) ? ( <HLSQualitySelector layers={availableLayers} onOpenChange={setQualityDropDownOpen} open={qualityDropDownOpen} selection={currentSelectedQuality} onQualityChange={handleQuality} isAuto={isUserSelectedAuto} containerRef={hlsViewRef.current} /> ) : null} {isFullScreenSupported ? ( <FullScreenButton isFullScreen={isFullScreen} onToggle={toggle} /> ) : null} </HMSVideoPlayer.Controls.Right> </HMSVideoPlayer.Controls.Root> </Flex> )} </> </HMSVideoPlayer.Root> </Flex> </HMSPlayerContext.Provider> <ToggleChat isFullScreen={isFullScreen} /> {isMobile && !isFullScreen && <HLSViewTitle />} </Flex> ); }; export default HLSView;