@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
text/typescript
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);
};
}