@applicaster/zapp-react-native-utils
Version:
Applicaster Zapp React Native utilities package
738 lines (593 loc) • 20 kB
text/typescript
import * as R from "ramda";
import { BehaviorSubject } from "rxjs";
import { isEmptyOrNil } from "@applicaster/zapp-react-native-utils/cellUtils";
import { createLogger, utilsLogger } from "../../logger";
import { getBoolFromConfigValue } from "../../configurationUtils";
import { PlayerRole, SharedPlayerCallBacksKeys } from "./conts";
import { OverlaysObserver } from "./OverlayObserver/OverlaysObserver";
const logger = createLogger({
category: "PlayerController",
subsystem: "General",
parent: utilsLogger,
});
const { log_verbose, log_info, log_error } = logger;
export declare type PlayerControllerListenerData = {
id: string;
listener: QuickBrickPlayer.SharedPlayerCallBacks;
};
/**
* Player Instance Controller
* Instance exposes utility methods that will help set or get state
* Keeps track of the difference between ad content and regular content
* Creates and keeps track of all of the player instances created
* Inherited all of the methods that used to exist in the player manager
* PlayerManager continues to be used for register, unregister of player,
* and subscription to events. We only support one castInstance at a time.
* When using playerManager.on we have to use fat arrow to not loose context of this
*/
// TODO: Must be abstract, can do it until fill be fixed babel/metro/webpack
export class Player {
readonly playerId: string;
readonly playerState: QuickBrickPlayer.PlayerState;
public playerPluginId: string;
protected _entry: ZappEntry | null;
protected config?: Record<string, any>;
protected startPosition?: number | null;
readonly playerRole: PlayerRole = PlayerRole.Unspecified;
private overlaysObserver?: OverlaysObserver | null;
private entryObservable?: BehaviorSubject<ZappEntry> | null;
protected listeners: {
[key: string]: QuickBrickPlayer.SharedPlayerCallBacks;
} = {};
get entry(): ZappEntry | null {
return this._entry;
}
set entry(newEntry: ZappEntry | null) {
this._entry = newEntry;
this.entryObservable?.next(newEntry);
}
constructor(props) {
this.playerState = {
// We want isLive null by default to be able not to show controls if we do not know type
isLive: null,
isPaused: R.isNil(props?.autoplay) ? null : !props?.autoplay,
isMuted: props?.muted || false,
isBuffering: null,
// TODO: Reset if reference null, view destroyed
isReadyToPlay: false,
seekableDuration: null,
seekPosition: null,
contentDuration: null,
contentPosition: null,
adState: null,
trackState: {
textTracks: [],
audioTracks: [],
audioTrackId: null,
textTrackId: null,
},
};
this.playerId = props.playerId || "error, no player id";
this.listeners = {};
this.startPosition = props?.startPosition || null;
this.config = props.config || null;
this.playerRole = props.playerRole || PlayerRole.Unspecified;
this.onVideoProgress = this.onVideoProgress.bind(this);
this.onVideoLoad = this.onVideoLoad.bind(this);
this.isFullScreenSupported = this.isFullScreenSupported.bind(this);
this.entryObservable = null;
}
getOverlayObservable = () => {
if (!this.overlaysObserver) {
this.overlaysObserver = new OverlaysObserver({ player: this });
}
return this.overlaysObserver;
};
getEntryObservable = (): BehaviorSubject<ZappEntry | null> => {
if (!this.entryObservable) {
this.entryObservable = new BehaviorSubject(this.entry);
}
return this.entryObservable;
};
/**
* ---------------------------------------- PLAYER SETTERS GETTERS ----------------------------------------
* These methods let you get information about the content / ad playing
* or they let you get set certain properties of the player directly
*/
getContentPosition() {
return this.playerState.contentPosition;
}
/**
* getContent duration returns the duration of the playing content
* This is the method we use when we don't want to display adDuration
* This duration should not change during playback of ads.
*/
getContentDuration = () => {
return this.playerState.contentDuration;
};
getSeekableDuration = () => {
return this.playerState.seekableDuration;
};
/**
* getDuration returns the duration of the currentAd or the content
* Used for situations where you want to know the duration of whatever is playing
*/
getDuration = () => {
return this.playerState.adState
? this.playerState.adState.adDuration
: this.playerState.contentDuration;
};
getPosition = () =>
this.isSeeking()
? this.playerState.seekPosition
: this.playerState.adState
? this.playerState.adState.adPosition
: this.playerState.contentPosition;
getEntry = (): ZappEntry | null => this.entry;
logState = (text, additionalParams = {}) => {
const isSeeking = this.playerState.seekPosition !== null;
log_verbose(
`logState id: ${this.playerId}: ${text}, title: ${this.entry?.title}, contentPosition: ${this.playerState.contentPosition}, seekPosition: ${this.playerState.seekPosition}, isSeeking: ${isSeeking}`,
{
playerState: this.playerState,
isLiveCheck: this.isLive(),
isSeeking: isSeeking,
isAd: this.isAd(),
duration: this.getDuration(),
entryId: this.entry?.id,
playerId: this.playerId,
playerRole: this.playerRole,
...additionalParams,
item: null, // it's too verbose for verbose log
}
);
};
/**
* Handle events that have currentTime and duration updates
*/
setPlaybackState = ({ currentTime, duration, seekableDuration, isLive }) => {
if (isEmptyOrNil(currentTime)) {
this.logState("Event tried to set invalid playback state", {
currentTime,
duration,
seekableDuration,
});
return;
}
this.playerState.isLive = isLive;
if (this.isAd()) {
this.playerState.adState = {
adDuration: duration,
adPosition: currentTime,
};
} else {
if (!isEmptyOrNil(seekableDuration) && seekableDuration >= 0) {
this.playerState.seekableDuration = seekableDuration;
}
if (!isEmptyOrNil(duration) && duration >= 0) {
this.playerState.contentDuration = duration;
}
if (!isEmptyOrNil(currentTime) && currentTime >= 0) {
this.playerState.contentPosition = currentTime;
}
}
};
getState = () => null;
hasSeekableDuration = () => {
const duration = this.getSeekableDuration();
if (typeof duration !== "number") {
return false;
}
if (Number.isNaN(duration)) {
return false;
}
return duration > 0;
};
hasContentDuration = () => {
const duration = this.getContentDuration();
if (typeof duration !== "number") {
return false;
}
if (Number.isNaN(duration)) {
return false;
}
return duration > 0;
};
isAd = () => {
return !!this.playerState.adState || this.playerState.isInAdBreak;
};
isSeeking = () => {
return this.playerState.seekPosition !== null;
};
isPaused = () => {
return !!this.playerState.isPaused;
};
isPlaying = () => {
return !!this.playerState.isPaused === false;
};
getIsMuted = () => {
return this.playerState.isMuted;
};
mute = () => {
this.playerState.isMuted = true;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerMute,
});
};
unmute = () => {
this.playerState.isMuted = false;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerUnmute,
});
};
isBuffering = (): boolean => {
return this.playerState.isBuffering;
};
isReadyToPlay = (): boolean => {
return this.playerState.isReadyToPlay;
};
// Methods to call actions on the player
seekTo = (_position: number) => {};
forward = (_deltaTime: number) => {};
rewind = (_deltaTime: number) => {};
/**
* Determine whether content is live using three checks
* First checks feed to see if content is specified as live
* Second checks if player identified a duration for the content
* Future iterations could just receive a prop from the native player
* This order gives users the ability to specify what is live before the player
*/
isLive = () => {
const entry = this.getEntry();
if (entry) {
const entryDeclaredAsLive =
entry.type?.value === "channel" ||
getBoolFromConfigValue(entry.extensions?.live) ||
getBoolFromConfigValue(entry.extensions?.isLive);
if (entryDeclaredAsLive) {
return true;
}
}
return this.playerState.isLive;
};
/**
* ---------------------------------------- PLAYER EVENT HANDLERS ----------------------------------------
* The legacy methods are calling the player's event handler directly, newer events handle the event in this class
* For instance onVideoProgress, onVideoLoad
* Only extending these to support legacy behavior but if you are triggering an event
* You should use dispatchPlayerEvent at PlayerContainer or call playerManager.invokeHandler directly
* This allows you to use one method for everything.
*/
onSeekStart = (event: QuickBrickPlayer.OnSeekStartEvent) => {
const { isLive, seekableDuration, currentTime, duration, seekTime } = event;
this.setPlaybackState({ currentTime, duration, seekableDuration, isLive });
if (this.playerState.seekPosition === null) {
this.playerState.seekPosition = seekTime;
}
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerSeekStart,
event,
});
};
onSeekComplete = (event: QuickBrickPlayer.OnSeekCompleteEvent) => {
const { isLive, seekableDuration, currentTime, duration } = event;
this.setPlaybackState({ currentTime, duration, seekableDuration, isLive });
this.playerState.seekPosition = null;
if (!isLive) {
this.playerState.contentPosition = currentTime;
}
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerSeekComplete,
event,
});
};
onVideoEnd = (event) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnVideoEnd,
event,
});
};
onVideoError = (error: Error) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnVideoError,
event: error,
});
};
onError = (error: Error) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnError,
event: error,
});
};
onPlayerPause = (event) => {
this.playerState.isPaused = true;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerPause,
event,
});
};
onPlayerResume = (event) => {
this.playerState.isPaused = false;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerResume,
event,
});
};
onPlaybackRateChange = (event) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlaybackRateChange,
event,
});
};
onTracksChanged = (event: QuickBrickPlayer.TracksState) => {
this.playerState.trackState = event;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnTracksChanged,
event,
});
};
onBufferStart = (event: QuickBrickPlayer.OnBufferEvent) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnBufferStart,
event,
});
};
onBufferComplete = (event: QuickBrickPlayer.OnBufferEvent) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnBufferComplete,
event,
});
};
onVideoFullscreenPlayerWillDismiss = () => {
this.invokeListenersUpdate({
callbackName:
SharedPlayerCallBacksKeys.onVideoFullscreenPlayerWillDismiss,
});
};
onVideoFullscreenPlayerDidDismiss = () => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.onVideoFullscreenPlayerDidDismiss,
});
};
onVideoFullscreenPlayerWillPresent = () => {
this.invokeListenersUpdate({
callbackName:
SharedPlayerCallBacksKeys.onVideoFullscreenPlayerWillPresent,
});
};
onVideoFullscreenPlayerDidPresent = () => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.onVideoFullscreenPlayerDidPresent,
});
};
public invokeListenersUpdate = ({
callbackName,
event = {},
}: {
callbackName: SharedPlayerCallBacksKeys;
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 }
);
}
}
this.logState(callbackName, event);
};
onLoad = (event) => {
const { currentTime, duration, seekableDuration, isLive = false } = event;
this.setPlaybackState({ currentTime, duration, seekableDuration, isLive });
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnLoad,
event: { ...event, entry: this.getEntry() },
});
};
onVideoLoad(event) {
const { currentTime, duration, seekableDuration, isLive = false } = event;
const shouldCallOnLoad = !this.playerState.isReadyToPlay;
this.playerState.isReadyToPlay = true;
this.setPlaybackState({ currentTime, duration, seekableDuration, isLive });
if (shouldCallOnLoad) {
this.onLoad?.(event);
}
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnVideoLoad,
event,
});
}
/**
* Util method that updates the ad or content playback position in the player interface
* Keeps ad and content position / duration data seperate
*/
onVideoProgress(event) {
const { currentTime, duration, seekableDuration, isLive = false } = event;
if (this.isSeeking()) {
this.setPlaybackState({
currentTime: this.playerState.seekPosition,
duration,
seekableDuration,
isLive,
});
this.notifyPlayHeadPositionUpdate();
return;
}
this.setPlaybackState({ currentTime, duration, seekableDuration, isLive });
this.notifyPlayHeadPositionUpdate();
}
onPlayerClose = () => {
this.playerState.isReadyToPlay = false;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerClose,
});
this.entryObservable?.complete();
};
onAdBegin = (event) => {
this.playerState.adState = {
adPosition: null,
adDuration: null,
};
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdBegin,
event,
});
};
onAdBreakBegin = (event) => {
this.playerState.isInAdBreak = true;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdBreakBegin,
event,
});
};
onAdBreakEnd = (event) => {
this.playerState.adState = null;
this.playerState.isInAdBreak = false;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdBreakEnd,
event,
});
};
onAdEnd = (event) => {
this.playerState.adState = null;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdEnd,
event,
});
};
onAdError = (event) => {
this.playerState.adState = null;
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdError,
event,
});
};
onAdRequest = (event) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdRequest,
event,
});
};
onAdClicked = (event) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdClicked,
event,
});
};
onAdTapped = (event) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnAdTapped,
event,
});
};
onPlayerDetached = (event) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerDetached,
event,
});
};
onPlayerAttached = (event) => {
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnPlayerAttached,
event,
});
};
getTracksState = (): QuickBrickPlayer.TracksState =>
this.playerState.trackState;
selectTrack = (_track) => {};
protected notifyPlayHeadPositionUpdate = () => {
const event = {
currentTime: this.getPosition(),
duration: this.getDuration(),
item: this.getEntry(),
seekableDuration: this.playerState.seekableDuration,
isLive: this.isLive(),
};
this.invokeListenersUpdate({
callbackName: SharedPlayerCallBacksKeys.OnVideoProgress,
event,
});
};
addListener = ({ id, listener }: PlayerControllerListenerData) => {
if (!R.isNil(this.listeners[id])) {
log_error(
`addListener: Listener already exists for id: ${id}, listener will not be added`
);
return;
}
this.listeners[id] = listener;
return () => this.removeListener(id);
};
removeListener = (id: string) => {
delete this.listeners[id];
};
public getListener = (): QuickBrickPlayer.SharedPlayerCallBacks => ({
onPlayerSeekStart: this.onSeekStart,
onPlayerSeekComplete: this.onSeekComplete,
onVideoEnd: this.onVideoEnd,
onError: this.onError,
onVideoError: this.onVideoError,
onLoad: this.onLoad,
onPlayerPause: this.onPlayerPause,
onPlayerResume: this.onPlayerResume,
onPlaybackRateChange: this.onPlaybackRateChange,
onVideoProgress: this.onVideoProgress,
onTracksChanged: this.onTracksChanged,
onVideoLoad: this.onVideoLoad,
onBufferStart: this.onBufferStart,
onBufferComplete: this.onBufferComplete,
onPlayerClose: this.onPlayerClose,
onVideoFullscreenPlayerWillPresent: this.onVideoFullscreenPlayerWillPresent,
onVideoFullscreenPlayerDidPresent: this.onVideoFullscreenPlayerDidPresent,
onVideoFullscreenPlayerWillDismiss: this.onVideoFullscreenPlayerWillDismiss,
onVideoFullscreenPlayerDidDismiss: this.onVideoFullscreenPlayerDidDismiss,
// Ads Callbacks:
onAdBegin: this.onAdBegin,
onAdBreakBegin: this.onAdBreakBegin,
onAdBreakEnd: this.onAdBreakEnd,
onAdEnd: this.onAdEnd,
onAdError: this.onAdError,
onAdRequest: this.onAdRequest,
onAdClicked: this.onAdClicked,
onAdTapped: this.onAdTapped,
// PIP
onPlayerDetached: this.onPlayerDetached,
onPlayerAttached: this.onPlayerAttached,
});
play = () => {};
pause = () => {};
disableBufferAnimation = (): boolean => true;
setPlaybackRate = (_rate) => {};
startSleepTimer = (_timestamp) => {};
cancelSleepTimer = () => {};
getPluginConfiguration = () => null;
appStateChange = (_appState, _previousAppState) => {};
close = () => {};
closeNativePlayer = () => {};
togglePlayPause = () => {};
isCellPlayer = () => false;
getContinueWatchingOffset = ({ entry, ignoreContinueWatching = false }) => {
if (ignoreContinueWatching) {
log_info(
"getContinueWatchingOffset: ignoreContinueWatching is true, skipping continue watching data"
);
return null;
}
const resumeTime = Number(entry?.extensions?.resumeTime);
if (resumeTime && !isNaN(resumeTime)) {
return resumeTime;
}
return null;
};
public getConfig = () => this.config;
// TODO: replace with enum of supported orientations
isFullScreenSupported(): boolean {
return true;
}
public supportsNativeControls = (): boolean => false;
public supportNativeCast = (): boolean => false;
public destroy = () => {};
}