UNPKG

shaka-player

Version:
351 lines (292 loc) 11.2 kB
/*! @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.ui.Utils'); 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); shaka.ui.Utils.setChosenItem(button, button, 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());