UNPKG

@applicaster/zapp-react-native-utils

Version:

Applicaster Zapp React Native utilities package

738 lines (593 loc) • 20 kB
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 = () => {}; }