UNPKG

@theoplayer/react-native-ui

Version:

A React Native UI for @theoplayer/react-native

383 lines (369 loc) 12.5 kB
import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Animated, AppState, Platform, TouchableOpacity, View } from 'react-native'; import { PlayerContext } from '../util/PlayerContext'; import { useTVOSEventHandler } from '../util/TVUtils'; import { AdEventType, CastEventType, PlayerEventType, PresentationMode } from 'react-native-theoplayer'; import { ErrorDisplay } from '../message/ErrorDisplay'; import { defaultLocale } from '../util/Locale'; import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; /** * The default style for a fullscreen centered view. */ export const FULLSCREEN_CENTER_STYLE = { 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 = { position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, zIndex: 0, justifyContent: 'center', overflow: 'hidden' }; /** * The default style for the ad container. */ export const AD_CONTAINER_STYLE = { position: 'absolute', top: 100, left: 0, bottom: 100, right: 0, zIndex: 0, justifyContent: 'center', overflow: 'hidden' }; /** * The default style for the top container. */ export const TOP_UI_CONTAINER_STYLE = { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 1, paddingTop: 10, paddingLeft: 10, paddingRight: 10 }; /** * The default style for the center container. */ export const CENTER_UI_CONTAINER_STYLE = { alignSelf: 'center', justifyContent: 'center', flexDirection: 'row', width: '100%' }; /** * The default style for the bottom container. */ export const BOTTOM_UI_CONTAINER_STYLE = { position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 1, paddingBottom: 10, paddingLeft: 10, paddingRight: 10 }; /** * The default style for the ad container. */ export const AD_UI_TOP_CONTAINER_STYLE = TOP_UI_CONTAINER_STYLE; export const AD_UI_CENTER_CONTAINER_STYLE = CENTER_UI_CONTAINER_STYLE; export const AD_UI_BOTTOM_CONTAINER_STYLE = BOTTOM_UI_CONTAINER_STYLE; /** * 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 = props => { const _currentFadeOutTimeout = useRef(undefined); const fadeAnimation = useRef(new Animated.Value(1)).current; const [currentMenu, setCurrentMenu] = useState(undefined); const [isPassingPointerEvents, setIsPassingPointerEvents] = useState(true); const [buttonsEnabled_, setButtonsEnabled] = useState(true); const [error, setError] = useState(undefined); 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 = { ...defaultLocale, ...locale }; // Animation control const fadeOutBlocked = !didPlay || currentMenu !== undefined || casting || pip || paused; const doFadeOut_ = useCallback(() => { if (fadeOutBlocked) { return; } clearTimeout(_currentFadeOutTimeout.current); setButtonsEnabled(false); Animated.timing(fadeAnimation, { useNativeDriver: true, toValue: 0, duration: 200 }).start(() => { setIsPassingPointerEvents(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 = true) => { clearTimeout(_currentFadeOutTimeout.current); _currentFadeOutTimeout.current = undefined; if (!isPassingPointerEvents) { setIsPassingPointerEvents(true); Animated.timing(fadeAnimation, { useNativeDriver: true, toValue: 1, duration: 200 }).start(() => { setButtonsEnabled(true); }); } if (fadeOutEnabled) { resumeUIFadeOut_(); } }, [fadeAnimation, isPassingPointerEvents, resumeUIFadeOut_]); // TVOS events hook useTVOSEventHandler(_event => { fadeInUI_(); }); // Ad listeners hook useEffect(() => { const handleAdEvent = event => { 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 handleLoadStart = () => { setError(undefined); }; const handleError = event => { setError(event.error); }; const handleCastEvent = event => { 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 => { setPip(event.presentationMode === PresentationMode.pip); if (event.presentationMode !== PresentationMode.pip) { fadeInUI_(); } }; player.addEventListener(PlayerEventType.LOAD_START, handleLoadStart); player.addEventListener(PlayerEventType.ERROR, handleError); 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.LOAD_START, handleLoadStart); player.removeEventListener(PlayerEventType.ERROR, handleError); 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 => { _menus.push(menuConstructor); setCurrentMenu(menuConstructor()); }; const closeCurrentMenu_ = () => { _menus.pop(); const nextMenu = _menus.length > 0 ? _menus[_menus.length - 1] : undefined; setCurrentMenu(nextMenu?.()); }; const playPause_ = () => { if (paused) { player.play(); } else { player.pause(); } }; const enterPip_ = () => { // Make sure the UI is disabled first before entering PIP clearTimeout(_currentFadeOutTimeout.current); setButtonsEnabled(false); Animated.timing(fadeAnimation, { useNativeDriver: true, toValue: 0, duration: 0 }).start(() => { setIsPassingPointerEvents(false); player.presentationMode = PresentationMode.pip; }); }; /** * Request to show the UI due to user input. */ const onUserAction_ = useCallback(() => { if (!didPlay) { return; } fadeInUI_(); }, [fadeInUI_, didPlay]); const combinedUiContainerStyle = [UI_CONTAINER_STYLE, props.style]; const combinedAdContainerStyle = [AD_CONTAINER_STYLE, props.style]; const showMobileAdLayout = adInProgress && Platform.OS != 'web'; if (error !== undefined) { return /*#__PURE__*/_jsx(ErrorDisplay, { error: error }); } if (Platform.OS !== 'web' && pip) { return /*#__PURE__*/_jsx(_Fragment, {}); } const ui = { buttonsEnabled_, onUserAction_, openMenu_, closeCurrentMenu_, enterPip_ }; return /*#__PURE__*/_jsxs(PlayerContext.Provider, { value: { player, style: props.theme, ui, adInProgress, locale: combinedLocale }, children: [/*#__PURE__*/_jsx(View, { style: FULLSCREEN_CENTER_STYLE, pointerEvents: 'none', children: props.behind }), !showMobileAdLayout && /*#__PURE__*/_jsx(Animated.View, { style: [combinedUiContainerStyle, { opacity: fadeAnimation }], onTouchStart: onUserAction_, onTouchMove: onUserAction_, pointerEvents: isPassingPointerEvents ? 'auto' : 'box-only', ...(Platform.OS === 'web' ? { onMouseMove: onUserAction_, onMouseLeave: doFadeOut_ } : {}), children: /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(View, { style: [combinedUiContainerStyle, { backgroundColor: props.theme.colors.uiBackground }], onTouchStart: doFadeOut_ }), currentMenu !== undefined && /*#__PURE__*/_jsx(View, { style: [combinedUiContainerStyle], children: currentMenu }), currentMenu === undefined && !adInProgress && /*#__PURE__*/_jsxs(_Fragment, { children: [didPlay && /*#__PURE__*/_jsx(View, { style: [TOP_UI_CONTAINER_STYLE, props.topStyle], children: props.top }), /*#__PURE__*/_jsx(View, { style: [CENTER_UI_CONTAINER_STYLE, props.centerStyle], children: props.center }), didPlay && /*#__PURE__*/_jsx(View, { style: [BOTTOM_UI_CONTAINER_STYLE, props.bottomStyle], children: props.bottom }), props.children] }), currentMenu === undefined && adInProgress && /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(View, { style: [AD_UI_TOP_CONTAINER_STYLE, props.adTopStyle], children: props.adTop }), /*#__PURE__*/_jsx(View, { style: [AD_UI_CENTER_CONTAINER_STYLE, props.adCenterStyle], children: props.adCenter }), /*#__PURE__*/_jsx(View, { style: [AD_UI_BOTTOM_CONTAINER_STYLE, props.adBottomStyle], children: props.adBottom })] })] }) }), showMobileAdLayout && /*#__PURE__*/_jsx(View, { style: [combinedAdContainerStyle], children: /*#__PURE__*/_jsx(TouchableOpacity, { style: [FULLSCREEN_CENTER_STYLE], onPress: playPause_ }) })] }); }; //# sourceMappingURL=UiContainer.js.map