/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.PlaybackRateSelection');
goog.require('goog.asserts');
goog.require('shaka.ui.Controls');
goog.require('shaka.ui.Enums');
goog.require('shaka.ui.Locales');
goog.require('shaka.ui.OverflowMenu');
goog.require('shaka.ui.RangeElement');
goog.require('shaka.ui.SettingsMenu');
goog.require('shaka.util.Dom');
goog.require('shaka.util.NumberUtils');
goog.requireType('shaka.ui.Controls');
/**
* @extends {shaka.ui.SettingsMenu}
* @final
* @export
*/
shaka.ui.PlaybackRateSelection = class extends shaka.ui.SettingsMenu {
/**
* @param {!HTMLElement} parent
* @param {!shaka.ui.Controls} controls
*/
constructor(parent, controls) {
super(parent, controls,
shaka.ui.Enums.MaterialDesignSVGIcons['PLAYBACK_RATE']);
/** @private {!shaka.extern.UIConfiguration} */
this.config_ = this.controls.getConfig();
this.button.classList.add('shaka-playbackrate-button');
this.menu.classList.add('shaka-playback-rates');
this.button.classList.add('shaka-tooltip-status');
if (!this.isSubMenu) {
/** @private {HTMLElement} */
this.playbackRateMark_ = shaka.util.Dom.createHTMLElement('span');
this.playbackRateMark_.classList.add('shaka-overflow-playback-rate-mark');
this.button.appendChild(this.playbackRateMark_);
}
/** @private {shaka.ui.RangeElement} */
this.rateSlider_ = null;
/** @private {HTMLElement} */
this.speedValue_ = null;
/** @private {HTMLButtonElement} */
this.decreaseButton_ = null;
/** @private {HTMLButtonElement} */
this.increaseButton_ = null;
this.buildUI_();
this.eventManager.listenMulti(
this.player,
[
'loaded',
'ratechange',
], () => {
this.updatePlaybackRateSelection_();
});
this.updateLocalizedStrings();
this.updatePlaybackRateSelection_();
}
/** @override */
release() {
if (this.rateSlider_) {
this.rateSlider_.release();
this.rateSlider_ = null;
}
super.release();
}
/** @override */
updateLocalizedStrings() {
const LocIds = shaka.ui.Locales.Ids;
this.backButton.ariaLabel = this.localization.resolve(LocIds.BACK);
const label = this.localization.resolve(LocIds.PLAYBACK_RATE);
this.button.ariaLabel = label;
this.nameSpan.textContent = label;
this.backSpan.textContent = label;
}
/** @private */
buildUI_() {
// Slider section
const sliderSection = shaka.util.Dom.createHTMLElement('div');
sliderSection.classList.add('shaka-playback-rate-slider-section');
this.speedValue_ = shaka.util.Dom.createHTMLElement('div');
this.speedValue_.classList.add('shaka-playback-rate-value');
sliderSection.appendChild(this.speedValue_);
// Slider row: [−] ──────slider────── [+]
const sliderRow = shaka.util.Dom.createHTMLElement('div');
sliderRow.classList.add('shaka-playback-rate-slider-row');
// Decrease button (−)
this.decreaseButton_ = shaka.util.Dom.createButton();
this.decreaseButton_.classList.add('shaka-playback-rate-step-btn');
this.decreaseButton_.classList.add('shaka-no-propagation');
this.decreaseButton_.setAttribute('aria-label', '−');
this.decreaseButton_.textContent = '−';
sliderRow.appendChild(this.decreaseButton_);
// Range slider.
goog.asserts.assert(this.controls, 'Controls should not be null!');
this.rateSlider_ = new shaka.ui.RangeElement(
sliderRow,
this.controls,
/* containerClassNames= */
['shaka-playback-rate-slider-container', 'shaka-no-propagation'],
/* barClassNames= */ ['shaka-playback-rate-slider'],
/* enableWheel= */ true);
this.rateSlider_.setStep(shaka.ui.PlaybackRateSelection.SLIDER_STEP);
this.rateSlider_.onChange = () => {
this.applyRate_(this.rateSlider_.getValue());
};
// Increase button (+)
this.increaseButton_ = shaka.util.Dom.createButton();
this.increaseButton_.classList.add('shaka-playback-rate-step-btn');
this.increaseButton_.classList.add('shaka-no-propagation');
this.increaseButton_.setAttribute('aria-label', '+');
this.increaseButton_.textContent = '+';
sliderRow.appendChild(this.increaseButton_);
sliderSection.appendChild(sliderRow);
this.menu.appendChild(sliderSection);
// Step-button listeners.
this.eventManager.listen(this.decreaseButton_, 'click', () => {
this.stepRate_(-shaka.ui.PlaybackRateSelection.SLIDER_STEP);
});
this.eventManager.listen(this.increaseButton_, 'click', () => {
this.stepRate_(shaka.ui.PlaybackRateSelection.SLIDER_STEP);
});
// Preset pill buttons (horizontal)
const presetsRow = shaka.util.Dom.createHTMLElement('div');
presetsRow.classList.add('shaka-playback-rate-presets');
for (const rate of this.controls.getConfig().playbackRates) {
const btn = shaka.util.Dom.createButton();
btn.classList.add('shaka-playback-rate-preset-btn');
btn.setAttribute('role', 'menuitemradio');
btn.setAttribute('aria-checked', 'false');
btn.dataset['rate'] = String(rate);
btn.textContent = this.formatPresetLabel_(rate);
this.eventManager.listen(btn, 'click', () => {
this.applyRate_(rate);
});
presetsRow.appendChild(btn);
}
this.menu.appendChild(presetsRow);
}
/**
* @param {number} rate
* @private
*/
applyRate_(rate) {
if (rate === this.video.defaultPlaybackRate) {
this.player.cancelTrickPlay();
} else {
this.player.trickPlay(rate, /* useTrickPlayTrack= */ false);
}
}
/**
* Steps the playback rate by `delta`, snapped to SLIDER_STEP and clamped to
* the configured [playbackRateSliderMin, playbackRateSliderMax] range.
*
* Snapping the current rate to the step grid before adding delta ensures that
* a programmatic value like 0.97 is brought back onto the grid (0.95 or
* 1.00) on the next user interaction rather than accumulating floating-point
* drift.
*
* @param {number} delta
* @private
*/
stepRate_(delta) {
const config = this.controls.getConfig();
const min = config.playbackRateSliderMin;
const max = config.playbackRateSliderMax;
const step = shaka.ui.PlaybackRateSelection.SLIDER_STEP;
const current = this.player.getPlaybackRate();
// Snap current rate to the nearest step grid point, then apply delta.
const snapped = Math.round(current / step) * step;
const raw = snapped + delta;
// Eliminate floating-point noise (e.g. 0.05 * 20 → 1.0000000000000002).
const next = parseFloat((Math.round(raw / step) * step).toPrecision(10));
const clamped = Math.max(min, Math.min(max, next));
this.applyRate_(clamped);
}
/**
* Returns the effective slider range.
*
* Normally this is [playbackRateSliderMin, playbackRateSliderMax] from the
* config. If the current rate was set programmatically outside that range,
* the bounds are extended just enough to keep the thumb visible.
*
* @return {{min: number, max: number}}
* @private
*/
getSliderRange_() {
const config = this.controls.getConfig();
let min = config.playbackRateSliderMin;
let max = config.playbackRateSliderMax;
const currentRate = this.player.getPlaybackRate();
if (currentRate < min) {
min = currentRate;
}
if (currentRate > max) {
max = currentRate;
}
return {min, max};
}
/**
* Syncs slider range/value, live value label, step-button disabled state,
* and preset-pill highlights to the current player rate.
* @private
*/
updatePlaybackRateSelection_() {
const config = this.controls.getConfig();
const rate = this.player.getPlaybackRate();
// Update slider range first (may be extended for out-of-config rates).
const {min, max} = this.getSliderRange_();
this.rateSlider_.setRange(min, max);
this.rateSlider_.setValue(rate);
// Large centred value label.
this.speedValue_.textContent = rate.toFixed(2) + 'x';
// Disable step buttons at the configured hard limits (not the extended
// ones) so the user cannot go beyond the intended range by clicking.
this.decreaseButton_.disabled = rate <= config.playbackRateSliderMin;
this.increaseButton_.disabled = rate >= config.playbackRateSliderMax;
// Highlight the matching preset pill, if any.
const presetBtns =
this.menu.querySelectorAll('.shaka-playback-rate-preset-btn');
for (const btn of presetBtns) {
const button = /** @type {!HTMLButtonElement} */ (btn);
const btnRate = parseFloat(button.dataset['rate']);
const isChosen =
shaka.util.NumberUtils.isFloatEqual(btnRate, rate, 0.001);
button.setAttribute('aria-checked', isChosen ? 'true' : 'false');
button.classList.toggle('shaka-chosen-item', isChosen);
}
// Overflow-menu badge / tooltip.
this.currentSelection.textContent = rate + 'x';
this.button.setAttribute('shaka-status', rate + 'x');
if (this.playbackRateMark_) {
this.playbackRateMark_.textContent = rate + 'x';
}
this.updateColors_();
}
/**
* Formats a preset rate for display inside a pill button.
* Rules
* - Comma as decimal separator.
* - Always at least one decimal place (e.g. 1 to "1,0", 3 to "3,0").
*
* @param {number} rate
* @return {string}
* @private
*/
formatPresetLabel_(rate) {
// Determine how many decimal places the value naturally has.
const str = rate.toString(); // e.g. "1", "1.25", "0.5"
const dotIndex = str.indexOf('.');
const decimals = dotIndex === -1 ? 0 : str.length - dotIndex - 1;
// Show at least one decimal place.
return rate.toFixed(Math.max(1, decimals)).replace('.', ',');
}
/** @private */
updateColors_() {
const colors = this.config_.playbackRateBarColors;
const value = this.rateSlider_.getValue();
const min = this.rateSlider_.getMin();
const max = this.rateSlider_.getMax();
// Convert current value to percentage within slider range.
const percent = ((value - min) / (max - min)) * 100;
const gradient = ['to right'];
gradient.push(colors.level + '0%');
gradient.push(colors.level + percent + '%');
gradient.push(colors.base + percent + '%');
gradient.push(colors.base + '100%');
this.rateSlider_.setBackground(
'linear-gradient(' + gradient.join(',') + ')');
}
};
/**
* Step size used for slider interaction and the +/- buttons.
* @const {number}
*/
shaka.ui.PlaybackRateSelection.SLIDER_STEP = 0.05;
/**
* @implements {shaka.extern.IUIElement.Factory}
* @final
*/
shaka.ui.PlaybackRateSelection.Factory = class {
/** @override */
create(rootElement, controls) {
return new shaka.ui.PlaybackRateSelection(rootElement, controls);
}
};
shaka.ui.OverflowMenu.registerElement(
'playback_rate', new shaka.ui.PlaybackRateSelection.Factory());
shaka.ui.Controls.registerElement(
'playback_rate', new shaka.ui.PlaybackRateSelection.Factory());