@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
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 { 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);
};
}