bitmovin-player-ui
Version:
Bitmovin Player UI Framework
539 lines (478 loc) • 19.3 kB
text/typescript
import { Container, ContainerConfig } from './Container';
import { UIInstanceManager } from '../UIManager';
import { DOM, HTMLElementWithComponent } from '../DOM';
import { Timeout } from '../utils/Timeout';
import { PlayerUtils } from '../utils/PlayerUtils';
import { CancelEventArgs, Event as UiEvent, EventDispatcher } from '../EventDispatcher';
import { PlayerAPI, PlayerResizedEvent } from 'bitmovin-player';
import { i18n } from '../localization/i18n';
import { Button, ButtonConfig } from './buttons/Button';
import { TouchControlOverlay, TouchControlOverlayConfig } from './overlays/TouchControlOverlay';
import { Component, ComponentConfig } from './Component';
import { SettingsPanel } from './settings/SettingsPanel';
/**
* Configuration interface for a {@link UIContainer}.
*
* @category Configs
*/
export interface UIContainerConfig extends ContainerConfig {
/**
* The delay in milliseconds after which the control bar will be hidden when there is no user interaction.
* Set to -1 for the UI to be always shown.
* Default: 2 seconds (2000)
*/
hideDelay?: number;
/**
* An array of player states in which the UI will not be hidden, no matter what the {@link hideDelay} is.
*/
hidePlayerStateExceptions?: PlayerUtils.PlayerState[];
/**
* The HTML element on which user interaction events (e.g. mouse and touch events) will be tracked to detect
* interaction with the UI. These basically trigger showing and hiding of the UI.
* Default: the UI container itself
*/
userInteractionEventSource?: HTMLElement;
/**
* Specify whether the UI should be hidden immediatly if the mouse leaves the userInteractionEventSource.
* If false or not set it will wait for the hideDelay.
* Default: true
*/
hideImmediatelyOnMouseLeave?: boolean;
/**
* When true, suspend the UIContainer's hide timer while a SettingsPanel is open,
* and resume it when the panel closes.
* Default: true
*/
deferUiHideWhileSettingsOpen?: boolean;
}
/**
* The base container that contains all of the UI. The UIContainer is passed to the {@link UIManager} to build and
* setup the UI.
*
* @category Containers
*/
export class UIContainer extends Container<UIContainerConfig> {
private static readonly STATE_PREFIX = 'player-state-';
private static readonly FULLSCREEN = 'fullscreen';
private static readonly BUFFERING = 'buffering';
private static readonly REMOTE_CONTROL = 'remote-control';
private static readonly CONTROLS_SHOWN = 'controls-shown';
private static readonly CONTROLS_HIDDEN = 'controls-hidden';
private uiHideTimeout: Timeout;
private playerStateChange: EventDispatcher<UIContainer, PlayerUtils.PlayerState>;
private userInteractionEventSource: DOM;
private userInteractionEvents: { name: string; handler: EventListenerOrEventListenerObject }[];
private hidingPrevented: () => boolean;
public hideUi: (force?: boolean) => void = () => {};
public showUi: () => void = () => {};
public toggleUiShown: () => void = () => {};
constructor(config: UIContainerConfig) {
super(config);
this.config = this.mergeConfig(
config,
<UIContainerConfig>{
cssClass: 'ui-uicontainer',
role: 'region',
ariaLabel: i18n.getLocalizer('player'),
hideDelay: 2000,
hideImmediatelyOnMouseLeave: true,
deferUiHideWhileSettingsOpen: true,
},
this.config,
);
this.playerStateChange = new EventDispatcher<UIContainer, PlayerUtils.PlayerState>();
this.hidingPrevented = () => false;
}
configure(player: PlayerAPI, uimanager: UIInstanceManager): void {
const config = this.getConfig();
if (config.userInteractionEventSource) {
this.userInteractionEventSource = new DOM(config.userInteractionEventSource);
} else {
this.userInteractionEventSource = this.getDomElement();
}
super.configure(player, uimanager);
this.configureUIShowHide(player, uimanager);
this.configurePlayerStates(player, uimanager);
}
private configureUIShowHide(player: PlayerAPI, uimanager: UIInstanceManager): void {
const config = this.getConfig();
let isUiShown = false;
let isSettingsPanelShown = false;
let isHideUiPending = false;
uimanager.onConfigured.subscribe(() => {
if (isUiShown) {
uimanager.onControlsShow.dispatch(this);
} else {
uimanager.onControlsHide.dispatch(this);
}
});
if (config.hideDelay === -1) {
isUiShown = true;
return;
}
let isSeeking = false;
let isFirstTouch = true;
let playerState: PlayerUtils.PlayerState;
if (config.deferUiHideWhileSettingsOpen) {
uimanager.onComponentShow.subscribe((component: Component<ComponentConfig>) => {
if (component instanceof SettingsPanel) {
isSettingsPanelShown = true;
}
});
uimanager.onComponentHide.subscribe((component: Component<ComponentConfig>) => {
if (component instanceof SettingsPanel) {
isSettingsPanelShown = false;
if (isHideUiPending) {
this.hideUi(true);
isHideUiPending = false;
}
}
});
}
this.hidingPrevented = (): boolean => {
return config.hidePlayerStateExceptions && config.hidePlayerStateExceptions.indexOf(playerState) > -1;
};
this.showUi = () => {
isHideUiPending = false;
if (!isUiShown) {
// Let subscribers know that they should reveal themselves
uimanager.onControlsShow.dispatch(this);
isUiShown = true;
}
// Don't trigger timeout while seeking (it will be triggered once the seek is finished) or casting
if (!isSeeking && !player.isCasting() && !this.hidingPrevented()) {
this.uiHideTimeout.start();
}
};
this.hideUi = (force: boolean = false) => {
// Hide the UI only if it is shown, and if not casting
if (isUiShown && !player.isCasting()) {
if (force) {
uimanager.onControlsHide.dispatch(this);
isUiShown = false;
return;
}
if (config.deferUiHideWhileSettingsOpen && isSettingsPanelShown) {
isHideUiPending = true;
return;
}
// Issue a preview event to check if we are good to hide the controls
const previewHideEventArgs = <CancelEventArgs>{};
uimanager.onPreviewControlsHide.dispatch(this, previewHideEventArgs);
if (!previewHideEventArgs.cancel) {
// If the preview wasn't canceled, let subscribers know that they should now hide themselves
uimanager.onControlsHide.dispatch(this);
isUiShown = false;
} else {
// If the hide preview was canceled, continue to show UI
this.showUi();
}
}
};
this.toggleUiShown = () => {
isUiShown ? this.hideUi() : this.showUi();
};
// Timeout to defer UI hiding by the configured delay time
this.uiHideTimeout = new Timeout(config.hideDelay, this.hideUi);
const checkActionAllowed = (e: Event): Boolean => {
/**
* The super-modern-UI has its own component, with its own listeners,
* to detect touches on empty space {@link TouchControlOverlay}.
* Because the {@link UIContainer} is the root container, it also detects these touches.
* In order to let the {@link TouchControlOverlay} do its work correctly,
* we check if the touched target is an instance of it.
*/
return !((e.target as HTMLElementWithComponent).component instanceof TouchControlOverlay);
};
this.userInteractionEvents = [
{
// On touch displays, the first touch reveals the UI
name: 'touchend',
handler: e => {
if (!checkActionAllowed(e)) {
return;
}
const shouldPreventDefault = (e: Event): Boolean => {
const findButtonComponent = (
element: HTMLElementWithComponent,
): Button<ButtonConfig> | TouchControlOverlay | null => {
if (
!element ||
element === this.userInteractionEventSource.get(0) ||
element.component instanceof UIContainer
) {
return null;
}
if (
(element.component && element.component instanceof Button) ||
element.component instanceof TouchControlOverlay
) {
return element.component;
} else {
return findButtonComponent(element.parentElement);
}
};
const buttonComponent = findButtonComponent(e.target as HTMLElementWithComponent);
return !(buttonComponent && buttonComponent.getConfig().acceptsTouchWithUiHidden);
};
if (!isUiShown) {
// Only if the UI is hidden, we prevent other actions (except for the first touch) and reveal the UI
// instead. The first touch is not prevented to let other listeners receive the event and trigger an
// initial action, e.g. the huge playback button can directly start playback instead of requiring a double
// tap which 1. reveals the UI and 2. starts playback.
if (isFirstTouch && !player.isPlaying()) {
isFirstTouch = false;
} else {
// On touch input devices, the first touch is expected to display the UI controls and not be propagated to
// other components.
// When buttons are always visible this causes UX problems, as the first touch is not recognized.
// This is the case for the {@link AdSkipButton} and {@link AdClickOverlay}.
// To prevent UX issues where the buttons need to be touched twice, we do not prevent the first touch event.
if (shouldPreventDefault(e)) {
e.preventDefault();
}
}
this.showUi();
}
},
},
{
// When the mouse enters, we show the UI
name: 'mouseenter',
handler: e => {
if (checkActionAllowed(e)) {
this.showUi();
}
},
},
{
// When the mouse moves within, we show the UI
name: 'mousemove',
handler: e => {
if (checkActionAllowed(e)) {
this.showUi();
}
},
},
{
name: 'focusin',
handler: e => {
if (checkActionAllowed(e)) {
this.showUi();
}
},
},
{
name: 'keydown',
handler: e => {
if (checkActionAllowed(e)) {
this.showUi();
}
},
},
{
// When the mouse leaves, we can prepare to hide the UI, except a seek is going on
name: 'mouseleave',
handler: () => {
// When a seek is going on, the seek scrub pointer may exit the UI area while still seeking, and we do not
// hide the UI in such cases
if (!isSeeking && !this.hidingPrevented()) {
if (this.config.hideImmediatelyOnMouseLeave) {
this.hideUi(true);
} else {
this.uiHideTimeout.start();
}
}
},
},
{
// When scrolling, we show the UI
name: 'wheel',
handler: e => {
if (checkActionAllowed(e)) {
this.showUi();
}
},
},
];
this.userInteractionEvents.forEach(event => this.userInteractionEventSource.on(event.name, event.handler));
// Add click listener running on capture phase to intercept clicks before stopPropagation() in buttons
this.userInteractionEventSource.on(
'click',
e => {
if (checkActionAllowed(e)) {
this.showUi();
}
},
{ capture: true },
);
uimanager.onSeek.subscribe(() => {
this.uiHideTimeout.clear(); // Don't hide UI while a seek is in progress
isSeeking = true;
});
uimanager.onSeeked.subscribe(() => {
isSeeking = false;
if (!this.hidingPrevented()) {
this.uiHideTimeout.start(); // Re-enable UI hide timeout after a seek
}
});
uimanager.onComponentViewModeChanged.subscribe((_, { mode }) => this.trackComponentViewMode(mode));
player.on(player.exports.PlayerEvent.CastStarted, () => {
this.showUi(); // Show UI when a Cast session has started (UI will then stay permanently on during the session)
});
this.playerStateChange.subscribe((_, state) => {
playerState = state;
if (this.hidingPrevented()) {
// Entering a player state that prevents hiding and forces the controls to be shown
this.uiHideTimeout.clear();
this.showUi();
} else {
// Entering a player state that allows hiding
this.uiHideTimeout.start();
}
});
}
private configurePlayerStates(player: PlayerAPI, uimanager: UIInstanceManager): void {
const container = this.getDomElement();
// Convert player states into CSS class names
const stateClassNames = <any>[];
for (const state in PlayerUtils.PlayerState) {
if (isNaN(Number(state))) {
const enumName = PlayerUtils.PlayerState[<any>PlayerUtils.PlayerState[state]];
stateClassNames[PlayerUtils.PlayerState[state]] = this.prefixCss(
UIContainer.STATE_PREFIX + enumName.toLowerCase(),
);
}
}
const removeStates = () => {
container.removeClass(stateClassNames[PlayerUtils.PlayerState.Idle]);
container.removeClass(stateClassNames[PlayerUtils.PlayerState.Prepared]);
container.removeClass(stateClassNames[PlayerUtils.PlayerState.Playing]);
container.removeClass(stateClassNames[PlayerUtils.PlayerState.Paused]);
container.removeClass(stateClassNames[PlayerUtils.PlayerState.Finished]);
};
const updateState = (state: PlayerUtils.PlayerState) => {
removeStates();
container.addClass(stateClassNames[state]);
this.playerStateChange.dispatch(this, state);
};
player.on(player.exports.PlayerEvent.SourceLoaded, () => {
updateState(PlayerUtils.PlayerState.Prepared);
});
player.on(player.exports.PlayerEvent.Play, () => {
updateState(PlayerUtils.PlayerState.Playing);
});
player.on(player.exports.PlayerEvent.Playing, () => {
updateState(PlayerUtils.PlayerState.Playing);
});
player.on(player.exports.PlayerEvent.Paused, () => {
updateState(PlayerUtils.PlayerState.Paused);
});
player.on(player.exports.PlayerEvent.PlaybackFinished, () => {
updateState(PlayerUtils.PlayerState.Finished);
});
player.on(player.exports.PlayerEvent.SourceUnloaded, () => {
updateState(PlayerUtils.PlayerState.Idle);
});
uimanager.getConfig().events.onUpdated.subscribe(() => {
updateState(PlayerUtils.getState(player));
});
// Fullscreen marker class
player.on(player.exports.PlayerEvent.ViewModeChanged, () => {
if (player.getViewMode() === player.exports.ViewMode.Fullscreen) {
container.addClass(this.prefixCss(UIContainer.FULLSCREEN));
} else {
container.removeClass(this.prefixCss(UIContainer.FULLSCREEN));
}
});
// Init fullscreen state
if (player.getViewMode() === player.exports.ViewMode.Fullscreen) {
container.addClass(this.prefixCss(UIContainer.FULLSCREEN));
}
// Buffering marker class
player.on(player.exports.PlayerEvent.StallStarted, () => {
container.addClass(this.prefixCss(UIContainer.BUFFERING));
});
player.on(player.exports.PlayerEvent.StallEnded, () => {
container.removeClass(this.prefixCss(UIContainer.BUFFERING));
});
// Init buffering state
if (player.isStalled()) {
container.addClass(this.prefixCss(UIContainer.BUFFERING));
}
// RemoteControl marker class
player.on(player.exports.PlayerEvent.CastStarted, () => {
container.addClass(this.prefixCss(UIContainer.REMOTE_CONTROL));
});
player.on(player.exports.PlayerEvent.CastStopped, () => {
container.removeClass(this.prefixCss(UIContainer.REMOTE_CONTROL));
});
// Init RemoteControl state
if (player.isCasting()) {
container.addClass(this.prefixCss(UIContainer.REMOTE_CONTROL));
}
// Controls visibility marker class
uimanager.onControlsShow.subscribe(() => {
container.removeClass(this.prefixCss(UIContainer.CONTROLS_HIDDEN));
container.addClass(this.prefixCss(UIContainer.CONTROLS_SHOWN));
});
uimanager.onControlsHide.subscribe(() => {
container.removeClass(this.prefixCss(UIContainer.CONTROLS_SHOWN));
container.addClass(this.prefixCss(UIContainer.CONTROLS_HIDDEN));
});
// Layout size classes
const updateLayoutSizeClasses = (width: number, height: number) => {
container.removeClass(this.prefixCss('layout-max-width-400'));
container.removeClass(this.prefixCss('layout-max-width-600'));
container.removeClass(this.prefixCss('layout-max-width-800'));
container.removeClass(this.prefixCss('layout-max-width-1200'));
if (width <= 400) {
container.addClass(this.prefixCss('layout-max-width-400'));
} else if (width <= 600) {
container.addClass(this.prefixCss('layout-max-width-600'));
} else if (width <= 800) {
container.addClass(this.prefixCss('layout-max-width-800'));
} else if (width <= 1200) {
container.addClass(this.prefixCss('layout-max-width-1200'));
}
};
player.on(player.exports.PlayerEvent.PlayerResized, (e: PlayerResizedEvent) => {
// Convert strings (with "px" suffix) to ints
const width = Math.round(Number(e.width.substring(0, e.width.length - 2)));
const height = Math.round(Number(e.height.substring(0, e.height.length - 2)));
updateLayoutSizeClasses(width, height);
});
// Init layout state
updateLayoutSizeClasses(new DOM(player.getContainer()).width(), new DOM(player.getContainer()).height());
}
release(): void {
// Explicitly unsubscribe user interaction event handlers because they could be attached to an external element
// that isn't owned by the UI and therefore not removed on release.
if (this.userInteractionEvents) {
this.userInteractionEvents.forEach(event => this.userInteractionEventSource.off(event.name, event.handler));
}
super.release();
if (this.uiHideTimeout) {
this.uiHideTimeout.clear();
}
}
onPlayerStateChange(): UiEvent<UIContainer, PlayerUtils.PlayerState> {
return this.playerStateChange.getEvent();
}
protected suspendHideTimeout() {
this.uiHideTimeout.suspend();
}
protected resumeHideTimeout() {
this.uiHideTimeout.resume(!this.hidingPrevented());
}
protected toDomElement(): DOM {
const container = super.toDomElement();
// Detect flexbox support (not supported in IE9)
if (document && typeof document.createElement('p').style.flex !== 'undefined') {
container.addClass(this.prefixCss('flexbox'));
} else {
container.addClass(this.prefixCss('no-flexbox'));
}
return container;
}
}