UNPKG

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

Version:

Applicaster Zapp React Native ui components for the Quick Brick App

585 lines (471 loc) • 15.6 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 { loggerLiveImageManager } from "./loggerHelper"; import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils"; import { Component } from "react"; 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 LiveImageProps = { player: Player; playerId: string; setMode?: (type: LiveImageType) => void; component: Component; // TODO: ...primary, powerCell, tvGallery, etc. // type: 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 ${playerInfo(item.player)}`); // 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 ${playerInfo(item.player)}`); if (this.currentlyPlaying === item) { this.currentlyPlaying = null; log_debug( `unregister: currently playing live-image was destroyed, ${playerInfo( item.player )}` ); // 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 ${playerInfo( item.player )}, primary ${playerInfo(this.primaryPlayer)}` ); 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 playerId: ${playerInfo( item.player )}, primary ${playerInfo(this.primaryPlayer)}` ); 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 ${playerInfo(item.player)}`); if (!item.player.playerState.isReadyToPlay) { log_debug( `playItem: live-image not ready, will start playback after loading, ${playerInfo( item.player )}` ); } 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 ${playerInfo( item.player )}, primary ${playerInfo(this.primaryPlayer)}` ); if (this.primaryPlayer) { return; } if (this.currentlyPlaying) { if (this.currentlyPlaying?.player?.playerId === item.player.playerId) { return; } else { this.pauseItem(this.currentlyPlaying); } } this.currentlyPlaying = item; item.setMode?.(LiveImageType.Video); if (item.player.playerState.isReadyToPlay) { item.player.play(); } }; public pauseLiveImage = (item: LiveImage) => { log_debug( `pauseLiveImage: live-image playerId: ${playerInfo( item.player )}, 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.player.mute()); }; public unmuteAll = () => { log_debug("unmuteAll"); setUserCellPlayerMutedPreference(false); this.items.forEach((liveImage) => liveImage.player.unmute()); }; public checkPlayerPosition = (item: LiveImage) => { this.cancelCheckPlayerPositionTimeout(); log_debug( `checkPlayerPosition: live-image playerId: ${playerInfo( item.player )}, 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} was removed` ); // TODO: ...Maybe player some other item } this.invokeListenersUpdate({ callbackName: "onError", event: { item, error, playerId: currentItem?.playerId, primaryPlayerId: currentItem?.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; public setMode: (type: LiveImageType) => void; // Will be replaced with rects public isFullyVisible: boolean = false; public position: Position = { centerX: 0, centerY: 0, top: 0, bottom: 0, right: 0, left: 0, }; readonly playerId: string; readonly component: Component; constructor(props: LiveImageProps) { this.player = props.player; this.setMode = props.setMode; this.playerId = this.player.playerId; this.component = props.component; this.player.addListener({ id: "live-image", listener: this }); } public getPlayer = (): Player => { 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); }; }