import * as React from "react"; import * as R from "ramda"; import { connectToStore } from "@applicaster/zapp-react-native-redux/utils/connectToStore"; import { QUICK_BRICK_EVENTS, sendQuickBrickEvent, } from "@applicaster/zapp-react-native-bridge/QuickBrick"; import { toBooleanWithDefaultFalse } from "@applicaster/zapp-react-native-utils/booleanUtils"; import { debounce, noop, } from "@applicaster/zapp-react-native-utils/functionUtils"; import { AccessibilityManager, ARROW_KEYS, focusManager, keyCode, KEYS, playerManager, } from "@applicaster/zapp-react-native-utils/appUtils"; import { showConfirmationDialog } from "@applicaster/zapp-react-native-utils/alertUtils"; import { DISPLAY_STATES, DisplayStateContext, } from "@applicaster/zapp-react-native-ui-components/Contexts/DisplayStateContext"; import { PlayerContentContext } from "@applicaster/zapp-react-dom-ui-components/Contexts"; import { withNavigator } from "@applicaster/zapp-react-native-ui-components/Decorators/Navigator"; import { getXray, createLogger, } from "@applicaster/zapp-react-native-utils/logger"; import { confirmationDialogStore, ConfirmationDialogState, } from "@applicaster/zapp-react-native-ui-components/Contexts/ConfirmDialogState"; import { isLgPlatform, isSamsungPlatform, isVizioPlatform, } from "@applicaster/zapp-react-native-utils/reactUtils"; import { OnScreenKeyboard } from "@applicaster/zapp-react-dom-ui-components/Components/OnScreenKeyboard"; import { KeyboardLongPressManager } from "@applicaster/zapp-react-dom-ui-components/Utils/KeyboardLongPressManager"; import { KeyInputHandler } from "@applicaster/zapp-react-native-utils/appUtils/keyInputHandler/KeyInputHandler"; import { getHomeRiver } from "@applicaster/zapp-react-native-utils/reactHooks/state"; import { withBackToTopActionHOC } from "./hoc"; const { withXray } = getXray(); const globalAny: any = global; const { log_debug, log_warning, log_info } = createLogger({ category: "InteractionManager", subsystem: "zapp-react-dom-app", }); const shouldUseOnScreenKeyboard = () => isVizioPlatform() || toBooleanWithDefaultFalse(window?.applicaster?.useOnScreenKeyboard); type Props = { displayState: string; setDisplayState: (state: string) => void; resetHudTimer: () => void; navigator: QuickBrickAppNavigator; rivers: {}; xray: XRayContext; confirmDialog: typeof confirmationDialogStore; backToTopAction: () => BackToTopAction; emitFocusOnSelectedTopMenuItem: Callback; emitFocusOnSelectedTab: Callback; }; interface State { isKeyboardVisible: boolean; keyboardInput: string; activeInputRef: HTMLInputElement | null; } type KeyCode = { code: string; keyCode: number | string }; const VIDEO_PLAYER_CONTROLS_NAVIGATION_MESSAGE = "Video player controls - use left/right to navigate"; class InteractionManagerClass extends React.Component { onKeyDownListener: (keyCode: KeyCode) => void; onMouseDownListener: (event: MouseEvent) => void; onMouseMoveListener: (event: MouseEvent) => void; onScrollListener: (event: MouseEvent) => void; private longPressDetector: KeyboardLongPressManager; private longPressActive: boolean = false; private timeout: any | null = null; private sequenceTimeout: NodeJS.Timeout | null; private userSequence: number[] = []; private isDismissing = false; private confirmDialog: ConfirmationDialogState = confirmationDialogStore.getState(); accessibilityManager: AccessibilityManager; state: State = { isKeyboardVisible: false, keyboardInput: "", activeInputRef: null, }; constructor(props) { super(props); this.onMouseMoveListener = debounce({ fn: this.onMouseMove, context: this, }); this.onScrollListener = debounce({ fn: this.onScroll, wait: 100, context: this, }); this.onMouseDownListener = debounce({ fn: this.onMouseDown, context: this, }); this.onKeyDownListener = this.onKeyDown.bind(this); this.resetHudTimeout = this.resetHudTimeout.bind(this); this.cursorVisibilityChange = this.cursorVisibilityChange.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.onKeyUp = this.onKeyUp.bind(this); this.onMouseMove = this.onMouseMove.bind(this); this.onScroll = this.onScroll.bind(this); this.onMouseDown = this.onMouseDown.bind(this); this.onKeyboardStateChange = this.onKeyboardStateChange.bind(this); this.onConfirmDialogOpen = this.onConfirmDialogOpen.bind(this); this.onConfirmDialogClose = this.onConfirmDialogClose.bind(this); this.performExit = this.performExit.bind(this); this.handleInputFocus = this.handleInputFocus.bind(this); this.handleInputBlur = this.handleInputBlur.bind(this); this.handleKeyboardInput = this.handleKeyboardInput.bind(this); this.handleKeyboardDismiss = this.handleKeyboardDismiss.bind(this); this.onMouseMoveListener = this.onMouseMove.bind(this); this.onScrollListener = this.onScroll.bind(this); this.onMouseDownListener = this.onMouseDown.bind(this); this.handlePhysicalKeyboardDismiss = this.handlePhysicalKeyboardDismiss.bind(this); this.confirmDialog.setConfirmAction(this.performExit); this.confirmDialog.setCancelAction(this.onConfirmDialogClose); this.longPressDetector = new KeyboardLongPressManager(); this.accessibilityManager = AccessibilityManager.getInstance(); } componentDidMount() { window.addEventListener("keydown", this.onKeyDownListener, true); window.addEventListener("keyup", this.onKeyUp); window.addEventListener("mousemove", this.onMouseMoveListener); document.addEventListener("wheel", this.onScrollListener, { passive: true, }); document.addEventListener("mousedown", this.onMouseDownListener); document.addEventListener( "keyboardStateChange", this.onKeyboardStateChange, false ); document.addEventListener("focusin", this.handleInputFocus); document.addEventListener( "cursorStateChange", this.cursorVisibilityChange, false ); window.addEventListener("onLongPress", this.handleLongPress.bind(this)); window.addEventListener( "onPressRelease", this.handleLongPressRelease.bind(this) ); window.addEventListener( "onLongPressRelease", this.handleLongPressRelease.bind(this) ); document.addEventListener( "keyboardDismissedByPhysicalKeypress", this.handlePhysicalKeyboardDismiss ); } componentWillUnmount() { window.removeEventListener("keydown", this.onKeyDownListener); window.removeEventListener("keyup", this.onKeyUp); document.removeEventListener("wheel", this.onScrollListener); document.removeEventListener( "cursorStateChange", this.cursorVisibilityChange ); document.removeEventListener("focusin", this.handleInputFocus); document.removeEventListener("mousedown", this.onMouseDownListener); if (this.timeout) { this.clearTimeout(); } this.isDismissing = false; this.longPressDetector.cleanup(); window.removeEventListener("onLongPress", this.handleLongPress); window.removeEventListener( "onLongPressRelease", this.handleLongPressRelease ); document.removeEventListener( "keyboardDismissedByPhysicalKeypress", this.handlePhysicalKeyboardDismiss ); } performExit() { if (isSamsungPlatform()) { this.onConfirmDialogClose(); sendQuickBrickEvent(QUICK_BRICK_EVENTS.MOVE_APP_TO_BACKGROUND); } if (isLgPlatform()) { // webOS recommend to use this method when implemention a custom exit globalAny.close(); } if (isVizioPlatform()) { window?.VIZIO?.exitApplication(); } } handleWebOsBack() { const systemBack = globalAny?.webOS?.platformBack; if (systemBack) { systemBack(); } else { /** * In case webOS failed to show the exit pop-up, we show a regular promtp as fallback */ showConfirmationDialog({ title: "", confirmCompletion: this.performExit, }); } } /** * This methods sends the app to background in whatever way is appropriate for each platform * In the case of Tizen it shows a custom dialog on confirm it exits the app * WebOS behavior depends on the SDK version, WebOS 4 would pull up the launcher menu * following versions would show a native exit prompt */ platformBack() { try { // TODO: temporary hack until this code is refactored if (isSamsungPlatform() || isVizioPlatform()) { const { isDialogVisible } = confirmationDialogStore.getState(); if (isDialogVisible) { this.onConfirmDialogClose(); } else { showConfirmationDialog({ title: "", confirmCompletion: this.performExit, cancelCompletion: this.onConfirmDialogClose, }); } } else if (isLgPlatform()) { this.handleWebOsBack(); } } catch (e) { log_warning("Failed to execute platformBack", e); } } cursorVisibilityChange(event) { const visible = event.detail.visibility; const { setDisplayState, displayState } = this.props; if (visible && displayState === DISPLAY_STATES.PLAYER) { setDisplayState(DISPLAY_STATES.HUD); this.resetHudTimeout(); } } onMouseMove() { const { setDisplayState, displayState } = this.props; if (displayState === DISPLAY_STATES.PLAYER) { setDisplayState(DISPLAY_STATES.HUD); this.resetHudTimeout(); } } isHomeScreen() { const { navigator } = this.props; const { rivers } = this.props; const homeRiver = getHomeRiver(rivers); const homePath = `/river/${homeRiver?.id}`; return homePath === navigator.currentRoute; } goToHome() { const { navigator } = this.props; const { rivers } = this.props; const homeRiver = getHomeRiver(rivers); navigator.replace(homeRiver); } resetHudTimeout() { const { resetHudTimer = noop } = this.props; resetHudTimer?.(); } clearTimeout() { if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } } cancelHudTimeout() { this.resetHudTimeout(); } onKeyUp(event: KeyboardEvent) { const { setDisplayState } = this.props; if (this.keySupportsLongPress(event)) { this.longPressDetector.endKeyPressMonitoring(event.code); } if ( keyCode(event).matches(KEYS.Enter) || (__DEV__ && keyCode(event).matches(KEYS.X)) ) { focusManager.pressOut(); } if ( keyCode(event).matches(KEYS.Forward) || (__DEV__ && keyCode(event).matches(KEYS.C)) ) { setDisplayState(DISPLAY_STATES.HUD); KeyInputHandler.getInstance().onForwardPressOut(); } if ( keyCode(event).matches(KEYS.Rewind) || (__DEV__ && keyCode(event).matches(KEYS.Z)) ) { setDisplayState(DISPLAY_STATES.HUD); KeyInputHandler.getInstance().onRewindPressOut(); } } /** When pressing escape or the Native "Back" button: if on the transport controls: we show the player if on the player : we show the UI if on the UI : we exit the app note: backspace affects the keyboard input, so it behaves differently */ onBackPress(event) { const { displayState, setDisplayState, navigator, backToTopAction, emitFocusOnSelectedTopMenuItem, emitFocusOnSelectedTab, } = this.props; const { isDialogVisible } = confirmationDialogStore.getState(); const { isKeyboardVisible } = this.state; log_debug("onBackPress", { eventCode: event.code, displayState, isDialogVisible, isKeyboardVisible, }); if (isKeyboardVisible && shouldUseOnScreenKeyboard()) { this.handleKeyboardDismiss(); event.preventDefault(); event.stopPropagation(); return; } const action = backToTopAction(); switch (displayState) { case DISPLAY_STATES.DEFAULT: if (action === "PLATFORM_BACK") { log_info(action); this.platformBack(); } if (action === "GO_HOME") { log_info(action); this.goToHome(); } if (action === "FOCUS_ON_SELECTED_TOP_MENU_ITEM") { log_info(action); // we don't have enough context at this point to set focus properly on selected top-menu-item, just emit event about it. // TopMenu component is supposed to handle this event. emitFocusOnSelectedTopMenuItem(); } if (action === "FOCUS_ON_SELECTED_TAB_ITEM") { log_info(action); // we don't have enough context at this point to set focus properly on selected tab-menu-item, just emit event about it // Tabs component is supposed to handle this event. emitFocusOnSelectedTab(); } if (action === "GO_BACK") { log_info(action); if (navigator.canGoBack()) { navigator.goBack(); } } if (action === "GO_BACK_FROM_HOOK") { log_info(action); const fallbackToHome = false; const fromHook = true; navigator.goBack(fallbackToHome, fromHook); } break; case DISPLAY_STATES.PLAYER: if (isDialogVisible) { this.onConfirmDialogClose(); } else if (navigator.canGoBack()) { navigator.goBack(); } else if (this.isHomeScreen()) { this.platformBack(); } else if (navigator.currentRoute.includes("hook")) { // If code reaches this block navigator cant go back, it is not home, // and this is a login plugin that was pushed on top of the home // Treat it like a home screen and run platformBack() const entryData = navigator?.data?.entry; const isHookInHomescreen = entryData?.payload?.home; const isHookFlowBlocker = typeof entryData?.hookPlugin?.module?.isFlowBlocker === "function" ? entryData?.hookPlugin?.module?.isFlowBlocker() : entryData?.hookPlugin?.module?.isFlowBlocker; if (isHookInHomescreen && isHookFlowBlocker) { this.platformBack(); } else { navigator.goBack(false, true); } } else { this.goToHome(); } break; case DISPLAY_STATES.HUD: // IF transport controls are open, close them instead of closing player setDisplayState(DISPLAY_STATES.PLAYER); break; default: setDisplayState(DISPLAY_STATES.PLAYER); break; } } onBackspacePress(event) { const isInputFocused = document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement; // Don't handle backspace if input is focused if (isInputFocused) { return; } this.onBackPress(event); } onKeyDown(event) { const { displayState, setDisplayState, navigator, xray } = this.props; const { isDialogVisible } = confirmationDialogStore.getState(); const { isKeyboardVisible } = this.state; if ( xray?.isPromptEnabled || (isKeyboardVisible && shouldUseOnScreenKeyboard()) ) { return; } if (keyCode(event).matches("Backspace")) { this.onBackspacePress(event); return; } if (keyCode(event).matchesAny(KEYS.Back, KEYS.Escape, KEYS.EscapeDevice)) { this.onBackPress(event); return; } if (this.keySupportsLongPress(event)) { this.longPressDetector.monitorKeyPress(event.code, event); } // Only handle repeat events for arrow keys if (event.repeat && !keyCode(event).matchesAny(...ARROW_KEYS)) return; /** When the player is displayed, we listen to Native Play / Pause / Stop buttons. Any other key press will display the transport controls */ if ( displayState === DISPLAY_STATES.PLAYER || displayState === DISPLAY_STATES.HUD ) { /** registering native keys for playback */ if (keyCode(event).matches(KEYS.TogglePlayPause)) { const { playing } = playerManager.getState() || {}; if (!playing) { return playerManager.togglePlayPause(); } } if (keyCode(event).matches(KEYS.Play)) { return playerManager.play(); } if (keyCode(event).matches(KEYS.Pause)) { setDisplayState(DISPLAY_STATES.HUD); return playerManager.pause(); } if (keyCode(event).matches(KEYS.Stop)) { setDisplayState(DISPLAY_STATES.DEFAULT); navigator.goBack(); return; } if ( keyCode(event).matches(KEYS.Forward) || (__DEV__ && keyCode(event).matches(KEYS.C)) ) { setDisplayState(DISPLAY_STATES.HUD); KeyInputHandler.getInstance().onForwardPress(); return; } if ( keyCode(event).matches(KEYS.Rewind) || (__DEV__ && keyCode(event).matches(KEYS.Z)) ) { setDisplayState(DISPLAY_STATES.HUD); KeyInputHandler.getInstance().onRewindPress(); return; } } if ( keyCode(event).matches(KEYS.Enter) || (__DEV__ && keyCode(event).matches(KEYS.X)) ) { if (displayState === DISPLAY_STATES.PLAYER) { setDisplayState(DISPLAY_STATES.HUD); this.accessibilityManager.addHeading( VIDEO_PLAYER_CONTROLS_NAVIGATION_MESSAGE ); focusManager.recoverFocus(); this.resetHudTimeout(); } else { focusManager.pressIn(); focusManager.press(); } return true; } if (keyCode(event).matches(KEYS.Exit)) { if (isSamsungPlatform() || shouldUseOnScreenKeyboard()) { this.performExit(); return; } this.platformBack(); } if (keyCode(event).matchesAny(...ARROW_KEYS)) { if (displayState === DISPLAY_STATES.PLAYER) { setDisplayState(DISPLAY_STATES.HUD); } this.checkSequence(event.keyCode); const direction = keyCode(event).direction(); if (!isDialogVisible || !["up", "down"].includes(direction.value)) { focusManager.moveFocus(direction); } if (!isDialogVisible) { // run focus recovery on user interaction attempt // probably we failed to set initial focus for some reason focusManager.recoverFocus(); } if ( displayState === DISPLAY_STATES.PLAYER || displayState === DISPLAY_STATES.HUD ) { this.resetHudTimeout(); } if (displayState === DISPLAY_STATES.PLAYER) { this.accessibilityManager.addHeading( VIDEO_PLAYER_CONTROLS_NAVIGATION_MESSAGE ); } return true; } } onScroll(event) { const { displayState } = this.props; const deltaY = event?.deltaY; const DIRECTION = { axis: "y", isHorizontal: false, isVertical: true, value: "", }; if (deltaY > 0) { DIRECTION.value = isLgPlatform() ? "down" : "up"; } else if (deltaY < 0) { DIRECTION.value = isLgPlatform() ? "up" : "down"; } if ( displayState === DISPLAY_STATES.PLAYER || displayState === DISPLAY_STATES.HUD ) { this.resetHudTimeout(); } else { focusManager.moveFocus(DIRECTION); } } onMouseDown(event) { const { button } = event; const { displayState } = this.props; if ( button === 1 && // number 1 represents press on the scroll wheel (displayState === DISPLAY_STATES.PLAYER || displayState === DISPLAY_STATES.HUD) ) { this.resetHudTimeout(); } } onKeyboardStateChange(event) { this.setState({ isKeyboardVisible: event.visibility }); } onConfirmDialogOpen() { this.confirmDialog.showDialog(); } onConfirmDialogClose() { this.confirmDialog.hideDialog(); const context: FocusManager.FocusContext = { source: "cancel", preserveScroll: true, }; // restore initial focus after closing dialog(with preserve scrolling) focusManager.setInitialFocus(undefined, context); } handleInputFocus = (event: FocusEvent) => { if (!shouldUseOnScreenKeyboard()) return; const target = event.target as HTMLInputElement; if (this.isDismissing) return; if ( (target.tagName === "INPUT" && target.type === "text") || (target.tagName === "INPUT" && target.type === "password") || target.tagName === "TEXTAREA" ) { this.setState({ isKeyboardVisible: true, keyboardInput: target.value || "", activeInputRef: target, }); } }; handleInputBlur(event: FocusEvent) { if (!shouldUseOnScreenKeyboard()) return; const target = event.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") { const relatedTarget = event.relatedTarget as HTMLElement; if ( !relatedTarget || (relatedTarget.tagName !== "INPUT" && relatedTarget.tagName !== "TEXTAREA") ) { this.setState({ isKeyboardVisible: false }); } } } handleKeyboardDismiss() { this.isDismissing = true; this.setState( { isKeyboardVisible: false, keyboardInput: "", activeInputRef: null, }, () => { this.isDismissing = false; focusManager.recoverFocus(); } ); } handleKeyboardInput = (value: string) => { const { activeInputRef } = this.state; if (activeInputRef) { // Update the input's value activeInputRef.value = value; // Dispatch keyboard update event const keyboardEvent = new CustomEvent("keyboardUpdate", { detail: { value }, }); document.dispatchEvent(keyboardEvent); } this.setState({ keyboardInput: value }); }; // TODO: use RemoteSequenceHandler class private checkSequence(key: number) { const SINK_SEQUENCE = [ KEYS.ArrowUp.keyCode, KEYS.ArrowUp.keyCode, KEYS.ArrowDown.keyCode, KEYS.ArrowDown.keyCode, KEYS.ArrowLeft.keyCode, KEYS.ArrowRight.keyCode, KEYS.ArrowLeft.keyCode, KEYS.ArrowRight.keyCode, ]; this.resetSequenceTimeout(); this.userSequence.push(key); const shouldAddSink = this.userSequence.toString() === SINK_SEQUENCE.toString(); if (shouldAddSink) { this.props.xray.addRemoteSink({}); } } private resetSequenceTimeout() { const SEQUENCE_ENTRY_TIME = 1900; if (this.sequenceTimeout) { clearTimeout(this.sequenceTimeout); } this.sequenceTimeout = setTimeout(() => { this.sequenceTimeout = null; this.userSequence = []; }, SEQUENCE_ENTRY_TIME); } // TODO: Delete it on next PR if not needed private handleLongPress = (event: CustomEvent) => { const { keyCode } = event.detail; this.longPressActive = true; this.cancelHudTimeout(); switch (keyCode) { case KEYS.Enter.code: case KEYS.X.code: focusManager.longPress(); break; } }; // TODO: Delete it on next PR if not needed private handleLongPressRelease = (event: CustomEvent) => { const { keyCode } = event.detail; this.longPressActive = false; this.resetHudTimeout(); switch (keyCode) { case KEYS.Enter.code: case KEYS.X.code: // focusManager.pressOut(); break; } }; private keySupportsLongPress(event: KeyboardEvent): boolean { return [ KEYS.Forward.code, KEYS.Rewind.code, KEYS.Enter.code, KEYS.Z.code, KEYS.X.code, KEYS.C.code, ].includes(event.code); } handlePhysicalKeyboardDismiss(event: CustomEvent) { const { value, position, inputElement } = event.detail; this.isDismissing = true; this.setState( { isKeyboardVisible: false, keyboardInput: "", activeInputRef: null, }, () => { if (inputElement) { inputElement.value = value; inputElement.focus(); inputElement.setSelectionRange(position, position); const keyboardEvent = new CustomEvent("keyboardUpdate", { detail: { value }, }); document.dispatchEvent(keyboardEvent); } this.isDismissing = false; } ); } render() { return ( (shouldUseOnScreenKeyboard() && this.state.isKeyboardVisible && ( )) || null ); } } export const InteractionManager = R.compose( withNavigator, withXray, connectToStore(R.pick(["rivers"])), PlayerContentContext.withConsumer, DisplayStateContext.withConsumer, withBackToTopActionHOC )(InteractionManagerClass);