mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-13 15:46:46 +03:00
351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
/*! @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());
|