UNPKG

bitmovin-player-ui

Version:
590 lines (498 loc) 18.8 kB
import { Container, ContainerConfig } from '../Container'; import { SelectBox } from './SelectBox'; import { UIInstanceManager } from '../../UIManager'; import { Timeout } from '../../utils/Timeout'; import { Event, EventDispatcher, NoArgs } from '../../EventDispatcher'; import { SettingsPanelPage, SettingsPanelPageConfig } from './SettingsPanelPage'; import { SettingsPanelItem, SettingsPanelItemConfig } from './SettingsPanelItem'; import { PlayerAPI } from 'bitmovin-player'; import { Component, ComponentConfig } from '../Component'; import { getKeyMapForPlatform } from '../../spatialnavigation/getKeyMapForPlatform'; import { Action } from '../../spatialnavigation/types'; /** * Configuration interface for a {@link SettingsPanel}. * * @category Configs */ export interface SettingsPanelConfig extends ContainerConfig { /** * The delay in milliseconds after which the settings panel will be hidden when there is no user interaction. * Set to -1 to disable automatic hiding. * Default: 5 seconds (5000) */ hideDelay?: number; /** * Flag to specify if there should be an animation when switching SettingsPanelPages. * Default: true */ pageTransitionAnimation?: boolean; /** * The delay in milliseconds after hiding the settings panel before its internal state * (e.g., navigation stack and scroll position) is reset. * Set to -1 to disable automatic state reset. * Default: 5 seconds (5000) */ stateResetDelay?: number; } /** * State interface for preserving settings panel navigation and scroll position */ export interface SettingsPanelState { activePageIndex: number; navigationStackIndices: number[]; scrollTop: number; wrapperScrollTop: number; } export enum NavigationDirection { Forwards, Backwards, } /** * A panel containing a list of {@link SettingsPanelPage items}. * * To configure pages just pass them in the components array. * * Example: * let settingsPanel = new SettingsPanel({ * hidden: true, * }); * * let settingsPanelPage = new SettingsPanelPage({ * components: […] * }); * * let secondSettingsPanelPage = new SettingsPanelPage({ * components: […] * }); * * settingsPanel.addComponent(settingsPanelPage); * settingsPanel.addComponent(secondSettingsPanelPage); * * For an example how to navigate between pages @see SettingsPanelPageNavigatorButton * * @category Components */ export class SettingsPanel<Config extends SettingsPanelConfig> extends Container<Config> { private static readonly CLASS_ACTIVE_PAGE = 'active'; // navigation handling private activePage: SettingsPanelPage; private navigationStack: SettingsPanelPage[] = []; private currentState: SettingsPanelState = null; private resetStateTimerId: number | null = null; private shouldResetStateImmediately: boolean = false; private settingsPanelEvents = { onSettingsStateChanged: new EventDispatcher<SettingsPanel<SettingsPanelConfig>, NoArgs>(), onActivePageChanged: new EventDispatcher<SettingsPanel<SettingsPanelConfig>, NoArgs>(), }; private hideTimeout: Timeout; constructor(config: Config) { super(config); this.config = this.mergeConfig( config, { cssClass: 'ui-settings-panel', hideDelay: 5000, pageTransitionAnimation: true, stateResetDelay: 5000, } as Config, this.config, ); this.activePage = this.getRootPage(); this.onActivePageChangedEvent(); } configure(player: PlayerAPI, uimanager: UIInstanceManager): void { super.configure(player, uimanager); const config = this.getConfig(); uimanager.onControlsHide.subscribe(() => this.hideHoveredSelectBoxes()); uimanager.onComponentViewModeChanged.subscribe((_, { mode }) => this.trackComponentViewMode(mode)); if (config.hideDelay > -1) { this.hideTimeout = new Timeout(config.hideDelay, () => { this.hide(); this.hideHoveredSelectBoxes(); }); this.getDomElement().on('mouseenter mousemove', () => { this.hideTimeout.reset(); }); this.getDomElement().on('mouseleave', () => { // On mouse leave activate the timeout this.hideTimeout.reset(); }); this.getDomElement().on('focusin', () => { this.hideTimeout.clear(); }); this.getDomElement().on('focusout', () => { this.hideTimeout.reset(); }); } if (config.pageTransitionAnimation) { const handleResize = () => { // Reset the dimension of the settingsPanel to let the browser calculate the new dimension after resizing this.getDomElement().css({ width: '', height: '' }); }; player.on(player.exports.PlayerEvent.PlayerResized, handleResize); } const maybeCloseSettingsPanel = (event: KeyboardEvent) => { const action = getKeyMapForPlatform()[event.keyCode]; if (action === Action.BACK) { this.hide(); this.resetState(); } }; const scheduleResetState = () => { if (this.resetStateTimerId !== null) { clearTimeout(this.resetStateTimerId); this.resetStateTimerId = null; } if (config.stateResetDelay > -1) { this.resetStateTimerId = window.setTimeout(() => this.resetState(), config.stateResetDelay); } }; this.onHide.subscribe(() => { if (this.shouldResetStateImmediately) { this.currentState = null; this.shouldResetStateImmediately = false; } else { this.currentState = this.maybeSaveCurrentState(); scheduleResetState(); } if (config.hideDelay > -1) { // Clear timeout when hidden from outside this.hideTimeout.clear(); } // Since we don't reset the actual navigation here we need to simulate a onInactive event in case some panel // needs to do something when they become invisible / inactive. this.activePage.onInactiveEvent(); document.removeEventListener('keyup', maybeCloseSettingsPanel); }); this.onShow.subscribe(() => { if (this.resetStateTimerId !== null) { clearTimeout(this.resetStateTimerId); this.resetStateTimerId = null; } if (this.currentState !== null) { this.restoreNavigationState(this.currentState); } else { // No saved state (was reset), ensure visual classes are updated this.updateActivePageClass(); } // Since we don't need to navigate to the root page again we need to fire the onActive event when the settings // panel gets visible. this.activePage.onActiveEvent(); if (config.hideDelay > -1) { // Activate timeout when shown this.hideTimeout.start(); } document.addEventListener('keyup', maybeCloseSettingsPanel); }); // pass event from root page through this.getRootPage().onSettingsStateChanged.subscribe(() => { this.onSettingsStateChangedEvent(); }); uimanager.onControlsHide.subscribe(() => { this.hide(); }); uimanager.onControlsShow.subscribe(() => { if (this.currentState !== null) { this.show(); } }); this.updateActivePageClass(); } /** * Returns the current active / visible page * @return {SettingsPanelPage} */ getActivePage(): SettingsPanelPage { return this.activePage; } /** * Sets the * @deprecated Use {@link setActivePage} instead * @param index */ setActivePageIndex(index: number): void { this.setActivePage(this.getPages()[index]); } /** * Adds the passed page to the navigation stack and makes it visible. * Use {@link popSettingsPanelPage} to navigate backwards. * * Results in no-op if the target page is the current page. * @param targetPage */ setActivePage(targetPage: SettingsPanelPage): void { if (targetPage === this.getActivePage()) { console.warn('Page is already the current one ... skipping navigation'); return; } this.navigateToPage( targetPage, this.getActivePage(), NavigationDirection.Forwards, !(this.config as SettingsPanelConfig).pageTransitionAnimation, ); } /** * Resets the navigation stack by navigating back to the root page and displaying it. */ popToRootSettingsPanelPage(): void { this.resetNavigation((this.config as SettingsPanelConfig).pageTransitionAnimation); } /** * Removes the current page from the navigation stack and makes the previous one visible. * Results in a no-op if we are already on the root page. */ popSettingsPanelPage() { if (this.navigationStack.length === 0) { console.warn('Already on the root page ... skipping navigation'); return; } let targetPage = this.navigationStack[this.navigationStack.length - 2]; // The root part isn't part of the navigation stack so handle it explicitly here if (!targetPage) { targetPage = this.getRootPage(); } const currentActivePage = this.activePage; this.navigateToPage( targetPage, this.activePage, NavigationDirection.Backwards, !(this.config as SettingsPanelConfig).pageTransitionAnimation, ); if (currentActivePage.getConfig().removeOnPop) { this.removeComponent(currentActivePage); this.updateComponents(); } } /** * Checks if there are active settings within the root page of the settings panel. * An active setting is a setting that is visible and enabled, which the user can interact with. * @returns {boolean} true if there are active settings, false if the panel is functionally empty to a user */ rootPageHasActiveSettings(): boolean { return this.getRootPage().hasActiveSettings(); } /** * Return all configured pages * @returns {SettingsPanelPage[]} */ getPages(): SettingsPanelPage[] { return <SettingsPanelPage[]>this.config.components.filter(component => component instanceof SettingsPanelPage); } /** * Returns the root page of the settings panel. * @returns {SettingsPanelPage} */ getRootPage(): SettingsPanelPage { return this.getPages()[0]; } get onSettingsStateChanged(): Event<SettingsPanel<SettingsPanelConfig>, NoArgs> { return this.settingsPanelEvents.onSettingsStateChanged.getEvent(); } get onActivePageChanged(): Event<SettingsPanel<SettingsPanelConfig>, NoArgs> { return this.settingsPanelEvents.onActivePageChanged.getEvent(); } hideAndReset(): void { this.shouldResetStateImmediately = true; this.hide(); this.resetState(); } release(): void { super.release(); if (this.hideTimeout) { this.hideTimeout.clear(); } } // Support adding settingsPanelPages after initialization addComponent(component: Component<ComponentConfig>) { if (this.getPages().length === 0 && component instanceof SettingsPanelPage) { this.activePage = component; this.onActivePageChangedEvent(); } super.addComponent(component); } addPage(page: SettingsPanelPage) { this.addComponent(page); this.updateComponents(); } protected suspendHideTimeout() { this.hideTimeout.suspend(); } protected resumeHideTimeout() { this.hideTimeout.resume(true); } private updateActivePageClass(): void { this.getPages().forEach((page: SettingsPanelPage) => { if (page === this.activePage) { page.getDomElement().addClass(this.prefixCss(SettingsPanel.CLASS_ACTIVE_PAGE)); } else { page.getDomElement().removeClass(this.prefixCss(SettingsPanel.CLASS_ACTIVE_PAGE)); } }); } private resetNavigation(resetNavigationOnShow: boolean): void { const sourcePage = this.getActivePage(); const rootPage = this.getRootPage(); if (sourcePage) { // Since the onInactiveEvent was already fired in the onHide we need to suppress it here if (!resetNavigationOnShow) { sourcePage.onInactiveEvent(); } } this.navigationStack = []; this.animateNavigation(rootPage, sourcePage, resetNavigationOnShow); this.activePage = rootPage; this.updateActivePageClass(); this.onActivePageChangedEvent(); } private get wrapperScrollTop(): number { return this.innerContainerElement.get(0)?.scrollTop ?? 0; } private set wrapperScrollTop(value: number) { const element = this.innerContainerElement.get(0); if (element) { element.scrollTop = value; } } private resetState(): void { this.activePage = this.getRootPage(); this.navigationStack = []; this.currentState = null; this.resetStateTimerId = null; if (this.isHidden()) { // Clear dimensions only when hidden to avoid visible transition animation this.getDomElement().css({ width: '', height: '' }); } } private buildCurrentState(): SettingsPanelState { const pages = this.getPages(); const activePageIndex = pages.indexOf(this.getActivePage()); const navigationStackIndices = this.navigationStack.map(p => pages.indexOf(p)); const panelElement = this.getDomElement().get(0); return { activePageIndex, navigationStackIndices, scrollTop: panelElement.scrollTop, wrapperScrollTop: this.wrapperScrollTop, }; } private isDefaultPanelState(): boolean { const panelElement = this.getDomElement().get(0); const atRoot = this.getActivePage() === this.getRootPage(); const noNav = this.navigationStack.length === 0; const noScroll = (panelElement?.scrollTop ?? 0) === 0 && this.wrapperScrollTop === 0; return atRoot && noNav && noScroll; } private maybeSaveCurrentState(): SettingsPanelState | null { return this.isDefaultPanelState() ? null : this.buildCurrentState(); } private restoreNavigationState(state: SettingsPanelState): void { const pages = this.getPages(); this.activePage = pages[state.activePageIndex] ?? this.getRootPage(); this.navigationStack = state.navigationStackIndices.map(i => pages[i]).filter(Boolean); this.updateActivePageClass(); this.onActivePageChangedEvent(); this.activePage.onActiveEvent(); this.getDomElement().get(0).scrollTop = state.scrollTop; this.wrapperScrollTop = state.wrapperScrollTop; } protected navigateToPage( targetPage: SettingsPanelPage, sourcePage: SettingsPanelPage, direction: NavigationDirection, skipAnimation: boolean, ): void { this.activePage = targetPage; if (direction === NavigationDirection.Forwards) { this.navigationStack.push(targetPage); } else { this.navigationStack.pop(); } this.animateNavigation(targetPage, sourcePage, skipAnimation); this.updateActivePageClass(); sourcePage.onInactiveEvent(); targetPage.onActiveEvent(); this.onActivePageChangedEvent(); } /** * @param targetPage * @param sourcePage * @param skipAnimation This is just an internal flag if we want to have an animation. It is set true when we reset * the navigation within the onShow callback of the settingsPanel. In this case we don't want an actual animation but * the recalculation of the dimension of the settingsPanel. * This is independent of the pageTransitionAnimation flag. */ private animateNavigation(targetPage: SettingsPanelPage, sourcePage: SettingsPanelPage, skipAnimation: boolean) { if (!(this.config as SettingsPanelConfig).pageTransitionAnimation) { return; } const settingsPanelDomElement = this.getDomElement(); const settingsPanelHTMLElement = this.getDomElement().get(0); // get current dimension const settingsPanelWidth = settingsPanelHTMLElement.scrollWidth; const settingsPanelHeight = settingsPanelHTMLElement.scrollHeight; // calculate target size of the settings panel sourcePage.getDomElement().css('display', 'none'); this.getDomElement().css({ width: '', height: '' }); // let css auto settings kick in again const targetPageHtmlElement = targetPage.getDomElement().get(0); // clone the targetPage DOM element so that we can calculate the width / height how they will be after // switching the page. We are using a clone to prevent (mostly styling) side-effects on the real DOM element const clone = targetPageHtmlElement.cloneNode(true) as HTMLElement; // append to parent so we get the 'real' size const containerWrapper = targetPageHtmlElement.parentNode; containerWrapper.appendChild(clone); // set clone visible clone.style.display = 'block'; // collect target dimension const targetSettingsPanelWidth = settingsPanelHTMLElement.scrollWidth; const targetSettingsPanelHeight = settingsPanelHTMLElement.scrollHeight; // remove clone from the DOM clone.parentElement.removeChild(clone); // .remove() is not working in IE sourcePage.getDomElement().css('display', ''); // set the values back to the current ones that the browser animates it (browsers don't animate 'auto' values) settingsPanelDomElement.css({ width: settingsPanelWidth + 'px', height: settingsPanelHeight + 'px', }); if (!skipAnimation) { // We need to force the browser to reflow between setting the width and height that we actually get a animation this.forceBrowserReflow(); } // set the values to the target dimension settingsPanelDomElement.css({ width: targetSettingsPanelWidth + 'px', height: targetSettingsPanelHeight + 'px', }); } private forceBrowserReflow(): void { // Force the browser to reflow the layout // https://gist.github.com/paulirish/5d52fb081b3570c81e3a // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.getDomElement().get(0).offsetLeft; } /** * Workaround for IE, Firefox and Safari * when the settings panel fades out while an item of a select box is still hovered, the select box will not fade out * while the settings panel does. This would leave a floating select box, which is just weird */ private hideHoveredSelectBoxes(): void { this.getComputedItems() .map(item => item['settingComponent']) .filter(component => component instanceof SelectBox) .forEach((selectBox: SelectBox) => selectBox.closeDropdown()); } // collect all items from all pages (see hideHoveredSelectBoxes) private getComputedItems(): SettingsPanelItem<SettingsPanelItemConfig>[] { const allItems: SettingsPanelItem<SettingsPanelItemConfig>[] = []; for (const page of this.getPages()) { allItems.push(...page.getItems()); } return allItems; } protected onSettingsStateChangedEvent() { this.settingsPanelEvents.onSettingsStateChanged.dispatch(this); } protected onActivePageChangedEvent() { this.settingsPanelEvents.onActivePageChanged.dispatch(this); } }