import * as React from "react"; import { useEffect, useReducer } from "react"; import { View, ViewStyle } from "react-native"; import * as R from "ramda"; import uuid from "uuid/v4"; import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager"; import { isTV, platformSelect, isAndroidTVPlatform, isTvOSPlatform, isApplePlatform, } from "@applicaster/zapp-react-native-utils/reactUtils"; import { isAudioItem, isInlineTV as isInlineTVUtil, } from "@applicaster/zapp-react-native-utils/playerUtils"; import { TVEventHandlerComponent } from "@applicaster/zapp-react-native-tvos-ui-components/Components/TVEventHandlerComponent"; import { usePrevious } from "@applicaster/zapp-react-native-utils/reactHooks/utils"; import { usePickFromState } from "@applicaster/zapp-react-native-redux/hooks"; import { useBackHandler, useNavigation, } from "@applicaster/zapp-react-native-utils/reactHooks"; import { PlayerStateContext } from "../../Contexts/PlayerStateContext"; import { QUICK_BRICK_EVENTS, QuickBrickEvent, sendQuickBrickEvent, } from "@applicaster/zapp-react-native-bridge/QuickBrick"; import { ProgramInfo } from "./ProgramInfo"; import { AudioPlayer } from "../AudioPlayer"; import { log_debug, log_info, log_warning, playerContainerLogger, } from "./logger"; import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer"; import { useSetNavbarState } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext"; import { useTargetScreenData } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useTargetScreenData"; import { PlayerContainerContext, PlayerContainerContextProvider, } from "./PlayerContainerContext"; import { FocusableGroup } from "@applicaster/zapp-react-native-ui-components/Components/FocusableGroup"; import { PlayerFocusableWrapperView } from "./WappersView/PlayerFocusableWrapperView"; import { FocusableGroupMainContainerId } from "./index"; import { isPlayable } from "@applicaster/zapp-react-native-utils/navigationUtils/itemTypeMatchers"; import { ComponentFocusableWrapperView } from "./WappersView/ComponentFocusableWrapperView"; import { GeneralContentScreen } from "../GeneralContentScreen"; import { toNumber } from "@applicaster/zapp-react-native-utils/numberUtils"; import { usePlayNextOverlay } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayNextOverlay"; import { PlayNextState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/OverlaysObserver"; import { PlayerNativeCommandTypes, PlayerNativeSendCommand, } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand"; import { PLAYER_EVENTS } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const"; type Props = { Player: React.ComponentType; PlayerLoadingView?: React.ComponentType; // 👀 we are not receiving this prop item: ZappEntry; style: ViewStyle; isModal?: boolean; mode?: VideoModalMode; loadPipesData: (url: string, options: {}) => void; disableCastAction?: boolean; groupId?: string; }; type ScreenStyles = ZappRiver["styles"] & { tv_component_container_height: number; }; export type State = { error: {} | null; playerId: string; }; // Constants export const VideoModalMode = { MAXIMIZED: "MAXIMIZED", MINIMIZED: "MINIMIZED", FULLSCREEN: "FULLSCREEN", } as const; export type PlayNextData = { state: PlayNextState; nextVideoPreloadThresholdPercentage: number; onPlayNextPerformNextVideoPlay: () => void; }; const focusableBottomContainerId = "player-container-bottom"; const isAndroidTV = isAndroidTVPlatform(); const isTvOS = isTvOSPlatform(); // height for container with additional content below player const INLINE_CONTAINER_CONTENT_HEIGHT = 400; const withBorderHack = () => { if (isAndroidTV) { /* @HACK: see GH#7269 */ return { borderWidth: 1, borderColor: "black", }; } return {}; }; // Styles const webStyles = { focusableGroup: { flex: 1, justifyContent: "center", height: 660, }, playerScreen: { flex: 1, height: "100vh", backgroundColor: "black", }, playerWrapper: { height: "100%", flex: "initial", }, inlineRiver: { height: INLINE_CONTAINER_CONTENT_HEIGHT, }, }; const nativeStyles = { focusableGroup: { flex: 1, height: 660, }, playerScreen: { flex: 1, overflow: "hidden", }, playerWrapper: { flex: 1, }, inlineRiver: { height: INLINE_CONTAINER_CONTENT_HEIGHT, }, }; // These styles are later overwritten below, // the hook takes a few user configured styles from props, and applies any other conditions on mount const defaultStyles = platformSelect({ web: webStyles, native: nativeStyles, }); // Render methods const renderApplePlayer = ({ isAudioContent, player, item, pluginConfiguration, videoStyle, playNextData, }) => { if (!isTvOS || !player) { return null; } // render audio_player for tvos if (isAudioContent) { return ( ); } if (!playNextData && player.isPaused() && player.isAd() === false) { return null; } // Title/Subtitle are not resolved here: `ProgramInfo` reads them from the // player manager's content channel via `usePlayerContent`. return ( ); }; const PlayerContainerComponent = (props: Props) => { const { Player, item, isModal, style: videoStyle, mode = VideoModalMode.MAXIMIZED, disableCastAction = false, groupId, } = props; // Hooks const [state, setState] = useReducer>>( (prevState, updatedState) => ({ ...prevState, ...updatedState }), { playerId: uuid(), error: null, } ); const [ nextVideoPreloadThresholdPercentage, setNextVideoPreloadThresholdPercentage, ] = React.useState(null); const controlsRef = React.useRef(null); const player = usePlayer(state.playerId); const playNextOverlayState = usePlayNextOverlay( state.playerId, "PlayerContainer" ); const playerRef = React.useRef(null); // Hack, to skip delayed event from previous video. const [isLoadingNextVideo, setIsLoadingNextVideo] = React.useState(false); const navigator = useNavigation(); const { appData } = usePickFromState(["appData"]); const prevItemId = usePrevious(item?.id); const screenData = useTargetScreenData(item); const { setVisible: showNavBar } = useSetNavbarState(); const playerEvent = (event, ...args) => { playerManager.invokeHandler(event, ...args); }; // Memoized methods const close = React.useCallback(() => { if (isModal) { navigator.closeVideoModal(); return; } // send command to clear and stop player PlayerNativeSendCommand( PlayerNativeCommandTypes.clearPlayerData, state.playerId ); showNavBar(true); navigator.goBack(); }, [isModal, state.playerId, showNavBar, navigator]); const pluginConfiguration = React.useMemo( () => player?.getPluginConfiguration(), [player] ); const playEntry = (entry: ZappEntry) => navigator.replaceTop(entry, { mode }); const onPlayNextPerformNextVideoPlay = React.useCallback(() => { if (!playNextOverlayState.entry) { log_warning( "onPlayNextPerformNextVideoPlay: playNextOverlayState.entry is not defined" ); return; } log_debug( `onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${playNextOverlayState.entry.title}, id: ${playNextOverlayState.entry.id}, outgoing item id: ${item?.id}` ); playNextOverlayState.hidePlayNext(); playerEvent(PLAYER_EVENTS.PlayNextTriggered, { id: item?.id }); setIsLoadingNextVideo(true); playEntry(playNextOverlayState.entry); }, [playNextOverlayState, item?.id]); const playNextData: Nullable = React.useMemo(() => { if (!playNextOverlayState) { return null; } return { state: playNextOverlayState, nextVideoPreloadThresholdPercentage: nextVideoPreloadThresholdPercentage ?? 0, onPlayNextPerformNextVideoPlay, }; }, [ playNextOverlayState, nextVideoPreloadThresholdPercentage, onPlayNextPerformNextVideoPlay, ]); const onVideoEnd = React.useCallback(() => { if (isLoadingNextVideo) { log_debug( "onVideoEnd: isLoadingNextVideo is true, skipping closing player" ); return; } const playNextEntry = player?.getOverlayObservable()?.getPlayNextEntry(); if (playNextEntry) { log_info( `PlayNext triggered by VideoEnd. Next entry title: ${playNextEntry.title}, id: ${playNextEntry.id}` ); playEntry(playNextEntry); } else { close(); } }, [close, player, mode, isLoadingNextVideo]); const onError = React.useCallback( ({ error, message = "", exception = "" }) => { setIsLoadingNextVideo(false); const errorObj = error || new Error([message, exception].join(" - ")); playerContainerLogger.error(errorObj); setState({ error: errorObj }); }, [close] ); const onLoad = React.useCallback(() => { setIsLoadingNextVideo(false); const resumeTime = Number(item?.extensions?.resumeTime); const activePlayer = playerManager.getActivePlayer(); // Сhecking that the player itself knows where to start playing, and there is no need to call seekTo after returning from the Сhromecast. // Live streams and a missing/zero resume position are skipped: seeking to 0 on // a live stream pulls playback off the live edge into a buffer-starvation seek loop. if ( !activePlayer?.hasResumePosition() && resumeTime > 0 && !activePlayer?.isLive() ) { player?.seekTo(resumeTime); } }, [item?.id, item?.extensions?.resumeTime, player]); const onVideoFullscreenPlayerWillPresent = React.useCallback(() => { playerManager.invokeHandler("toggleFullscreen", { fullscreen: true, }); }, []); const onVideoFullscreenPlayerWillDismiss = React.useCallback(() => { playerManager.invokeHandler("toggleFullscreen", { fullscreen: false, }); }, []); // Util methods const toggleFullscreen = (event) => { const { fullscreen = false } = event || {}; setTimeout(() => { if (fullscreen) { navigator.fullscreenVideoModal(); playerEvent("onFullscreenPlayerWillPresent"); } else { navigator.maximiseVideoModal(); playerEvent("onFullscreenPlayerWillDismiss"); } }); }; const onCellTap = (entry) => { // TODO: Remove hack, it happens because we need to load hooks to player if new item is selected if (isPlayable(entry)) { navigator.replaceTop(entry); } else { navigator.push(entry); } }; const playerRemoteHandler = React.useCallback( (isLanguageOverlayVisible = false) => (event) => { const { eventType } = event; if (!isLanguageOverlayVisible && eventType === "menu") { close(); } }, [close] ); // Effects useEffect(() => { const playerContainerId = "player-container"; if (!player) { return; } player.addListener({ id: playerContainerId, listener: { onVideoEnd, onError: (err: Error) => { // Adapt Error to the expected shape for onError onError({ error: err }); }, onLoad, onPlayerClose: close, onPlayerDetached: close, onVideoFullscreenPlayerWillDismiss, onVideoFullscreenPlayerWillPresent, }, }); return () => { player.removeListener(playerContainerId); }; }, [ player, onVideoEnd, onError, onLoad, close, onVideoFullscreenPlayerWillDismiss, onVideoFullscreenPlayerWillPresent, ]); useEffect(() => { // TODO: We should not use playerManager here to connect buttons to player container if (playerManager) { playerManager .on?.("close", close) // TODO: close button should call close method directly .on?.("toggleFullscreen", toggleFullscreen); return () => { playerManager .removeHandler?.("close", close) .removeHandler?.("toggleFullscreen", toggleFullscreen); }; } }, []); const disableMiniPlayer = React.useMemo(() => { return pluginConfiguration?.disable_mini_player_when_inline; }, [pluginConfiguration]); const backHandler = React.useCallback(() => { if (isTV()) { return false; } if (isModal && mode === VideoModalMode.MAXIMIZED) { if (disableMiniPlayer) { navigator.closeVideoModal(); } else { navigator.minimiseVideoModal(); } } if (mode === VideoModalMode.FULLSCREEN) { toggleFullscreen({ fullscreen: false }); if (!isModal) { playerManager.closeNativePlayer(); } } return !!( isModal && (mode === VideoModalMode.MAXIMIZED || mode === VideoModalMode.FULLSCREEN) ); }, [isModal, mode, disableMiniPlayer]); // TODO: Skip hook call on TV platform useBackHandler(backHandler); const isAudioContent = isAudioItem(item); useEffect(() => { if (!isAudioContent && isApplePlatform()) { sendQuickBrickEvent( QUICK_BRICK_EVENTS.IDLE_TIMER_DISABLED as QuickBrickEvent, { disabled: true, } ); return () => sendQuickBrickEvent( QUICK_BRICK_EVENTS.IDLE_TIMER_DISABLED as QuickBrickEvent, { disabled: false, } ); } }, [isAudioContent]); useEffect(() => { playerEvent("source_changed", { item }); return () => { playerEvent("player_did_close", { item }); }; }, [item?.id, Player]); useEffect(() => { if (!isModal) { showNavBar(false); } if (!Player) { onError({ error: new Error("Could not find player plugin") }); } return () => { if (!isModal) { showNavBar(true); } }; }, [showNavBar, isModal, Player]); useEffect(() => { if (prevItemId && !R.equals(prevItemId, item?.id)) { playerEvent("source_changed", { item }); } }, [item, prevItemId]); useEffect(() => { navigator.setPlayNextOverlay(playNextOverlayState); return () => navigator.setPlayNextOverlay(null); }, [playNextOverlayState]); // Constants // Styles from Zapp screen config const { tv_component_container_height, screen_background_color } = (screenData?.styles || {}) as ScreenStyles; const uri = item?.content ? item.content?.src : null; const isInlineTV = isInlineTVUtil(screenData); const inline = [VideoModalMode.MAXIMIZED, VideoModalMode.MINIMIZED].includes( mode as any ) || isInlineTV; const value = React.useMemo( () => ({ playerId: state.playerId }), [state.playerId] ); // Update defaults with any styles from screenData, or props const styles = React.useMemo(() => { const updatedStyles = R.clone(defaultStyles); if (videoStyle.height) { updatedStyles.focusableGroup.height = videoStyle.height; } if (tv_component_container_height) { updatedStyles.inlineRiver.height = toNumber( tv_component_container_height ); } if ( screen_background_color && mode !== VideoModalMode.FULLSCREEN && isTV() ) { updatedStyles.playerScreen.backgroundColor = screen_background_color; } if (isInlineTV) { // This prevents the window from getting resized on older devices updatedStyles.playerWrapper.border = "1px solid transparent"; } else { updatedStyles.playerWrapper.border = "0"; } return updatedStyles; }, [ defaultStyles, videoStyle?.height, tv_component_container_height, screen_background_color, isInlineTV, mode, ]); const applePlayerProps = { isAudioContent, player, item, pluginConfiguration, videoStyle, playNextData, }; const pointerEventsProp = mode === "MINIMIZED" ? "box-none" : "auto"; return ( {(context) => ( {/* Video player and components */} {/* Player container */} {Player ? ( {renderApplePlayer(applePlayerProps)} ) : null} {/* Components container */} {isInlineTV && context.showComponentsContainer ? ( {(focused, parentFocus) => ( )} ) : null} )} ); }; export const PlayerContainer = React.memo(PlayerContainerComponent);