bitmovin-player-ui
Version:
Bitmovin Player UI Framework
1,124 lines (992 loc) • 43.2 kB
text/typescript
import { UIContainer } from './components/UIContainer';
import { DOM } from './DOM';
import { Component, ComponentConfig, ViewModeChangedEventArgs } from './components/Component';
import { Container } from './components/Container';
import { SeekBar, SeekBarMarker } from './components/seekbar/SeekBar';
import { NoArgs, EventDispatcher, CancelEventArgs } from './EventDispatcher';
import { UIUtils } from './utils/UIUtils';
import { ArrayUtils } from './utils/ArrayUtils';
import { BrowserUtils } from './utils/BrowserUtils';
import { TimelineMarker, UIConfig } from './UIConfig';
import { PlayerAPI, PlayerEventCallback, PlayerEventBase, PlayerEvent, AdEvent, LinearAd } from 'bitmovin-player';
import { VolumeController } from './utils/VolumeController';
import { i18n, CustomVocabulary, Vocabularies, I18n, LanguageChangedArgument } from './localization/i18n';
import { FocusVisibilityTracker } from './utils/FocusVisibilityTracker';
import { isMobileV3PlayerAPI, MobileV3PlayerAPI, MobileV3PlayerEvent } from './utils/MobileV3PlayerAPI';
import { SpatialNavigation } from './spatialnavigation/SpatialNavigation';
import { SubtitleSettingsManager } from './utils/SubtitleSettingsManager';
import { StorageUtils } from './utils/StorageUtils';
import { BufferingOverlay } from './components/overlays/BufferingOverlay';
import { ShadowDomManager } from './utils/ShadowDomManager';
import { AdBreakTracker } from './utils/AdBreakTracker';
/**
* @category Configs
*/
export interface LocalizationConfig {
/**
* Sets the desired language, and falls back to 'en' if there is no vocabulary for the desired language. Setting it
* to "auto" will enable language detection from the browser's locale.
*/
language?: 'auto' | 'en' | 'de' | string;
/**
* A map of `language` to {@link CustomVocabulary} definitions. Can be used to overwrite default translations and add
* custom strings or additional languages.
*/
vocabularies?: Vocabularies;
events?: {
/**
* Fires when the UI language has been changed during the lifetime of the UI.
*/
onLanguageChanged: EventDispatcher<I18n, LanguageChangedArgument>;
};
/**
* Specifies if the UI localization should automatically adapt to the selected subtitle language.
* When enabled, the UI language will change to match the subtitle track's language, falling back
* to the configured default UI language (English unless configured otherwise) if the language is not available.
*
* Default: false
*/
adaptLocalizationToSubtitleLanguage?: boolean;
}
/**
* @category Configs
*/
export interface InternalUIConfig extends UIConfig {
events: {
/**
* Fires when the configuration has been updated/changed.
*/
onUpdated: EventDispatcher<UIManager, void>;
};
volumeController: VolumeController;
adBreakTracker: AdBreakTracker;
}
/**
* The context that will be passed to a {@link UIConditionResolver} to determine if it's conditions fulfil the context.
*/
export interface UIConditionContext {
/**
* Tells if the player is loading or playing an ad.
*/
isAd: boolean;
/**
* Tells if the current ad requires an external UI, if {@link #isAd} is true.
*/
adRequiresUi: boolean;
/**
* Tells if the player is currently in fullscreen mode.
*/
isFullscreen: boolean;
/**
* Tells if the UI is running in a mobile browser.
*/
isMobile: boolean;
/**
* Tells if the UI is running in a TV browser.
*/
isTv: boolean;
/**
* Tells if the player is in playing or paused state.
*/
isPlaying: boolean;
/**
* Tells if the player has a Source.
*/
isSourceLoaded: boolean;
/**
* The width of the player/UI element.
*/
width: number;
/**
* The width of the document where the player/UI is embedded in.
*/
documentWidth: number;
}
/**
* Resolves the conditions of its associated UI in a {@link UIVariant} upon a {@link UIConditionContext} and decides
* if the UI should be displayed. If it returns true, the UI is a candidate for display; if it returns false, it will
* not be displayed in the given context.
*/
export interface UIConditionResolver {
(context: UIConditionContext): boolean;
}
/**
* Associates a UI instance with an optional {@link UIConditionResolver} that determines if the UI should be displayed.
*/
export interface UIVariant {
ui: UIContainer;
condition?: UIConditionResolver;
spatialNavigation?: SpatialNavigation;
}
export interface ActiveUiChangedArgs extends NoArgs {
/**
* The previously active {@link UIInstanceManager} prior to the {@link UIManager} switching to a different UI variant.
*/
previousUi: UIInstanceManager;
/**
* The currently active {@link UIInstanceManager}.
*/
currentUi: UIInstanceManager;
}
export class UIManager {
private player: PlayerAPI;
private uiContainerElement: DOM;
private uiVariants: UIVariant[];
private uiInstanceManagers: InternalUIInstanceManager[];
private currentUi: InternalUIInstanceManager;
private config: InternalUIConfig; // Conjunction of provided uiConfig and sourceConfig from the player
private managerPlayerWrapper: PlayerWrapper;
private focusVisibilityTracker: FocusVisibilityTracker;
private subtitleSettingsManager: SubtitleSettingsManager;
private shadowDomManager: ShadowDomManager;
private events = {
onUiVariantResolve: new EventDispatcher<UIManager, UIConditionContext>(),
onActiveUiChanged: new EventDispatcher<UIManager, ActiveUiChangedArgs>(),
};
/**
* Creates a UI manager with a single UI variant that will be permanently shown.
* @param player the associated player of this UI
* @param ui the UI to add to the player
* @param uiconfig optional UI configuration
*/
constructor(player: PlayerAPI, ui: UIContainer, uiconfig?: UIConfig);
/**
* Creates a UI manager with a list of UI variants that will be dynamically selected and switched according to
* the context of the UI.
*
* Every time the UI context changes, the conditions of the UI variants will be sequentially resolved and the first
* UI, whose condition evaluates to true, will be selected and displayed. The last variant in the list might omit the
* condition resolver and will be selected as default/fallback UI when all other conditions fail. If there is no
* fallback UI and all conditions fail, no UI will be displayed.
*
* @param player the associated player of this UI
* @param uiVariants a list of UI variants that will be dynamically switched
* @param uiconfig optional UI configuration
*/
constructor(player: PlayerAPI, uiVariants: UIVariant[], uiconfig?: UIConfig);
constructor(player: PlayerAPI, playerUiOrUiVariants: UIContainer | UIVariant[], uiconfig: UIConfig = {}) {
if (playerUiOrUiVariants instanceof UIContainer) {
// Single-UI constructor has been called, transform arguments to UIVariant[] signature
const playerUi = <UIContainer>playerUiOrUiVariants;
const uiVariants = [];
// Add the default player UI
uiVariants.push({ ui: playerUi });
this.uiVariants = uiVariants;
} else {
// Default constructor (UIVariant[]) has been called
this.uiVariants = <UIVariant[]>playerUiOrUiVariants;
}
this.subtitleSettingsManager = new SubtitleSettingsManager();
this.shadowDomManager = new ShadowDomManager();
this.player = player;
this.managerPlayerWrapper = new PlayerWrapper(player);
// ensure that at least the metadata object does exist in the uiconfig
uiconfig.metadata = uiconfig.metadata ? uiconfig.metadata : {};
this.config = {
playbackSpeedSelectionEnabled: true, // Switch on speed selector by default
autoUiVariantResolve: true, // Switch on auto UI resolving by default
disableAutoHideWhenHovered: false, // Disable auto hide when UI is hovered
enableSeekPreview: true,
shadowDom: false,
...uiconfig,
events: {
onUpdated: new EventDispatcher<UIManager, void>(),
},
volumeController: new VolumeController(this.managerPlayerWrapper.getPlayer()),
adBreakTracker: new AdBreakTracker(this.managerPlayerWrapper.getPlayer()),
};
/**
* Gathers configuration data from the UI config and player source config and creates a merged UI config
* that is used throughout the UI instance.
*/
const updateConfig = () => {
const playerSourceConfig = player.getSource() || {};
this.config.metadata = JSON.parse(JSON.stringify(uiconfig.metadata || {}));
// Extract the UI-related config properties from the source config
const playerSourceUiConfig: UIConfig = {
metadata: {
// TODO move metadata into source.metadata namespace in player v8
title: playerSourceConfig.title,
description: playerSourceConfig.description,
markers: (playerSourceConfig as any).markers,
recommendations: (playerSourceConfig as any).recommendations,
},
};
// Player source config takes precedence over the UI config, because the config in the source is attached
// to a source which changes with every player.load, whereas the UI config stays the same for the whole
// lifetime of the player instance.
this.config.metadata.title = playerSourceUiConfig.metadata.title || uiconfig.metadata.title;
this.config.metadata.description = playerSourceUiConfig.metadata.description || uiconfig.metadata.description;
this.config.metadata.markers = playerSourceUiConfig.metadata.markers || uiconfig.metadata.markers || [];
this.config.metadata.recommendations =
playerSourceUiConfig.metadata.recommendations || uiconfig.metadata.recommendations || [];
StorageUtils.setStorageApiDisabled(uiconfig);
};
updateConfig();
if (this.config.localization) {
i18n.setConfig(this.config.localization);
}
this.subtitleSettingsManager.initialize();
// Update the source configuration when a new source is loaded and dispatch onUpdated
const updateSource = () => {
updateConfig();
this.config.events.onUpdated.dispatch(this);
};
const wrappedPlayer = this.managerPlayerWrapper.getPlayer();
wrappedPlayer.on(this.player.exports.PlayerEvent.SourceLoaded, updateSource);
// The PlaylistTransition event is only available on Mobile v3 for now.
// This event is fired when a new source becomes active in the player.
if (isMobileV3PlayerAPI(wrappedPlayer)) {
wrappedPlayer.on(MobileV3PlayerEvent.PlaylistTransition, updateSource);
}
if (uiconfig.container) {
// Unfortunately "uiContainerElement = new DOM(config.container)" will not accept the container with
// string|HTMLElement type directly, although it accepts both types, so we need to spit these two cases up here.
// TODO check in upcoming TS versions if the container can be passed in directly, or fix the constructor
this.uiContainerElement =
uiconfig.container instanceof HTMLElement ? new DOM(uiconfig.container) : new DOM(uiconfig.container);
} else {
this.uiContainerElement = new DOM(player.getContainer());
}
if (this.config.shadowDom == true || (this.config.shadowDom && this.config.shadowDom.enabled)) {
if (ShadowDomManager.isShadowDomSupported()) {
this.shadowDomManager.initialize(
this.uiContainerElement,
this.config.shadowDom === true ? { enabled: true } : this.config.shadowDom,
);
} else {
console.warn('Shadow DOM is not supported in this environment. Falling back to classic UI rendering.');
}
}
// Create UI instance managers for the UI variants
// The instance managers map to the corresponding UI variants by their array index
this.uiInstanceManagers = [];
const uiVariantsWithoutCondition = [];
for (const uiVariant of this.uiVariants) {
if (uiVariant.condition == null) {
// Collect variants without conditions for error checking
uiVariantsWithoutCondition.push(uiVariant);
}
// Create the instance manager for a UI variant
this.uiInstanceManagers.push(
new InternalUIInstanceManager(
player,
uiVariant.ui,
this.config,
this.subtitleSettingsManager,
this.uiWrapperElement,
uiVariant.spatialNavigation,
),
);
}
// Make sure that there is only one UI variant without a condition
// It does not make sense to have multiple variants without condition, because only the first one in the list
// (the one with the lowest index) will ever be selected.
if (uiVariantsWithoutCondition.length > 1) {
throw Error('Too many UIs without a condition: You cannot have more than one default UI');
}
// Make sure that the default UI variant, if defined, is at the end of the list (last index)
// If it comes earlier, the variants with conditions that come afterwards will never be selected because the
// default variant without a condition always evaluates to 'true'
if (
uiVariantsWithoutCondition.length > 0 &&
uiVariantsWithoutCondition[0] !== this.uiVariants[this.uiVariants.length - 1]
) {
throw Error('Invalid UI variant order: the default UI (without condition) must be at the end of the list');
}
let adStartedEvent: AdEvent = null; // keep the event stored here during ad playback
let isSourceLoaded = player.getSource() != null;
player.on(player.exports.PlayerEvent.SourceLoaded, () => {
isSourceLoaded = true;
});
player.on(player.exports.PlayerEvent.SourceUnloaded, () => {
isSourceLoaded = false;
});
// Dynamically select a UI variant that matches the current UI condition.
const resolveUiVariant = (event: PlayerEventBase) => {
// Make sure that the AdStarted event data is persisted through ad playback in case other events happen
// in the meantime, e.g. player resize. We need to store this data because there is no other way to find out
// ad details while an ad is playing (in v8.0 at least; from v8.1 there will be ads.getActiveAd()).
// Existing event data signals that an ad is currently active (instead of ads.isLinearAdActive()).
if (event != null) {
switch (event.type) {
// The ads UI is shown upon the first AdStarted event. Subsequent AdStarted events within an ad break
// will not change the condition context and thus not lead to undesired UI variant resolving.
// The ads UI is shown upon AdStarted instead of AdBreakStarted because there can be a loading delay
// between these two events in the player, and the AdBreakStarted event does not carry any metadata to
// initialize the ads UI, so it would be rendered in an uninitialized state for a certain amount of time.
// TODO show ads UI upon AdBreakStarted and display loading overlay between AdBreakStarted and first AdStarted
// TODO display loading overlay between AdFinished and next AdStarted
case player.exports.PlayerEvent.AdStarted:
adStartedEvent = event as AdEvent;
break;
// The ads UI is hidden only when the ad break is finished, i.e. not on AdFinished events. This way we keep
// the ads UI variant active throughout an ad break, as reacting to AdFinished would lead to undesired UI
// variant switching between two ads in an ad break, e.g. ads UI -> AdFinished -> content UI ->
// AdStarted -> ads UI.
case player.exports.PlayerEvent.AdBreakFinished:
adStartedEvent = null;
// When switching to a variant for the first time, a config.events.onUpdated event is fired to trigger a UI
// update of the new variant, because most components subscribe to this event to update themselves. When
// switching to the ads UI on the first AdStarted, all UI variants update themselves with the ad data, so
// when switching back to the "normal" UI it will carry properties of the ad instead of the main content.
// We thus fire this event here to force an UI update with the properties of the main content. This is
// basically a hack because the config.events.onUpdated event is abused in many places and not just used
// for config updates (e.g. adding a marker to the seekbar).
// TODO introduce an event that is fired when the playback content is updated, a switch to/from ads
this.config.events.onUpdated.dispatch(this);
break;
case player.exports.PlayerEvent.SourceLoaded:
// No need to take care of SourceLoaded. As when the source changes, a SourceUnloaded event is received.
// When the source gets loaded during ad playback, we don't want to change the UI.
break;
case player.exports.PlayerEvent.SourceUnloaded:
// When the source gets unloaded during ad playback, there will be no Ad(Break)Finished event.
// This also covers changing a source
adStartedEvent = null;
break;
}
}
// Detect if an ad has started
const isAd = adStartedEvent != null;
let adRequiresUi = false;
if (isAd) {
const ad = adStartedEvent.ad;
// for now only linear ads can request a UI
if (ad.isLinear) {
const linearAd = ad as LinearAd;
adRequiresUi = (linearAd.uiConfig && linearAd.uiConfig.requestsUi) || false;
}
}
if (adRequiresUi) {
// we dispatch onUpdated event because if there are multiple adBreaks for same position
// `Play` and `Playing` events will not be dispatched which will cause `PlaybackButton` state
// to be out of sync
this.config.events.onUpdated.dispatch(this);
}
this.resolveUiVariant(
{
isAd,
adRequiresUi,
isSourceLoaded,
},
context => {
// If this is an ad UI, we need to relay the saved ON_AD_STARTED event data so ad components can configure
// themselves for the current ad.
if (context.isAd) {
/* Relay the ON_AD_STARTED event to the ads UI
*
* Because the ads UI is initialized in the ON_AD_STARTED handler, i.e. when the ON_AD_STARTED event has
* already been fired, components in the ads UI that listen for the ON_AD_STARTED event never receive it.
* Since this can break functionality of components that rely on this event, we relay the event to the
* ads UI components with the following call.
*/
this.currentUi.getWrappedPlayer().fireEventInUI(this.player.exports.PlayerEvent.AdStarted, adStartedEvent);
}
},
);
};
// Listen to the following events to trigger UI variant resolution
if (this.config.autoUiVariantResolve) {
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.SourceLoaded, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.SourceUnloaded, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.Play, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.Paused, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.Playing, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.AdStarted, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.AdBreakFinished, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.PlayerResized, resolveUiVariant);
this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.ViewModeChanged, resolveUiVariant);
}
this.focusVisibilityTracker = new FocusVisibilityTracker('{{PREFIX}}', this.uiWrapperElement);
// Initialize the UI
resolveUiVariant(null);
}
/**
* Exposes i18n.getLocalizer() function
* @returns {I18nApi.getLocalizer()}
*/
static localize<V extends CustomVocabulary<Record<string, string>>>(key: keyof V) {
return i18n.getLocalizer(key);
}
/**
* Provide configuration to support Custom UI languages
* default language: 'en'
*/
static setLocalizationConfig(localizationConfig: LocalizationConfig) {
i18n.setConfig(localizationConfig);
}
getSubtitleSettingsManager() {
return this.subtitleSettingsManager;
}
getConfig(): UIConfig {
return this.config;
}
/**
* Returns the list of UI variants as passed into the constructor of {@link UIManager}.
* @returns {UIVariant[]} the list of available UI variants
*/
getUiVariants(): UIVariant[] {
return this.uiVariants;
}
/**
* Switches to a UI variant from the list returned by {@link getUiVariants}.
* @param {UIVariant} uiVariant the UI variant to switch to
* @param {() => void} onShow a callback that is executed just before the new UI variant is shown
*/
switchToUiVariant(uiVariant: UIVariant, onShow?: () => void): void {
const uiVariantIndex = this.uiVariants.indexOf(uiVariant);
const previousUi = this.currentUi;
const nextUi: InternalUIInstanceManager = this.uiInstanceManagers[uiVariantIndex];
// Determine if the UI variant is changing
// Only if the UI variant is changing, we need to do some stuff. Else we just leave everything as-is.
if (nextUi === this.currentUi) {
return;
// console.log('switched from ', this.currentUi ? this.currentUi.getUI() : 'none',
// ' to ', nextUi ? nextUi.getUI() : 'none');
}
// Hide the currently active UI variant
if (this.currentUi) {
this.currentUi.getUI().hide();
}
// Assign the new UI variant as current UI
this.currentUi = nextUi;
// When we switch to a different UI instance, there's some additional stuff to manage. If we do not switch
// to an instance, we're done here.
if (this.currentUi == null) {
return;
}
// Add the UI to the DOM (and configure it) the first time it is selected
if (!this.currentUi.isConfigured()) {
this.addUi(this.currentUi);
// ensure that the internal state is ready for the upcoming show call
if (!this.currentUi.getUI().isHidden()) {
this.currentUi.getUI().hide();
}
}
if (onShow) {
onShow();
}
this.currentUi.getUI().show();
this.events.onActiveUiChanged.dispatch(this, { previousUi, currentUi: nextUi });
}
/**
* Triggers a UI variant switch as triggered by events when automatic switching is enabled. It allows to overwrite
* properties of the {@link UIConditionContext}.
* @param {Partial<UIConditionContext>} context an optional set of properties that overwrite properties of the
* automatically determined context
* @param {(context: UIConditionContext) => void} onShow a callback that is executed just before the new UI variant
* is shown (if a switch is happening)
*/
resolveUiVariant(context: Partial<UIConditionContext> = {}, onShow?: (context: UIConditionContext) => void): void {
// Determine the current context for which the UI variant will be resolved
const defaultContext: UIConditionContext = {
isAd: false,
adRequiresUi: false,
isFullscreen: this.player.getViewMode() === this.player.exports.ViewMode.Fullscreen,
isMobile: BrowserUtils.isMobile,
isTv: BrowserUtils.isTv,
isPlaying: this.player.isPlaying(),
isSourceLoaded: false,
width: this.uiContainerElement.width(),
documentWidth: document.body.clientWidth,
};
// Overwrite properties of the default context with passed in context properties
const switchingContext = { ...defaultContext, ...context };
// Fire the event and allow modification of the context before it is used to resolve the UI variant
this.events.onUiVariantResolve.dispatch(this, switchingContext);
let nextUiVariant: UIVariant = null;
// Select new UI variant
// If no variant condition is fulfilled, we switch to *no* UI
for (const uiVariant of this.uiVariants) {
const matchesCondition = uiVariant.condition == null || uiVariant.condition(switchingContext) === true;
if (nextUiVariant == null && matchesCondition) {
nextUiVariant = uiVariant;
} else {
// hide all UIs besides the one which should be active
uiVariant.ui.hide();
}
}
this.switchToUiVariant(nextUiVariant, () => {
if (onShow) {
onShow(switchingContext);
}
});
}
/**
* The node the UI renders into. When Shadow DOM is enabled, this wraps the ShadowRoot; otherwise it wraps the
* provided `UIConfig.container` (or the `player.container`).
*/
get uiWrapperElement(): DOM {
const shadowRoot = this.shadowDomManager.getShadowRoot();
return shadowRoot != undefined ? new DOM(shadowRoot) : this.uiContainerElement;
}
private addUi(ui: InternalUIInstanceManager): void {
const dom = ui.getUI().getDomElement();
const player = ui.getWrappedPlayer();
ui.configureControls();
/* Append the UI DOM after configuration to avoid CSS transitions at initialization
* Example: Components are hidden during configuration and these hides may trigger CSS transitions that are
* undesirable at this time. */
this.uiWrapperElement.append(dom);
// When the UI is loaded after a source was loaded, we need to tell the components to initialize themselves
if (player.getSource()) {
this.config.events.onUpdated.dispatch(this);
}
// Fire onConfigured after UI DOM elements are successfully added. When fired immediately, the DOM elements
// might not be fully configured and e.g. do not have a size.
// https://swizec.com/blog/how-to-properly-wait-for-dom-elements-to-show-up-in-modern-browsers/swizec/6663
if (window.requestAnimationFrame) {
requestAnimationFrame(() => {
ui.onConfigured.dispatch(ui.getUI());
});
} else {
// IE9 fallback
setTimeout(() => {
ui.onConfigured.dispatch(ui.getUI());
}, 0);
}
}
private releaseUi(ui: InternalUIInstanceManager): void {
ui.releaseControls();
const uiContainer = ui.getUI();
if (uiContainer.hasDomElement()) {
uiContainer.getDomElement().remove();
}
ui.clearEventHandlers();
}
release(): void {
this.config.adBreakTracker.release();
for (const uiInstanceManager of this.uiInstanceManagers) {
this.releaseUi(uiInstanceManager);
}
this.managerPlayerWrapper.clearEventHandlers();
this.focusVisibilityTracker.release();
this.shadowDomManager.release();
}
/**
* Fires just before UI variants are about to be resolved and the UI variant is possibly switched. It is fired when
* the switch is triggered from an automatic switch and when calling {@link resolveUiVariant}.
* Can be used to modify the {@link UIConditionContext} before resolving is done.
* @returns {EventDispatcher<UIManager, UIConditionContext>}
*/
get onUiVariantResolve(): EventDispatcher<UIManager, UIConditionContext> {
return this.events.onUiVariantResolve;
}
/**
* Fires after the UIManager has switched to a different UI variant.
* @returns {EventDispatcher<UIManager, ActiveUiChangedArgs>}
*/
get onActiveUiChanged(): EventDispatcher<UIManager, ActiveUiChangedArgs> {
return this.events.onActiveUiChanged;
}
/**
* The current active {@link UIInstanceManager}.
*/
get activeUi(): UIInstanceManager {
return this.currentUi;
}
/**
* Returns the list of all added markers in undefined order.
*/
getTimelineMarkers(): TimelineMarker[] {
return this.config.metadata.markers;
}
/**
* Adds a marker to the timeline. Does not check for duplicates/overlaps at the `time`.
*/
addTimelineMarker(timelineMarker: TimelineMarker): void {
this.config.metadata.markers.push(timelineMarker);
this.config.events.onUpdated.dispatch(this);
}
/**
* Removes a marker from the timeline (by reference) and returns `true` if the marker has
* been part of the timeline and successfully removed, or `false` if the marker could not
* be found and thus not removed.
*/
removeTimelineMarker(timelineMarker: TimelineMarker): boolean {
if (ArrayUtils.remove(this.config.metadata.markers, timelineMarker) === timelineMarker) {
this.config.events.onUpdated.dispatch(this);
return true;
}
return false;
}
}
export interface SeekPreviewArgs extends NoArgs {
/**
* The timeline position in percent where the event originates from.
*/
position: number;
/**
* The timeline marker associated with the current position, if existing.
*/
marker?: SeekBarMarker;
}
/**
* Encapsulates functionality to manage a UI instance. Used by the {@link UIManager} to manage multiple UI instances.
*/
export class UIInstanceManager {
private playerWrapper: PlayerWrapper;
private ui: UIContainer;
private config: InternalUIConfig;
private subtitleSettingsManager: SubtitleSettingsManager;
protected spatialNavigation?: SpatialNavigation;
readonly uiWrapperElement: DOM;
private events = {
onConfigured: new EventDispatcher<UIContainer, NoArgs>(),
onSeek: new EventDispatcher<SeekBar, NoArgs>(),
onSeekPreview: new EventDispatcher<SeekBar, SeekPreviewArgs>(),
onSeeked: new EventDispatcher<SeekBar, NoArgs>(),
onComponentShow: new EventDispatcher<Component<ComponentConfig>, NoArgs>(),
onComponentHide: new EventDispatcher<Component<ComponentConfig>, NoArgs>(),
onComponentViewModeChanged: new EventDispatcher<Component<ComponentConfig>, ViewModeChangedEventArgs>(),
onControlsShow: new EventDispatcher<UIContainer, NoArgs>(),
onPreviewControlsHide: new EventDispatcher<UIContainer, CancelEventArgs>(),
onControlsHide: new EventDispatcher<UIContainer, NoArgs>(),
onRelease: new EventDispatcher<UIContainer, NoArgs>(),
onBufferingShow: new EventDispatcher<BufferingOverlay, NoArgs>(),
onBufferingHide: new EventDispatcher<BufferingOverlay, NoArgs>(),
};
constructor(
player: PlayerAPI,
ui: UIContainer,
config: InternalUIConfig,
subtitleSettingsManager: SubtitleSettingsManager,
uiWrapperElement: DOM,
spatialNavigation?: SpatialNavigation,
) {
this.playerWrapper = new PlayerWrapper(player);
this.ui = ui;
this.config = config;
this.subtitleSettingsManager = subtitleSettingsManager;
this.uiWrapperElement = uiWrapperElement;
this.spatialNavigation = spatialNavigation;
}
getSubtitleSettingsManager() {
return this.subtitleSettingsManager;
}
getConfig(): InternalUIConfig {
return this.config;
}
getUI(): UIContainer {
return this.ui;
}
getPlayer(): PlayerAPI {
return this.playerWrapper.getPlayer();
}
/**
* Fires when the UI is fully configured and added to the DOM.
* @returns {EventDispatcher}
*/
get onConfigured(): EventDispatcher<UIContainer, NoArgs> {
return this.events.onConfigured;
}
/**
* Fires when a seek starts.
* @returns {EventDispatcher}
*/
get onSeek(): EventDispatcher<SeekBar, NoArgs> {
return this.events.onSeek;
}
/**
* Fires when the seek timeline is scrubbed.
* @returns {EventDispatcher}
*/
get onSeekPreview(): EventDispatcher<SeekBar, SeekPreviewArgs> {
return this.events.onSeekPreview;
}
/**
* Fires when a seek is finished.
* @returns {EventDispatcher}
*/
get onSeeked(): EventDispatcher<SeekBar, NoArgs> {
return this.events.onSeeked;
}
/**
* Fires when a component is showing.
* @returns {EventDispatcher}
*/
get onComponentShow(): EventDispatcher<Component<ComponentConfig>, NoArgs> {
return this.events.onComponentShow;
}
/**
* Fires when a component is hiding.
* @returns {EventDispatcher}
*/
get onComponentHide(): EventDispatcher<Component<ComponentConfig>, NoArgs> {
return this.events.onComponentHide;
}
/**
* Fires when the UI controls are showing.
* @returns {EventDispatcher}
*/
get onControlsShow(): EventDispatcher<UIContainer, NoArgs> {
return this.events.onControlsShow;
}
/**
* Fires before the UI controls are hiding to check if they are allowed to hide.
* @returns {EventDispatcher}
*/
get onPreviewControlsHide(): EventDispatcher<UIContainer, CancelEventArgs> {
return this.events.onPreviewControlsHide;
}
/**
* Fires when the UI controls are hiding.
* @returns {EventDispatcher}
*/
get onControlsHide(): EventDispatcher<UIContainer, NoArgs> {
return this.events.onControlsHide;
}
/**
* Fires when the BufferingOverlay shows.
* @returns {EventDispatcher}
*/
get onBufferingShow(): EventDispatcher<BufferingOverlay, NoArgs> {
return this.events.onBufferingShow;
}
/**
* Fires when the BufferingOverlay hides.
* @returns {EventDispatcher}
*/
get onBufferingHide(): EventDispatcher<BufferingOverlay, NoArgs> {
return this.events.onBufferingHide;
}
/**
* Fires when the UI controls are released.
* @returns {EventDispatcher}
*/
get onRelease(): EventDispatcher<UIContainer, NoArgs> {
return this.events.onRelease;
}
get onComponentViewModeChanged(): EventDispatcher<Component<ComponentConfig>, ViewModeChangedEventArgs> {
return this.events.onComponentViewModeChanged;
}
protected clearEventHandlers(): void {
this.playerWrapper.clearEventHandlers();
const events = <any>this.events; // avoid TS7017
for (const event in events) {
const dispatcher = <EventDispatcher<Object, Object>>events[event];
dispatcher.unsubscribeAll();
}
}
}
/**
* Extends the {@link UIInstanceManager} for internal use in the {@link UIManager} and provides access to functionality
* that components receiving a reference to the {@link UIInstanceManager} should not have access to.
*/
class InternalUIInstanceManager extends UIInstanceManager {
private configured: boolean;
private released: boolean;
getWrappedPlayer(): WrappedPlayer {
// TODO find a non-hacky way to provide the WrappedPlayer to the UIManager without exporting it
// getPlayer() actually returns the WrappedPlayer but its return type is set to Player so the WrappedPlayer does
// not need to be exported
return <WrappedPlayer>this.getPlayer();
}
configureControls(): void {
this.configureControlsTree(this.getUI());
this.configured = true;
}
isConfigured(): boolean {
return this.configured;
}
private configureControlsTree(component: Component<ComponentConfig>) {
const configuredComponents: Component<ComponentConfig>[] = [];
UIUtils.traverseTree(component, component => {
// First, check if we have already configured a component, and throw an error if we did. Multiple configuration
// of the same component leads to unexpected UI behavior. Also, a component that is in the UI tree multiple
// times hints at a wrong UI structure.
// We could just skip configuration in such a case and not throw an exception, but enforcing a clean UI tree
// seems like the better choice.
for (const configuredComponent of configuredComponents) {
if (configuredComponent === component) {
// Write the component to the console to simplify identification of the culprit
// (e.g. by inspecting the config)
if (console) {
console.error('Circular reference in UI tree', component);
}
// Additionally throw an error, because this case must not happen and leads to unexpected UI behavior.
throw Error('Circular reference in UI tree: ' + component.constructor.name);
}
}
component.initialize();
component.configure(this.getPlayer(), this);
configuredComponents.push(component);
});
}
releaseControls(): void {
// Do not call release methods if the components have never been configured; this can result in exceptions
if (this.configured) {
this.onRelease.dispatch(this.getUI());
this.releaseControlsTree(this.getUI());
this.configured = false;
}
this.spatialNavigation?.release();
this.released = true;
}
isReleased(): boolean {
return this.released;
}
private releaseControlsTree(component: Component<ComponentConfig>) {
component.release();
if (component instanceof Container) {
for (const childComponent of component.getComponents()) {
this.releaseControlsTree(childComponent);
}
}
}
clearEventHandlers(): void {
super.clearEventHandlers();
}
}
/**
* Extended interface of the {@link Player} for use in the UI.
*/
export interface WrappedPlayer extends PlayerAPI {
/**
* Fires an event on the player that targets all handlers in the UI but never enters the real player.
* @param event the event to fire
* @param data data to send with the event
*/
fireEventInUI(event: PlayerEvent, data: {}): void;
}
/**
* Wraps the player to track event handlers and provide a simple method to remove all registered event
* handlers from the player.
*
* @category Utils
*/
export class PlayerWrapper {
private player: PlayerAPI;
private wrapper: WrappedPlayer;
private eventHandlers: { [eventType: string]: PlayerEventCallback<PlayerEvent>[] } = {};
constructor(player: PlayerAPI) {
this.player = player;
// Collect all members of the player (public API methods and properties of the player)
const objectProtoPropertyNames = Object.getOwnPropertyNames(Object.getPrototypeOf({}));
const namesToIgnore = ['constructor', ...objectProtoPropertyNames];
const members = getAllPropertyNames(player).filter(name => namesToIgnore.indexOf(name) === -1);
// Split the members into methods and properties
const methods = <any[]>[];
const properties = <any[]>[];
for (const member of members) {
if (typeof (<any>player)[member] === 'function') {
methods.push(member);
} else {
properties.push(member);
}
}
// Create wrapper object
const wrapper = <any>{};
// Add function wrappers for all API methods that do nothing but calling the base method on the player
for (const method of methods) {
wrapper[method] = function () {
// console.log('called ' + member); // track method calls on the player
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return (<any>player)[method].apply(player, arguments);
};
}
// Add all public properties of the player to the wrapper
for (const property of properties) {
// Get an eventually existing property descriptor to differentiate between plain properties and properties with
// getters/setters.
const propertyDescriptor = ((target: PlayerAPI) => {
while (target) {
const propertyDescriptor = Object.getOwnPropertyDescriptor(target, property);
if (propertyDescriptor) {
return propertyDescriptor;
}
// Check if the PropertyDescriptor exists on a child prototype in case we have an inheritance of the player
target = Object.getPrototypeOf(target);
}
})(player);
// If the property has getters/setters, wrap them accordingly...
if (propertyDescriptor && (propertyDescriptor.get || propertyDescriptor.set)) {
Object.defineProperty(wrapper, property, {
get: () => propertyDescriptor.get.call(player),
set: (value: any) => propertyDescriptor.set.call(player, value),
});
}
// ... else just transfer the property to the wrapper
else {
wrapper[property] = (<any>player)[property];
}
}
// Explicitly add a wrapper method for 'on' that adds added event handlers to the event list
wrapper.on = <T extends PlayerEvent>(eventType: T, callback: PlayerEventCallback<T>) => {
player.on(eventType, callback);
if (!this.eventHandlers[eventType]) {
this.eventHandlers[eventType] = [];
}
this.eventHandlers[eventType].push(callback);
return wrapper;
};
// Explicitly add a wrapper method for 'off' that removes removed event handlers from the event list
wrapper.off = <T extends PlayerEvent>(eventType: T, callback: PlayerEventCallback<T>) => {
player.off(eventType, callback);
if (this.eventHandlers[eventType]) {
ArrayUtils.remove(this.eventHandlers[eventType], callback);
}
return wrapper;
};
wrapper.fireEventInUI = (event: PlayerEvent, data: {}) => {
if (this.eventHandlers[event]) {
// check if there are handlers for this event registered
// Extend the data object with default values to convert it to a {@link PlayerEventBase} object.
const playerEventData = <PlayerEventBase>Object.assign(
{},
{
timestamp: Date.now(),
type: event,
// Add a marker property so the UI can detect UI-internal player events
uiSourced: true,
},
data,
);
// Execute the registered callbacks
for (const callback of this.eventHandlers[event]) {
callback(playerEventData);
}
}
};
this.wrapper = <WrappedPlayer>wrapper;
}
/**
* Returns a wrapped player object that can be used on place of the normal player object.
* @returns {WrappedPlayer} a wrapped player
*/
getPlayer(): WrappedPlayer {
return this.wrapper;
}
/**
* Clears all registered event handlers from the player that were added through the wrapped player.
*/
clearEventHandlers(): void {
try {
// Call the player API to check if the instance is still valid or already destroyed.
// This can be any call throwing the PlayerAPINotAvailableError when the player instance is destroyed.
this.player.getSource();
} catch (error) {
if (error instanceof this.player.exports.PlayerAPINotAvailableError) {
// We have detected that the player instance is already destroyed, so we clear the event handlers to avoid
// event handler unsubscription attempts (which would result in PlayerAPINotAvailableError errors).
this.eventHandlers = {};
}
}
for (const eventType in this.eventHandlers) {
for (const callback of this.eventHandlers[eventType]) {
this.player.off(eventType as PlayerEvent, callback);
}
}
}
}
function getAllPropertyNames(target: Object): string[] {
let names: string[] = [];
while (target) {
const newNames = Object.getOwnPropertyNames(target).filter(name => names.indexOf(name) === -1);
names = names.concat(newNames);
// go up prototype chain
target = Object.getPrototypeOf(target);
}
return names;
}