UNPKG

@applicaster/zapp-react-native-ui-components

Version:

Applicaster Zapp React Native ui components for the Quick Brick App

741 lines (590 loc) 20.5 kB
import { Player } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/player"; import { PlayerLifecycleListener, playerManager, } from "@applicaster/zapp-react-native-utils/appUtils/playerManager"; import { setUserCellPlayerMutedPreference } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference"; import { playerFactory } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory"; import { PlayerRole } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/conts"; import { loggerLiveImageManager } from "./loggerHelper"; import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils"; import { executePreloadHooks, PreloadHookConfig, } from "../MasterCell/DefaultComponents/LiveImage/executePreloadHooks"; const TIMEOUT_FOR_DELAY_CHECK_PLAYER_POSITION = 500; // ms const { log_debug, log_info, log_error } = loggerLiveImageManager; export type LiveImageManagerEvent = | "onEnd" | "onStart" | "onError" | "onDestroy"; export type LiveImageManagerListenerData = { id: string; listener: Record< LiveImageManagerEvent, (event?: Record<string, any>) => void >; }; export enum LiveImageType { Video = "video", Image = "image", } type Position = { centerX: number; centerY: number; top: number; bottom: number; left: number; right: number; }; type PlayerFactoryConfig = { player: any; // React ref for native view playerId: string; playerPluginId: string; screenConfig: Record<string, any>; entry: ZappEntry; // original entry, used as fallback if no hooks }; type LiveImageProps = { playerId: string; setMode?: (type: LiveImageType) => void; preloadHooks?: PreloadHookConfig[]; factoryConfig: PlayerFactoryConfig; tag: string; }; // Disabled because we have only unmute button but no play/pause state anymore const IS_ALLOWED_REPLAY = false; const playerInfo = (player: Player | null): string => player ? `playerId: ${player.playerId}, title: ${player.getEntry()?.title}` : "null"; export class LiveImageManager implements PlayerLifecycleListener { protected items: LiveImage[] = []; // Only allow one player to play at a time for now protected currentlyPlaying: LiveImage | null = null; protected primaryPlayer: Player | null = null; private checkPlayerPositionTimeout: ReturnType<typeof setTimeout> | null = null; protected listeners: Record< string, Record<LiveImageManagerEvent, (event?: Record<string, any>) => void> > = {}; constructor() { playerManager.addLifecycleListener(this); this.listeners = {}; } private static _instance: LiveImageManager; public static get instance() { return this._instance || (this._instance = new this()); } public register = (item: LiveImage): (() => void) => { this.items.push(item); log_debug(`register: live image ${item.playerId} - ${item.tag}`); // TV only Start playing video once registered if (isTV()) { this.playLiveImage(item); } return () => this.unregister(item); }; public unregister = (item: LiveImage) => { log_debug(`unregister: live-image ${item.playerId} - ${item.tag}`); if (this.currentlyPlaying === item) { this.currentlyPlaying = null; log_debug( `unregister: currently playing live-image was destroyed, ${item.playerId} - ${item.tag}` ); // TODO: Maybe start another one } this.items = this.items.filter((i) => i !== item); this.invokeListenersUpdate({ callbackName: "onDestroy", event: { item, playerId: this.currentlyPlaying?.playerId, primaryPlayerId: this.primaryPlayer?.playerId, entry: item.getPlayer()?.getEntry(), }, }); }; private cancelCheckPlayerPositionTimeout = () => { clearTimeout(this.checkPlayerPositionTimeout); this.checkPlayerPositionTimeout = null; }; public onViewportEnter = (item: LiveImage) => { log_debug( `onViewportEnter: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)} - position:${item.positionToString()}` ); if (!isTV()) { // mobile only // we have to delay running checkPlayerPosition, because sometimes on fast scrolling we get wrong order onEnter, then onLeave. // which could cause select wrong item to play this.cancelCheckPlayerPositionTimeout(); this.checkPlayerPositionTimeout = setTimeout(() => { this.cancelCheckPlayerPositionTimeout(); this.checkPlayerPosition(item); }, TIMEOUT_FOR_DELAY_CHECK_PLAYER_POSITION); } else { this.checkPlayerPosition(item); } }; public onViewportLeave = (item: LiveImage) => { log_debug( `onViewportLeave: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)} - position:${item.positionToString()}` ); this.pauseItem(item); }; public getCurrentlyPlaying = () => { return this.currentlyPlaying; }; private findNextPlayableItem = () => { if (isTV()) { return this.items[this.items.length - 1]; } return ( this.items .filter(({ isFullyVisible }) => isFullyVisible) .sort((a: LiveImage, b: LiveImage) => { return a.position.centerX - b.position.centerX; }) .sort((a: LiveImage, b: LiveImage) => { const a1 = Math.abs(a.position.centerY - 0.5); const b1 = Math.abs(b.position.centerY - 0.5); return a1 - b1; })?.[0] || this.currentlyPlaying ); }; private findItem = (playerId: string): LiveImage | null => this.items.find((i) => i.playerId === playerId) || null; private pauseItem = (item: LiveImage) => { log_debug(`pauseItem: live-image ${item.playerId} - ${item.tag}`); if (!item.player) { // Player not yet created (e.g. hooks still running) — just reset mode item.setMode?.(LiveImageType.Image); if (item === this.currentlyPlaying) { this.currentlyPlaying = null; } return; } if (!item.player.playerState.isReadyToPlay) { log_debug( `pauseItem: live-image not ready, ${item.playerId} - ${item.tag}` ); } else { item.player.pause(); } // Fake close event, because we unmount native view item.player.onPlayerClose(); item.setMode?.(LiveImageType.Image); if (item === this.currentlyPlaying) { this.currentlyPlaying = null; } }; public playLiveImage = (item: LiveImage) => { log_debug( `playLiveImage: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)}` ); if (this.primaryPlayer) { return; } if (this.currentlyPlaying) { if (this.currentlyPlaying.playerId === item.playerId) { return; } else { this.pauseItem(this.currentlyPlaying); } } this.currentlyPlaying = item; item .prepareForPlayback() .then((result) => { if (!result) { log_error( `Failed to prepare live image ${item.playerId} - ${item.tag} for playback: prepareForPlayback returned false` ); this.currentlyPlaying = null; item.setMode?.(LiveImageType.Image); return; } // Guard: item might have been replaced while hooks were running if (this.currentlyPlaying !== item) return; item.setMode?.(LiveImageType.Video); if (item.player?.playerState.isReadyToPlay) { item.player.play(); } }) .catch((error) => { log_error( `Failed to prepare live image ${item.playerId} - ${item.tag} for playback: ${error?.message}` ); this.onLiveImageError(item, error); }); }; public pauseLiveImage = (item: LiveImage) => { log_debug( `pauseLiveImage: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)}` ); this.pauseItem(item); }; public onLiveImageCompleted = (_item: LiveImage) => { // TODO: Notify listeners that player has died // Do not try look for new playable, otherwise you will restart video finish to play }; public muteAll = () => { log_debug("muteAll"); setUserCellPlayerMutedPreference(true); this.items.forEach((liveImage) => liveImage.mute()); }; public unmuteAll = () => { log_debug("unmuteAll"); setUserCellPlayerMutedPreference(false); this.items.forEach((liveImage) => liveImage.unmute()); }; public onViewPositionChanged = (item: LiveImage) => { log_debug( `onViewPositionChanged: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)} - position:${item.positionToString()}` ); if (!isTV()) { // mobile only // we have to delay running checkPlayerPosition, because sometimes on fast scrolling we get wrong order onEnter, then onLeave. // which could cause select wrong item to play this.cancelCheckPlayerPositionTimeout(); this.checkPlayerPositionTimeout = setTimeout(() => { this.cancelCheckPlayerPositionTimeout(); this.checkPlayerPosition(item); }, TIMEOUT_FOR_DELAY_CHECK_PLAYER_POSITION); } else { this.checkPlayerPosition(item); } }; private checkPlayerPosition = (item: LiveImage) => { this.cancelCheckPlayerPositionTimeout(); log_debug( `checkPlayerPosition: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)}` ); const playerItem = this.findNextPlayableItem(); if (playerItem) { if (!playerItem.isFullyVisible) { log_error( `checkPlayerPosition: trying to start playback currently invisible item: ${playerInfo( playerItem.player )}` ); } // Will also check if it's item that already playing this.playLiveImage(playerItem); } }; onPrimaryPlayerClosed = () => { const playerItem = this.findNextPlayableItem(); log_info("onPrimaryPlayerClosed: starting to play visible live-image"); this.currentlyPlaying = null; if (playerItem) { this.playLiveImage(playerItem); } }; onPrimaryPlayerCreated = () => { log_info("onPrimaryPlayerCreated: pausing visible live-images"); if (this.currentlyPlaying) { this.pauseItem(this.currentlyPlaying); } }; private liveImageManagerListenerId = "live-image-manager"; onRegistered = (player: Player) => { const item = this.findItem(player.playerId); if (item) { return; } player.addListener({ id: this.liveImageManagerListenerId, listener: { onVideoEnd: (event) => () => this.onPrimaryPlayerEnded(player, event), onError: (event) => () => this.onPrimaryPlayerError(player, event), onLoad: (event) => () => this.onPrimaryPlayerLoad(player, event), }, }); // Should not happen with current architecture if (!this.primaryPlayer) { this.primaryPlayer = player; this.onPrimaryPlayerCreated(); } else { log_error( `onRegistered: multiple primary players not allowed, primary ${playerInfo( this.primaryPlayer )}` ); } }; onUnRegistered = (player: Player) => { const item = this.findItem(player.playerId); if (item) return; player.removeListener(this.liveImageManagerListenerId); if (player === this.primaryPlayer) { this.primaryPlayer = null; this.onPrimaryPlayerClosed(); } }; // Primary player callbacks onPrimaryPlayerEnded = (_player: Player, _event) => { // Not used now, primary player has been destroyed }; onPrimaryPlayerError = (_player: Player, _event) => { // Not used now, primary player has been destroyed }; onPrimaryPlayerLoad = (_player: Player, _event) => { // Not used now, we stop playing when primary player is registered }; // Live Image player callbacks onLiveImageVideoLoaded = (item: LiveImage) => { log_debug( `onLiveImageVideoLoaded: live-image ${playerInfo( item.player )}, currentPlayingId: ${ this.currentlyPlaying?.playerId }, primaryPlayerId: ${this.primaryPlayer?.playerId}` ); if (this.currentlyPlaying === item) { item.player?.play(); } this.invokeListenersUpdate({ callbackName: "onStart", event: { item, playerId: this.currentlyPlaying?.playerId, primaryPlayerId: this.primaryPlayer?.playerId, entry: item.getPlayer()?.getEntry(), }, }); }; onLiveImageEnded = (item: LiveImage) => { log_debug( `onLiveImageEnded: live-image ${playerInfo( item.player )}, currentPlayingId: ${ this.currentlyPlaying?.playerId }, primaryPlayerId: ${this.primaryPlayer?.playerId}` ); const isCurrentItemEnded = this.currentlyPlaying === item; // TODO: This branch was used when we had play button to toggle replay. // Now we mute button instead but we keep it just in case. if (IS_ALLOWED_REPLAY && !isTV() && isCurrentItemEnded) { this.currentlyPlaying?.player.seekTo(0); this.currentlyPlaying?.player.pause(); // TODO: ...Maybe player some other item } else { item.setMode?.(LiveImageType.Image); } // Prevent receiving onEnd event from both `close` and `end` player events isCurrentItemEnded && this.invokeListenersUpdate({ callbackName: "onEnd", event: { item, playerId: this.currentlyPlaying?.playerId, primaryPlayerId: this.primaryPlayer?.playerId, entry: item.getPlayer()?.getEntry(), }, }); }; onLiveImageError = (item: LiveImage, error: Error) => { item.setMode?.(LiveImageType.Image); const currentItem = this.currentlyPlaying; log_debug( `onLiveImageError: error: ${error.message}, live-image ${playerInfo( item.player )}, currentPlayingId: ${ this.currentlyPlaying?.playerId }, primaryPlayerId: ${this.primaryPlayer?.playerId}` ); if (currentItem === item) { this.currentlyPlaying = null; log_debug( `onLiveImageError: currentItem: ${currentItem.playerId} - ${currentItem.tag} was removed` ); // TODO: ...Maybe player some other item } this.invokeListenersUpdate({ callbackName: "onError", event: { item, error, playerId: currentItem?.playerId, primaryPlayerId: this.primaryPlayer?.playerId, entry: item.getPlayer()?.getEntry(), }, }); }; addListener = ({ id, listener }: LiveImageManagerListenerData) => { this.listeners[id] = listener; return () => this.removeListener(id); }; removeListener = (id: string) => { delete this.listeners[id]; }; public invokeListenersUpdate = ({ callbackName, event = {}, }: { callbackName: LiveImageManagerEvent; event?: Record<string, any>; }) => { for (const [key, listener] of Object.entries(this.listeners)) { try { listener[callbackName]?.(event); } catch (error) { log_error( `invokeListenersUpdate: Error: listenerId: ${key}, invoking callback: ${callbackName}, message: ${error?.message}`, { error } ); } } }; } // TODO: now we can check primary player exist and we can remove this hack // then primary player was created and LiveImageManager doesn't know that primary player exist // Happens when home screen has LiveImage and deep link open a player // https://applicaster.monday.com/boards/1615228456/pulses/5979392200?notification=4136716156 LiveImageManager.instance; export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks { public player: Player | null = null; public setMode?: (type: LiveImageType) => void; public isFullyVisible: boolean = false; public tag: string; public position: Position = { centerX: 0, centerY: 0, top: 0, bottom: 0, right: 0, left: 0, }; // Keeps muted state in sync: updates the initial value for deferred // player creation and forwards to the player if it already exists. public initiallyMuted: boolean = true; mute = () => { this.initiallyMuted = true; this.player?.mute(); }; unmute = () => { this.initiallyMuted = false; this.player?.unmute(); }; positionToString() { if (!this.position) { return "position not set"; } const { centerX, centerY, top, bottom, left, right } = this.position; return `centerX: ${centerX.toFixed(2)}, centerY: ${centerY.toFixed(2)}, top: ${top.toFixed(2)}, bottom: ${bottom.toFixed(2)}, left: ${left.toFixed(2)}, right: ${right.toFixed(2)}`; } readonly playerId: string; public component: any = null; private factoryConfig: PlayerFactoryConfig; public preloadHooks?: PreloadHookConfig[]; public processedEntry: ZappEntry | null = null; private _preparePromise: Promise<boolean> | null = null; constructor(props: LiveImageProps) { this.setMode = props.setMode; this.playerId = props.playerId; this.preloadHooks = props.preloadHooks; this.factoryConfig = props.factoryConfig; this.tag = props.tag || "untagged"; } async prepareForPlayback(): Promise<boolean> { // Already prepared — player exists if (this.player) { return true; } // Deduplicate: if preparation is already in flight, await the same promise if (this._preparePromise) { return this._preparePromise; } this._preparePromise = this.createPreparedPlayer() .then((result) => { this._preparePromise = null; return result; }) .catch((error) => { this._preparePromise = null; log_error( `prepareForPlayback: live-image ${this.playerId}, error preparing for playback: ${error?.message}`, { error } ); throw error; }); return this._preparePromise; } private async createPreparedPlayer(): Promise<boolean> { let entry = this.factoryConfig.entry; if (this.preloadHooks?.length) { const result = await executePreloadHooks({ preloadHooks: this.preloadHooks, entry, }); if (!result) return false; this.processedEntry = result; entry = result; } const factoryItem = await playerFactory({ player: this.factoryConfig.player, playerId: this.factoryConfig.playerId, autoplay: false, entry, muted: this.initiallyMuted, playerPluginId: this.factoryConfig.playerPluginId, screenConfig: this.factoryConfig.screenConfig, playerRole: PlayerRole.Cell, }); if (!factoryItem) { throw new Error( `Player factory returned null (playerId: ${this.factoryConfig.playerId}, playerPluginId: ${this.factoryConfig.playerPluginId})` ); } this.player = factoryItem.controller; this.component = factoryItem.Component; this.player.addListener({ id: "live-image", listener: this }); return true; } public getPlayer = (): Player | null => { return this.player; }; onLoad = (_event) => { log_info( `onLoad: live-image, video loaded, switching to video, ${playerInfo( this.player )}` ); LiveImageManager.instance.onLiveImageVideoLoaded(this); }; onError = (error: Error) => { log_error( `onError: live-image, fail to play, switching back to image. error: ${ error?.message }, ${playerInfo(this.player)}` ); LiveImageManager.instance.onLiveImageError(this, error); }; onVideoError = (error: Error) => { log_error( `onVideoError: live-image, fail to play, switching back to image. error: ${ error?.message }, ${playerInfo(this.player)}` ); LiveImageManager.instance.onLiveImageError(this, error); }; onVideoEnd = (_event) => { log_info( `onVideoEnd: live-image, video completed, switching back to image, ${playerInfo( this.player )}` ); LiveImageManager.instance.onLiveImageEnded(this); }; onPlayerClose = () => { log_info( `onPlayerClose: live-image, player closed, switching back to image, ${playerInfo( this.player )}` ); // TODO: we maybe want to add onPlayerClose to separate completion behavior LiveImageManager.instance.onLiveImageEnded(this); }; }