import React, { type PropsWithChildren, ReactNode, useCallback, useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; import { Animated, AppState, Platform, StyleProp, View, ViewStyle } from 'react-native'; import { PlayerContext } from '../util/PlayerContext'; import { type TVOSEvent, useTVOSEventHandler } from '../util/TVUtils'; import type { AdEvent, PresentationModeChangeEvent, THEOplayer } from 'react-native-theoplayer'; import { AdEventType, CastEvent, CastEventType, PlayerEventType, PresentationMode } from 'react-native-theoplayer'; import type { THEOplayerTheme } from '../../THEOplayerTheme'; import type { MenuConstructor, UiControls } from './UiControls'; import { ErrorDisplay } from '../message/ErrorDisplay'; import { useError } from '../../hooks/useError'; import { type Locale, defaultLocale } from '../util/Locale'; import { useWebMouseEvents } from '../../hooks/useWebMouseEvents'; import { useThrottledCallback } from '../../hooks/useThrottledCallback'; export interface UiContainerProps { /** * The player that is provided to all children using PlayerContext. */ player: THEOplayer; /** * The theme that is provided to all children using PlayerContext. */ theme: THEOplayerTheme; /** * The localized strings used in the UI components. */ locale?: Partial; /** * The style of the container. */ style?: StyleProp; /** * The style of the top slot. */ topStyle?: StyleProp; /** * The style of the center slot. */ centerStyle?: StyleProp; /** * The style of the bottom slot. */ bottomStyle?: StyleProp; /** * The style of the top ad slot. */ adTopStyle?: StyleProp; /** * The style of the center ad slot. */ adCenterStyle?: StyleProp; /** * The style of the bottom ad slot. */ adBottomStyle?: StyleProp; /** * The components to be put in the top slot. */ top?: ReactNode; /** * The components to be put in the center slot. */ center?: ReactNode; /** * The components to be put in the bottom slot. */ bottom?: ReactNode; /** * The components to be put in the top slot during an ad. * * @remarks *
- Currently only supported for web. */ adTop?: ReactNode; /** * The components to be put in the center slot during an ad. * * @remarks *
- Currently only supported for web. */ adCenter?: ReactNode; /** * The components to be put in the bottom slot during an ad. * * @remarks *
- Currently only supported for web. */ adBottom?: ReactNode; /** * A slot to put components behind the UI background. */ behind?: ReactNode; children?: never; } /** * Ref interface for UiContainer, exposing methods to interact with the UI from parent components. */ export interface UiContainerRef { /** * Programmatically triggers the UI fade-in animation. * Useful for showing the UI in response to external events (e.g., keyboard shortcuts). */ onUserAction: () => void; } /** * The default style for a fullscreen centered view. */ export const FULLSCREEN_CENTER_STYLE: ViewStyle = { position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, alignItems: 'center', justifyContent: 'center', }; /** * The default style for the UI container. */ export const UI_CONTAINER_STYLE: ViewStyle = { position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, zIndex: 0, justifyContent: 'center', overflow: 'hidden', }; /** * The default style for the top container. */ export const TOP_UI_CONTAINER_STYLE: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 1, paddingTop: 10, paddingLeft: 10, paddingRight: 10, pointerEvents: 'box-none', }; /** * The default style for the center container. */ export const CENTER_UI_CONTAINER_STYLE: ViewStyle = { alignSelf: 'center', justifyContent: 'center', flexDirection: 'row', width: '100%', pointerEvents: 'box-none', }; /** * The default style for the bottom container. */ export const BOTTOM_UI_CONTAINER_STYLE: ViewStyle = { position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 1, paddingBottom: 10, paddingLeft: 10, paddingRight: 10, pointerEvents: 'box-none', }; /** * The default style for the ad container. */ export const AD_UI_TOP_CONTAINER_STYLE: ViewStyle = TOP_UI_CONTAINER_STYLE; export const AD_UI_CENTER_CONTAINER_STYLE: ViewStyle = CENTER_UI_CONTAINER_STYLE; export const AD_UI_BOTTOM_CONTAINER_STYLE: ViewStyle = BOTTOM_UI_CONTAINER_STYLE; const WEB_POINTER_MOVE_THROTTLE = 500; /** * A component that does all the coordination between UI components. * - It provides all UI components with the PlayerContext, so they can access the styling and player. * - It provides slots for UI components to be places in the top/center/bottom positions. * - It uses animations to fade the UI in and out when applicable. */ export const UiContainer = forwardRef((props, ref) => { const _currentFadeOutTimeout = useRef(undefined); const fadeAnimation = useRef(new Animated.Value(1)).current; const [currentMenu, setCurrentMenu] = useState(undefined); const [uiVisible_, setUiVisible] = useState(true); const [didPlay, setDidPlay] = useState(false); const [paused, setPaused] = useState(true); const [casting, setCasting] = useState(false); const [pip, setPip] = useState(false); const [adInProgress, setAdInProgress] = useState(false); const [adTapped, setAdTapped] = useState(false); const appStateSubscription = useRef(null); const _menus = useRef([]).current; const { player, locale } = props; const combinedLocale: Locale = { ...defaultLocale, ...locale }; // Animation control const fadeOutBlocked = !didPlay || currentMenu !== undefined || casting || pip || paused; const doFadeOut_ = useCallback(() => { if (fadeOutBlocked) { return; } clearTimeout(_currentFadeOutTimeout.current); Animated.timing(fadeAnimation, { useNativeDriver: true, toValue: 0, duration: 200, }).start(() => { setUiVisible(false); }); }, [fadeOutBlocked, fadeAnimation]); const resumeUIFadeOut_ = useCallback(() => { clearTimeout(_currentFadeOutTimeout.current); // @ts-ignore _currentFadeOutTimeout.current = setTimeout(doFadeOut_, props.theme.fadeAnimationTimoutMs); }, [doFadeOut_, props.theme.fadeAnimationTimoutMs]); const fadeInUI_ = useCallback( (fadeOutEnabled: boolean = true) => { clearTimeout(_currentFadeOutTimeout.current); _currentFadeOutTimeout.current = undefined; setUiVisible(true); Animated.timing(fadeAnimation, { useNativeDriver: true, toValue: 1, duration: 200, }).start(); if (fadeOutEnabled) { resumeUIFadeOut_(); } }, [fadeAnimation, resumeUIFadeOut_], ); // TVOS events hook useTVOSEventHandler((_event: TVOSEvent) => { fadeInUI_(); }); // Ad listeners hook useEffect(() => { const handleAdEvent = (event: AdEvent) => { if (event.subType === AdEventType.AD_BREAK_BEGIN) { setAdInProgress(true); setAdTapped(false); } else if (event.subType === AdEventType.AD_BREAK_END) { setAdInProgress(false); setAdTapped(false); } else if (event.subType === AdEventType.AD_CLICKED || event.subType === AdEventType.AD_TAPPED) { setAdTapped(true); } }; player.addEventListener(PlayerEventType.AD_EVENT, handleAdEvent); appStateSubscription.current = AppState.addEventListener('change', (state) => { if (state === 'active' && adInProgress && adTapped) { player.play(); setAdTapped(false); } }); return () => { player.removeEventListener(PlayerEventType.AD_EVENT, handleAdEvent); appStateSubscription.current?.remove(); }; }, [player, adInProgress, adTapped]); // Player listeners hook useEffect(() => { const handlePlay = () => { setDidPlay(true); setPaused(false); }; const handlePause = () => { setDidPlay(true); setPaused(true); }; const handleSourceChange = () => { setPaused(player.paused); }; const handleCastEvent = (event: CastEvent) => { if (event.subType === CastEventType.CHROMECAST_STATE_CHANGE || event.subType === CastEventType.AIRPLAY_STATE_CHANGE) { setCasting(event.state === 'connecting' || event.state === 'connected'); } }; const handleEnded = () => { fadeInUI_(false); }; const handlePresentationModeChange = (event: PresentationModeChangeEvent) => { setPip(event.presentationMode === PresentationMode.pip); if (event.presentationMode !== PresentationMode.pip) { fadeInUI_(); } }; player.addEventListener(PlayerEventType.CAST_EVENT, handleCastEvent); player.addEventListener(PlayerEventType.PLAY, handlePlay); player.addEventListener(PlayerEventType.PLAYING, handlePlay); player.addEventListener(PlayerEventType.PAUSE, handlePause); player.addEventListener(PlayerEventType.SOURCE_CHANGE, handleSourceChange); player.addEventListener(PlayerEventType.ENDED, handleEnded); player.addEventListener(PlayerEventType.PRESENTATIONMODE_CHANGE, handlePresentationModeChange); setPip(player.presentationMode === 'picture-in-picture'); return () => { player.removeEventListener(PlayerEventType.CAST_EVENT, handleCastEvent); player.removeEventListener(PlayerEventType.PLAY, handlePlay); player.removeEventListener(PlayerEventType.PLAYING, handlePlay); player.removeEventListener(PlayerEventType.PAUSE, handlePause); player.removeEventListener(PlayerEventType.SOURCE_CHANGE, handleSourceChange); player.removeEventListener(PlayerEventType.ENDED, handleEnded); player.removeEventListener(PlayerEventType.PRESENTATIONMODE_CHANGE, handlePresentationModeChange); }; }, [player, paused, resumeUIFadeOut_, fadeInUI_, currentMenu]); // State reflecting hook useEffect(() => { if (currentMenu === undefined && !paused && didPlay) { resumeUIFadeOut_(); } else { fadeInUI_(false); } }, [currentMenu, paused, didPlay, fadeInUI_, resumeUIFadeOut_]); // Interactions const openMenu_ = (menuConstructor: () => ReactNode) => { _menus.push(menuConstructor); setCurrentMenu(menuConstructor()); }; const closeCurrentMenu_ = () => { _menus.pop(); const nextMenu = _menus.length > 0 ? _menus[_menus.length - 1] : undefined; setCurrentMenu(nextMenu?.()); }; const enterPip_ = () => { // Make sure the UI is disabled first before entering PIP clearTimeout(_currentFadeOutTimeout.current); fadeAnimation.setValue(0); setUiVisible(false); // eslint-disable-next-line react-hooks/immutability player.presentationMode = PresentationMode.pip; }; /** * Request to show the UI due to user input. */ const onUserAction_ = useCallback(() => { if (!didPlay) { return; } fadeInUI_(); }, [fadeInUI_, didPlay]); // Expose onUserAction_ to parent components via ref useImperativeHandle( ref, () => ({ onUserAction: onUserAction_, }), [onUserAction_], ); /** * On Web platform, use (throttled) pointer moves on the root container to enable showing/hiding instead of the UI container. * If an ad is playing, the UI should pass through all pointer events ("box-none") in order for ad clickThrough to work. * Throttle the callback avoids hammering the fade-in animation. */ useWebMouseEvents('#theoplayer-root-container', useThrottledCallback(onUserAction_, WEB_POINTER_MOVE_THROTTLE), doFadeOut_); const combinedUiContainerStyle = [UI_CONTAINER_STYLE, props.style]; if (Platform.OS !== 'web' && pip) { return <>; } const ui: UiControls = { buttonsEnabled_: uiVisible_, onUserAction_, openMenu_, closeCurrentMenu_, enterPip_, }; const hasUIComponents = props.top || props.center || props.bottom; const hasAdUIComponents = props.adTop || props.adCenter || props.adBottom; const showUIBackground = (!adInProgress && hasUIComponents) || (adInProgress && hasAdUIComponents); return ( {/* The View behind the UI, that is always visible. Typically contains a spinner to indicate buffering. */} {props.behind} {/* The UI Container. */} {/* - Enable onTouchMove to make sure keeps visible while scrubbing the slider. */} {/* - Enable tap/click to force-fully fade-in the UI. */} {/* - Disable tap/click when ad is in progress to allow clickThrough. */} {uiVisible_ && ( <> {/* The UI background. */} {/* - Enable tap/click to force-fully fade-out the UI when it is visible. */} {/* - Disable tap/click when ad is in progress to allow clickThrough. */} {/* - Don't show when all UI slots are empty. */} {showUIBackground && ( true} // at the end of the touch or mouse click, fade-out UI onResponderRelease={doFadeOut_} pointerEvents={adInProgress ? 'box-none' : uiVisible_ ? 'auto' : 'box-only'} /> )} {/* The Settings Menu */} {currentMenu !== undefined && {currentMenu}} {/* The content UI */} {/* - Hide while ad break is in progress. */} {currentMenu === undefined && !adInProgress && ( <> {didPlay && ( {props.top} )} {props.center} {didPlay && ( {props.bottom} )} {props.children} )} {/* The Ad UI */} {/* - Show while ad break is in progress. */} {currentMenu === undefined && adInProgress && ( <> {props.adTop} {props.adCenter} {props.adBottom} )} )} ); }); function ErrorGate({ children }: PropsWithChildren) { const error = useError(); if (error !== undefined) { return ; } return <>{children}; }