/*! @license
* Shaka Player
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.MenuBase');
goog.require('shaka.ui.Element');
goog.requireType('shaka.ui.Controls');
/**
* Abstract base class for UI menu elements (OverflowMenu, SettingsMenu).
*
* @extends {shaka.ui.Element}
* @abstract
* @export
*/
shaka.ui.MenuBase = class extends shaka.ui.Element {
/**
* @param {!HTMLElement} parent
* @param {!shaka.ui.Controls} controls
*/
constructor(parent, controls) {
super(parent, controls);
/** @protected {!shaka.extern.UIConfiguration} */
this.config = this.controls.getConfig();
/** @private {HTMLElement} */
this.videoContainer_ = this.controls.getVideoContainer();
/** @private {ResizeObserver} */
this.resizeObserver_ = null;
/** @private {?number} */
this.resizeRafId_ = null;
const resize = () => {
if (this.resizeRafId_ != null) {
cancelAnimationFrame(this.resizeRafId_);
}
this.resizeRafId_ = requestAnimationFrame(() => {
this.resizeRafId_ = null;
this.adjustCustomStyle();
});
};
// Use ResizeObserver if available, fallback to window resize event.
if (window.ResizeObserver) {
this.resizeObserver_ = new ResizeObserver(resize);
this.resizeObserver_.observe(this.controls.getVideoContainer());
} else {
// Fallback for older browsers.
this.eventManager.listen(window, 'resize', resize);
}
if ('documentPictureInPicture' in window) {
this.eventManager.listen(window.documentPictureInPicture, 'enter',
(e) => {
const event = /** @type {DocumentPictureInPictureEvent} */(e);
const pipWindow = event.window;
this.eventManager.listen(pipWindow, 'resize', resize);
this.eventManager.listenOnce(pipWindow, 'pagehide', () => {
this.eventManager.unlisten(pipWindow, 'resize', resize);
resize();
});
resize();
});
}
}
/** @override */
release() {
if (this.resizeObserver_) {
this.resizeObserver_.disconnect();
this.resizeObserver_ = null;
}
if (this.resizeRafId_ != null) {
cancelAnimationFrame(this.resizeRafId_);
this.resizeRafId_ = null;
}
super.release();
}
/**
* Called by the RAF-debounced resize handler.
* Subclasses override this to reposition their specific menu element.
*
* @protected
*/
adjustCustomStyle() {}
/**
* Shared positioning algorithm used by both OverflowMenu and SettingsMenu.
*
* Computes:
* - maxHeight so the menu does not overflow the video container vertically.
* - left/right offset so the menu stays within the controls bar
* horizontally, aligned with the button that opened it.
*
* @param {!HTMLElement} menuElement The floating menu div to position.
* @param {!HTMLElement} buttonElement The button that triggered the menu.
* @param {!HTMLElement} controlsContainer
* The bottom controls bar used as the horizontal reference.
* @protected
*/
adjustMenuStyle(menuElement, buttonElement, controlsContainer) {
// --- Max height ---
const rectMenu = menuElement.getBoundingClientRect();
// Use the element's own window so this works both in the main document
// and when videoContainer has been moved into a DocumentPictureInPicture
// window (where the global `window` would be the wrong browsing context).
const elementWindow = menuElement.ownerDocument.defaultView || window;
const styleMenu = elementWindow.getComputedStyle(menuElement);
const paddingTop = parseFloat(styleMenu.paddingTop);
const paddingBottom = parseFloat(styleMenu.paddingBottom);
const rectContainer = this.videoContainer_.getBoundingClientRect();
const gap = 5;
const heightIntersection =
rectMenu.bottom - rectContainer.top - paddingTop - paddingBottom - gap;
menuElement.style.maxHeight = heightIntersection + 'px';
if (this.config.showMenusOnTheRight) {
menuElement.style.right = '15px';
return;
}
// --- Horizontal position ---
const bottomControlsPos = controlsContainer.getBoundingClientRect();
const buttonPos = buttonElement.getBoundingClientRect();
const leftGap = buttonPos.left - bottomControlsPos.left;
const rightGap = bottomControlsPos.right - buttonPos.right;
const EDGE_PADDING = 15;
const MIN_GAP = 60;
// Align to whichever side has more space, respecting a minimum edge gap.
if (leftGap < rightGap) {
const left = leftGap < MIN_GAP ?
EDGE_PADDING : Math.max(leftGap, EDGE_PADDING);
menuElement.style.left = left + 'px';
menuElement.style.right = 'auto';
} else {
const right = rightGap < MIN_GAP ?
EDGE_PADDING : Math.max(rightGap, EDGE_PADDING);
menuElement.style.right = right + 'px';
menuElement.style.left = 'auto';
}
}
};