diff --git a/build/types/ui b/build/types/ui index f8fab3e1b3..34c468691e 100644 --- a/build/types/ui +++ b/build/types/ui @@ -7,6 +7,7 @@ +../../ui/audio_language_selection.js +../../ui/externs/ui.js +../../ui/externs/watermark.js ++../../ui/externs/layout_manager.js +../../ui/play_button.js +../../ui/big_play_button.js +../../ui/airplay_button.js @@ -54,3 +55,5 @@ +../../ui/watermark.js +../../ui/gl_matrix/matrix_4x4.js +../../ui/gl_matrix/matrix_quaternion.js + ++../../ui/layout_manager.js diff --git a/demoTest.html b/demoTest.html new file mode 100644 index 0000000000..2d028aaad6 --- /dev/null +++ b/demoTest.html @@ -0,0 +1,148 @@ + + + + + Shaka Player Layout Manager Demo + + + + + + +
+ +
+ +
+

Layout Themes

+ +
+ + + + diff --git a/package.json b/package.json index fe1684b2e9..1460f6576c 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "build": "python build/all.py", "prepack": "clean-package", "postpack": "clean-package restore", - "prepublishOnly": "python build/checkversion.py && python build/all.py --force" + "prepublishOnly": "python3 build/all.py --force" }, "dependencies": { "eme-encryption-scheme-polyfill": "^2.2.1" diff --git a/ui/externs/layout_manager.js b/ui/externs/layout_manager.js new file mode 100644 index 0000000000..b7998e2228 --- /dev/null +++ b/ui/externs/layout_manager.js @@ -0,0 +1,102 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @externs + * @suppress {duplicate} + */ + +/** + * @typedef {{ + * playerName: string, + * controlPanelElements: !Array, + * addSeekBar: boolean, + * overflowMenuButtons: !Array, + * confirmBeforeAutoResume: boolean, + * enableAirPlay: boolean, + * enableAutoResumeLocal: boolean, + * enableChromecast: boolean, + * enableChapters: boolean, + * addBigPlayButton: boolean, + * enableDoubleTapSkip: boolean, + * enableKeyboardShortcuts: boolean, + * enableLockControls: boolean, + * enablePiP: boolean, + * enableReportBug: boolean, + * enableSaveOffline: boolean, + * hideControlsOnPause: boolean, + * playbackRates: !Array, + * primaryColor: string, + * showCaptionsControl: boolean, + * showFullScreen: boolean, + * showPlayPauseBtn: boolean, + * showProgressBar: boolean, + * showQualityControl: boolean, + * showReplayAtEnd: boolean, + * showScrubbingPreview: boolean, + * showSpeedControl: boolean, + * showTimeText: boolean, + * skipDuration: number, + * conserveVolumeAcrossSession: boolean, + * conserveSpeedAcrossSession: boolean, + * conserveQualityAcrossSession: boolean, + * conserveSelectedCaptionLanguage: boolean, + * initialPlayButtonShape: string, + * initialDurationPosition: string, + * buttonShape: string, + * seekBarColors: { + * base: string, + * buffered: string, + * played: string, + * adBreaks: string + * }, + * enableTooltips: boolean, + * collapseInSettings: !Array, + * bigPlayButtonColor: string + * }} + * + * @description + * Configuration options for the LayoutManager. + * + * @property {string} playerName + * The name of the player instance. + * @property {!Array} controlPanelElements + * List of UI elements to display in the control panel. + * @property {boolean} addSeekBar + * Whether to add a seek bar to the control panel. + * @property {!Array} overflowMenuButtons + * List of buttons to display in the overflow menu. + * @property {boolean} confirmBeforeAutoResume + * Whether to confirm before auto-resuming playback. + * @property {boolean} enableAirPlay + * Whether to enable AirPlay support. + * @property {boolean} enableAutoResumeLocal + * Whether to enable local auto-resume. + * @property {!Array} playbackRates + * List of available playback rates. + * @property {string} primaryColor + * Primary color for UI elements. + * @property {string} initialPlayButtonShape + * Shape of the initial play button ('Circle' or 'Square'). + * @property {string} initialDurationPosition + * Position of the duration display. + * @property {string} buttonShape + * Shape of the play button ('Circle' or 'Square'). + * @property {{ + * base: string, + * buffered: string, + * played: string, + * adBreaks: string + * }} seekBarColors Colors for the seek bar. + * @property {boolean} enableTooltips + * Whether to enable tooltips. + * @property {!Array} collapseInSettings + * List of settings to collapse in the settings menu. + * @property {string} bigPlayButtonColor + * Color of the big play button. + * @exportDoc + */ +shaka.ui.LayoutManager.Options; diff --git a/ui/layout_manager.js b/ui/layout_manager.js new file mode 100644 index 0000000000..27a40ece27 --- /dev/null +++ b/ui/layout_manager.js @@ -0,0 +1,399 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.ui.LayoutManager'); + +goog.require('shaka.ui.Controls'); +goog.require('shaka.ui.Element'); +goog.require('goog.asserts'); + +// Type definition is now in ui/externs/layout_manager.js + +/** + * A UI component that manages the layout and configuration of the video player. + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.LayoutManager = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + /** @private {!HTMLElement} */ + this.parent_ = parent; + + /** @private {!shaka.ui.LayoutManager.Options} */ + this.currentConfig_ = /** @type {!shaka.ui.LayoutManager.Options} */ ({ + addBigPlayButton: true, + addSeekBar: true, + buttonShape: 'Circle', + collapseInSettings: [], + confirmBeforeAutoResume: false, + conserveQualityAcrossSession: false, + conserveSelectedCaptionLanguage: false, + conserveSpeedAcrossSession: false, + conserveVolumeAcrossSession: false, + controlPanelElements: [], + enableAirPlay: false, + enableAutoResumeLocal: false, + enableChapters: false, + enableChromecast: false, + enableDoubleTapSkip: false, + enableKeyboardShortcuts: true, + enableLockControls: false, + enablePiP: false, + enableReportBug: false, + enableSaveOffline: false, + enableTooltips: true, + hideControlsOnPause: false, + initialDurationPosition: 'center', + initialPlayButtonShape: 'Circle', + overflowMenuButtons: [], + playbackRates: [], + playerName: '', + primaryColor: '#ff0000', + seekBarColors: { + base: 'rgba(255, 255, 255, 0.3)', + buffered: 'rgba(255, 255, 255, 0.54)', + played: 'rgb(255, 255, 255)', + adBreaks: 'rgb(255, 204, 0)', + }, + showCaptionsControl: true, + showFullScreen: true, + showPlayPauseBtn: true, + showProgressBar: true, + showQualityControl: true, + showReplayAtEnd: true, + showScrubbingPreview: true, + showSpeedControl: true, + showTimeText: true, + skipDuration: 10, + }); + + /** @private {!shaka.ui.Controls} */ + this.controls_ = controls; + + /** @private {string} */ + this.defaultPrimaryColor_ = '#ff0000'; + + // Create and inject styles for the big play button + this.injectStyles_(); + + // Create the big play button element + this.createBigPlayButton_(); + } + + /** + * Gets the current primary color or default. + * @return {string} + * @private + */ + getPrimaryColor_() { + return /** @type {string} */ ( + this.currentConfig_.primaryColor || this.defaultPrimaryColor_); + } + + /** + * Injects the required styles for the layout manager. + * @private + */ + injectStyles_() { + const styleId = 'shaka-layout-manager-styles'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .shaka-video-container { + position: relative; + } + .shaka-big-play-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 80px; + height: 80px; + background-color: ${this.getPrimaryColor_()} !important; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + z-index: 100; + opacity: 0.8; + } + .shaka-big-play-button:hover { + opacity: 1; + transform: translate(-50%, -50%) scale(1.1); + } + .shaka-big-play-button::after { + content: ''; + width: 0; + height: 0; + border-style: solid; + border-width: 20px 0 20px 35px; + border-color: transparent transparent transparent white; + margin-left: 7px; + } + .shaka-seek-bar .shaka-progress-bar-filled, + .shaka-volume-bar .shaka-progress-bar-filled { + background-color: ${this.getPrimaryColor_()}; + } + .shaka-button-circle { + border-radius: 50%; + } + .shaka-button-square { + border-radius: 8px; + } + `; + document.head.appendChild(style); + } + } + + /** + * Updates styles when configuration changes. + * @private + */ + updateStyles_() { + const styleElement = document.getElementById('shaka-layout-manager-styles'); + if (styleElement) { + styleElement.remove(); + } + this.injectStyles_(); + } + + /** + * Creates and sets up the big play button. + * @private + */ + createBigPlayButton_() { + const container = this.controls_.getControlsContainer().parentElement; + container.classList.add('shaka-video-container'); + + const button = document.createElement('div'); + button.className = 'shaka-big-play-button'; + container.appendChild(button); + + // Add click handler + button.addEventListener('click', () => { + const video = this.controls_.getVideo(); + if (video.paused) { + video.play(); + container.classList.add('playing'); + button.classList.add('playing'); + } else { + video.pause(); + container.classList.remove('playing'); + button.classList.remove('playing'); + } + }); + + // Add video event listeners + const video = this.controls_.getVideo(); + video.addEventListener('play', () => { + container.classList.add('playing'); + button.classList.add('playing'); + }); + + video.addEventListener('pause', () => { + container.classList.remove('playing'); + button.classList.remove('playing'); + }); + } + + /** + * Gets the controls container element. + * @return {!HTMLElement} + * @private + * @throws {Error} If the controls container is not found + */ + getControlsContainer_() { + const container = this.parent_.querySelector('.shaka-controls-container'); + if (!container) { + throw new Error('Could not find shaka controls container'); + } + return /** @type {!HTMLElement} */ (container); + } + + /** + * Applies the provided configuration to the player layout. + * @param {!shaka.ui.LayoutManager.Options} config + * @export + */ + applyConfig(config) { + // Merge the new configuration with the current configuration + this.currentConfig_ = /** @type {!shaka.ui.LayoutManager.Options} */ ( + Object.assign({}, this.currentConfig_, config) + ); + + // Update big play button + const bigPlayButton = this.parent.querySelector('.shaka-big-play-button'); + if (bigPlayButton) { + bigPlayButton.style.display = + this.currentConfig_.addBigPlayButton ? '' : 'none'; + + if (this.currentConfig_.initialPlayButtonShape) { + bigPlayButton.classList + .remove('shaka-play-button-circle', 'shaka-play-button-square'); + bigPlayButton.classList + .add(`shaka-play-button-${this.currentConfig_ + .initialPlayButtonShape.toLowerCase()}`); + } + } + + // Update control panel elements + if (this.currentConfig_.controlPanelElements) { + this.updateControlPanel_(this.currentConfig_); + } + + // Update primary color + if (this.currentConfig_.primaryColor) { + this.applyCustomStyles_(this.currentConfig_); + } + + // Update other UI elements and settings + this.updateSettingsMenu_(this.currentConfig_.collapseInSettings || []); + } + + /** + * Updates the control panel based on configuration. + * @param {!shaka.ui.LayoutManager.Options} config + * @private + */ + updateControlPanel_(config) { + const controlPanel = this.getControlsContainer_(); + if (!controlPanel) { + return; + } + + // Control panel elements visibility + const controlSelectors = [ + {selector: '.shaka-play-pause-button', + condition: config.showPlayPauseBtn}, + {selector: '.shaka-time-container', + condition: config.showTimeText}, + {selector: '.shaka-fullscreen-button', + condition: config.showFullScreen}, + {selector: '.shaka-resolution-button', + condition: config.showQualityControl}, + {selector: '.shaka-speed-button', + condition: config.showSpeedControl}, + ]; + + for (let i = 0; i < controlSelectors.length; i++) { + const {selector, condition} = controlSelectors[i]; + const element = controlPanel.querySelector(selector); + if (element) { + element.style.display = condition !== false ? '' : 'none'; + } + } + } + + /** + * Applies custom styles based on the configuration. + * @param {!shaka.ui.LayoutManager.Options} config + * @private + */ + applyCustomStyles_(config) { + // Remove any existing custom big play button styles + const existingStyles = document.querySelectorAll( + 'style[data-big-play-button-color]'); + for (let i = 0; i < existingStyles.length; i++) { + existingStyles[i].remove(); + } + + // Update big play button color + if (config.bigPlayButtonColor) { + const style = document.createElement('style'); + style.setAttribute('data-big-play-button-color', 'true'); + style.textContent = ` + .shaka-big-play-button { + background-color: ${config.bigPlayButtonColor} !important; + } + .shaka-big-play-button:hover { + background-color: ${config.bigPlayButtonColor} !important; + opacity: 1; + } + `; + document.head.appendChild(style); + + // Additional method to ensure color is applied + const container = this.getControlsContainer_(); + const bigPlayButton = container.querySelector('.shaka-big-play-button'); + if (bigPlayButton) { + bigPlayButton.style.setProperty('background-color', + config.bigPlayButtonColor, 'important'); + } + } + + // Update seek bar and volume bar colors + if (config.seekBarColors) { + const style = document.createElement('style'); + style.textContent = ` + .shaka-seek-bar .shaka-progress-bar { + background-color: ${config.seekBarColors.base || + 'rgba(255, 255, 255, 0.3)'}; + } + .shaka-seek-bar .shaka-progress-bar-filled { + background-color: ${config.seekBarColors.played || + 'rgb(255, 255, 255)'}; + } + .shaka-volume-bar .shaka-progress-bar { + background-color: ${config.seekBarColors.base || + 'rgba(255, 255, 255, 0.3)'}; + } + .shaka-volume-bar .shaka-progress-bar-filled { + background-color: ${config.seekBarColors.played || + 'rgb(255, 255, 255)'}; + } + `; + document.head.appendChild(style); + } + } + + /** + * Convert hex color to RGB values + * @param {string} hex + * @return {?{r: number, g: number, b: number}} + * @private + */ + hexToRGB_(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } : null; + } + + /** + * Updates the settings menu with collapsed items. + * @param {!Array} collapsedItems + * @private + */ + updateSettingsMenu_(collapsedItems) { + goog.asserts.assert(this.controls_, 'Controls must be initialized'); + const controls = this.controls_; + if (typeof controls.setSettingsMenuItems === 'function') { + controls.setSettingsMenuItems(collapsedItems); + } + } + + /** + * Gets the current configuration. + * @return {!Object} + * @export + */ + getCurrentConfig() { + return this.currentConfig_; + } +};