import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useSWR from 'swr'; import { fetchPlaybackMetadata, CFDomainError } from './utils'; import { isiOS } from '../../utils'; import { LIVE, VOD } from '../../constants'; import PlaybackContext from './context'; import usePlayer from '../../hooks/usePlayer'; import useStateWithCallback from '../../hooks/useStateWithCallback'; const defaultPlaybackMetadata = { livePlaybackUrl: '', vodPlaybackURL: '', recordingStartTime: null, playlistDuration: null }; const PlaybackProvider = ({ children }) => { const [currProgress, setCurrProgress] = useStateWithCallback(100); const [bufferPercent, setBufferPercent] = useState(0); const retryTimeoutId = useRef(); const isiOSDevice = isiOS(); const { data: { isChannelLive, playlistDuration: vodPlaylistDuration, recordingStartTime, vodPlaybackURL, livePlaybackUrl } } = useSWR('recording-started-latest.json', fetchPlaybackMetadata, { refreshInterval: 5000, revalidateOnMount: true, fallbackData: defaultPlaybackMetadata, onErrorRetry: (error, _key, _config, revalidate) => { if (error instanceof CFDomainError) { livePlayer?.setError(error); console.error(`${error.message} \n ${error.description}`); return; } retryTimeoutId.current = setTimeout(revalidate, 3000); } }); const [activePlayerType, setActivePlayerType] = useState(LIVE); const livePlayer = usePlayer(livePlaybackUrl, LIVE, isChannelLive); const vodPlayer = usePlayer(vodPlaybackURL, VOD, isChannelLive); const { instance: vodPlayerInstance, setCurrentTime: setVodCurrentTime, videoRef: vodVideoRef } = vodPlayer; /** * Known issue: getDuration, seekTo and getPosition are not working as expected on iOS. * * The workaround is to use the currentTime property of the HTML video element to get and set the current playback position. * The current VOD duration has been exposed from the backend and is fetched every second. */ const getVodDuration = useCallback(() => { if (!vodPlayerInstance) return 0; let duration = vodPlayerInstance.getDuration(); if (isiOSDevice && vodPlaylistDuration) { duration = vodPlaylistDuration; } return duration; }, [isiOSDevice, vodPlayerInstance, vodPlaylistDuration]); const seekVodToPos = useCallback( (seekPosition) => { if (isiOSDevice) { setVodCurrentTime(seekPosition); } else { vodPlayerInstance?.seekTo(seekPosition); } }, [isiOSDevice, setVodCurrentTime, vodPlayerInstance] ); const getVodPosition = useCallback( (seekPosition) => { if (!vodPlayerInstance) return 0; if (isiOSDevice) { return vodVideoRef.current.currentTime; } return vodPlayerInstance.getPosition(); }, [isiOSDevice, vodPlayerInstance, vodVideoRef] ); const isLiveAvailable = useMemo( () => isChannelLive && !livePlayer.error, [isChannelLive, livePlayer.error] ); const isVodAvailable = useMemo( () => vodPlayer.isReady && !vodPlayer.error, [vodPlayer.error, vodPlayer.isReady] ); // Ensures we restart the playback when switching player const setActivePlayerTypeWithState = useCallback( (nextType) => { setActivePlayerType((prevType) => { const nextPlayFn = nextType === LIVE ? livePlayer.play : vodPlayer.play; if (nextType !== prevType) { nextPlayFn(); } return nextType; }); }, [livePlayer.play, vodPlayer.play] ); // Clear the error retry timeout useEffect(() => { return () => clearTimeout(retryTimeoutId.current); }, []); // Switch to the LIVE player if VOD has ended (will lead to "live stream offline" error state) useEffect(() => { if (vodPlayer.hasEnded) setActivePlayerType(LIVE); }, [vodPlayer.hasEnded]); // Reset the VOD player if the LIVE player has encountered an error and is currently active (ensures that the VOD player starts fresh with the next live stream) useEffect(() => { if (activePlayerType === LIVE && livePlayer.error) { const resetVOD = vodPlayer.reset; resetVOD(); } }, [activePlayerType, vodPlayer.reset, livePlayer.error]); // Seek the VOD player to the end while the LIVE player is active so it can continue fetching new playlists useEffect(() => { if (isVodAvailable && activePlayerType === LIVE) { const vodDuration = getVodDuration(); seekVodToPos(vodDuration); // Seek VOD to the end to fetch new playlists while LIVE is active } }, [activePlayerType, getVodDuration, isVodAvailable, seekVodToPos]); // Lower the rendition of the inactive player to the lowest available resolution useEffect(() => { const activePlayerInstance = activePlayerType === LIVE ? livePlayer.instance : vodPlayerInstance; const inactivePlayerInstance = activePlayerType === LIVE ? vodPlayerInstance : livePlayer.instance; if (activePlayerInstance && inactivePlayerInstance) { const qualities = inactivePlayerInstance?.getQualities() || []; const lowestQuality = qualities.pop(); if (lowestQuality) { inactivePlayerInstance.setQuality(lowestQuality, true); } activePlayerInstance.setAutoQualityMode(true); } }, [activePlayerType, livePlayer.instance, vodPlayerInstance]); const value = useMemo( () => ({ activePlayer: activePlayerType === LIVE ? { ...livePlayer, type: activePlayerType } : { ...vodPlayer, type: activePlayerType }, bufferPercent, currProgress, getVodDuration, getVodPosition, isLiveAvailable, isVodAvailable, liveVideoRef: livePlayer.videoRef, recordingStartTime, resetVOD: vodPlayer.reset, seekVodToPos, setActivePlayerType: setActivePlayerTypeWithState, setBufferPercent, setCurrProgress, vodPlayerInstance, vodVideoRef: vodPlayer.videoRef }), [ activePlayerType, bufferPercent, currProgress, getVodDuration, getVodPosition, isLiveAvailable, isVodAvailable, livePlayer, recordingStartTime, seekVodToPos, setActivePlayerTypeWithState, setCurrProgress, vodPlayer, vodPlayerInstance ] ); return ( {children} ); }; PlaybackProvider.propTypes = { children: PropTypes.node.isRequired }; export default PlaybackProvider;