Source: ui/seek_bar.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */


goog.provide('shaka.ui.SeekBar');

goog.require('shaka.ads.Utils');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.ui.Constants');
goog.require('shaka.ui.Locales');
goog.require('shaka.ui.Localization');
goog.require('shaka.ui.RangeElement');
goog.require('shaka.ui.Utils');
goog.require('shaka.util.Dom');
goog.require('shaka.util.Error');
goog.require('shaka.util.Mp4Parser');
goog.require('shaka.util.Networking');
goog.require('shaka.util.Timer');
goog.requireType('shaka.ui.Controls');


/**
 * @extends {shaka.ui.RangeElement}
 * @implements {shaka.extern.IUISeekBar}
 * @final
 * @export
 */
shaka.ui.SeekBar = class extends shaka.ui.RangeElement {
  /**
   * @param {!HTMLElement} parent
   * @param {!shaka.ui.Controls} controls
   */
  constructor(parent, controls) {
    super(parent, controls,
        [
          'shaka-seek-bar-container',
        ],
        [
          'shaka-seek-bar',
          'shaka-no-propagation',
          'shaka-show-controls-on-mouse-over',
        ]);

    /** @private {!HTMLElement} */
    this.adMarkerContainer_ = shaka.util.Dom.createHTMLElement('div');
    this.adMarkerContainer_.classList.add('shaka-ad-markers');
    // Insert the ad markers container as a first child for proper
    // positioning.
    this.container.insertBefore(
        this.adMarkerContainer_, this.container.childNodes[0]);


    /** @private {!shaka.extern.UIConfiguration} */
    this.config_ = this.controls.getConfig();

    /**
     * This timer is used to introduce a delay between the user scrubbing across
     * the seek bar and the seek being sent to the player.
     *
     * @private {shaka.util.Timer}
     */
    this.seekTimer_ = new shaka.util.Timer(() => {
      let newCurrentTime = this.getValue();
      if (!this.player.isLive()) {
        if (newCurrentTime == this.video.duration) {
          newCurrentTime -= 0.001;
        }
      }
      this.video.currentTime = newCurrentTime;
    });


    /**
     * The timer is activated for live content and checks if
     * new ad breaks need to be marked in the current seek range.
     *
     * @private {shaka.util.Timer}
     */
    this.adBreaksTimer_ = new shaka.util.Timer(() => {
      this.markAdBreaks_();
    });


    /**
     * When user is scrubbing the seek bar - we should pause the video - see
     * https://github.com/google/shaka-player/pull/2898#issuecomment-705229215
     * but will conditionally pause or play the video after scrubbing
     * depending on its previous state
     *
     * @private {boolean}
     */
    this.wasPlaying_ = false;


    /** @private {!HTMLElement} */
    this.thumbnailContainer_ = shaka.util.Dom.createHTMLElement('div');
    this.thumbnailContainer_.id = 'shaka-player-ui-thumbnail-container';

    /** @private {!HTMLImageElement} */
    this.thumbnailImage_ = /** @type {!HTMLImageElement} */ (
      shaka.util.Dom.createHTMLElement('img'));
    this.thumbnailImage_.id = 'shaka-player-ui-thumbnail-image';
    this.thumbnailImage_.draggable = false;

    /** @private {!HTMLElement} */
    this.thumbnailTime_ = shaka.util.Dom.createHTMLElement('div');
    this.thumbnailTime_.id = 'shaka-player-ui-thumbnail-time';

    this.thumbnailContainer_.appendChild(this.thumbnailImage_);
    this.thumbnailContainer_.appendChild(this.thumbnailTime_);
    this.container.appendChild(this.thumbnailContainer_);

    this.timeContainer_ = shaka.util.Dom.createHTMLElement('div');
    this.timeContainer_.id = 'shaka-player-ui-time-container';
    this.container.appendChild(this.timeContainer_);

    /**
     * @private {?shaka.extern.Thumbnail}
     */
    this.lastThumbnail_ = null;

    /**
     * @private {?shaka.net.NetworkingEngine.PendingRequest}
     */
    this.lastThumbnailPendingRequest_ = null;

    /**
     * True if the bar is moving due to touchscreen or keyboard events.
     *
     * @private {boolean}
     */
    this.isMoving_ = false;

    /**
     * The timer is activated to hide the thumbnail.
     *
     * @private {shaka.util.Timer}
     */
    this.hideThumbnailTimer_ = new shaka.util.Timer(() => {
      this.hideThumbnail_();
    });

    /** @private {!Array.<!shaka.extern.AdCuePoint>} */
    this.adCuePoints_ = [];

    this.eventManager.listen(this.localization,
        shaka.ui.Localization.LOCALE_UPDATED,
        () => this.updateAriaLabel_());

    this.eventManager.listen(this.localization,
        shaka.ui.Localization.LOCALE_CHANGED,
        () => this.updateAriaLabel_());

    this.eventManager.listen(
        this.adManager, shaka.ads.Utils.AD_STARTED, () => {
          if (!this.shouldBeDisplayed_()) {
            shaka.ui.Utils.setDisplay(this.container, false);
          }
        });

    this.eventManager.listen(
        this.adManager, shaka.ads.Utils.AD_STOPPED, () => {
          if (this.shouldBeDisplayed_()) {
            shaka.ui.Utils.setDisplay(this.container, true);
          }
        });

    this.eventManager.listen(
        this.adManager, shaka.ads.Utils.CUEPOINTS_CHANGED, (e) => {
          this.adCuePoints_ = (e)['cuepoints'];
          this.onAdCuePointsChanged_();
        });

    this.eventManager.listen(
        this.player, 'unloading', () => {
          this.adCuePoints_ = [];
          this.onAdCuePointsChanged_();
          if (this.lastThumbnailPendingRequest_) {
            this.lastThumbnailPendingRequest_.abort();
            this.lastThumbnailPendingRequest_ = null;
          }
          this.lastThumbnail_ = null;
          this.hideThumbnail_();
          this.hideTime_();
        });

    this.eventManager.listen(this.bar, 'mousemove', (event) => {
      const rect = this.bar.getBoundingClientRect();
      const min = parseFloat(this.bar.min);
      const max = parseFloat(this.bar.max);
      // Pixels from the left of the range element
      const mousePosition = event.clientX - rect.left;
      // Pixels per unit value of the range element.
      const scale = (max - min) / rect.width;
      // Mouse position in units, which may be outside the allowed range.
      const value = Math.round(min + scale * mousePosition);
      if (!this.player.getImageTracks().length) {
        this.hideThumbnail_();
        this.showTime_(mousePosition, value);
        return;
      }
      this.hideTime_();
      this.showThumbnail_(mousePosition, value);
    });

    this.eventManager.listen(this.container, 'mouseleave', () => {
      this.hideTime_();
      this.hideThumbnailTimer_.stop();
      this.hideThumbnailTimer_.tickAfter(/* seconds= */ 0.25);
    });

    // Initialize seek state and label.
    this.setValue(this.video.currentTime);
    this.update();
    this.updateAriaLabel_();

    if (this.ad) {
      // There was already an ad.
      shaka.ui.Utils.setDisplay(this.container, false);
    }
  }

  /** @override */
  release() {
    if (this.seekTimer_) {
      this.seekTimer_.stop();
      this.seekTimer_ = null;
      this.adBreaksTimer_.stop();
      this.adBreaksTimer_ = null;
    }

    super.release();
  }

  /**
   * Called by the base class when user interaction with the input element
   * begins.
   *
   * @override
   */
  onChangeStart() {
    this.wasPlaying_ = !this.video.paused;
    this.controls.setSeeking(true);
    this.video.pause();
    this.hideThumbnailTimer_.stop();
    this.isMoving_ = true;
  }

  /**
   * Update the video element's state to match the input element's state.
   * Called by the base class when the input element changes.
   *
   * @override
   */
  onChange() {
    if (!this.video.duration) {
      // Can't seek yet.  Ignore.
      return;
    }

    // Update the UI right away.
    this.update();

    // We want to wait until the user has stopped moving the seek bar for a
    // little bit to reduce the number of times we ask the player to seek.
    //
    // To do this, we will start a timer that will fire in a little bit, but if
    // we see another seek bar change, we will cancel that timer and re-start
    // it.
    //
    // Calling |start| on an already pending timer will cancel the old request
    // and start the new one.
    this.seekTimer_.tickAfter(/* seconds= */ 0.125);

    if (this.player.getImageTracks().length) {
      const min = parseFloat(this.bar.min);
      const max = parseFloat(this.bar.max);
      const rect = this.bar.getBoundingClientRect();
      const value = Math.round(this.getValue());
      const scale = (max - min) / rect.width;
      const position = (value - min) / scale;
      this.showThumbnail_(position, value);
    } else {
      this.hideThumbnail_();
    }
  }

  /**
   * Called by the base class when user interaction with the input element
   * ends.
   *
   * @override
   */
  onChangeEnd() {
    // They just let go of the seek bar, so cancel the timer and manually
    // call the event so that we can respond immediately.
    this.seekTimer_.tickNow();
    this.controls.setSeeking(false);

    if (this.wasPlaying_) {
      this.video.play();
    }

    if (this.isMoving_) {
      this.isMoving_ = false;
      this.hideThumbnailTimer_.stop();
      this.hideThumbnailTimer_.tickAfter(/* seconds= */ 0.25);
    }
  }

  /**
   * @override
  */
  isShowing() {
    // It is showing by default, so it is hidden if shaka-hidden is in the list.
    return !this.container.classList.contains('shaka-hidden');
  }

  /**
   * @override
   */
  update() {
    const colors = this.config_.seekBarColors;
    const currentTime = this.getValue();
    const bufferedLength = this.video.buffered.length;
    const bufferedStart = bufferedLength ? this.video.buffered.start(0) : 0;
    const bufferedEnd =
        bufferedLength ? this.video.buffered.end(bufferedLength - 1) : 0;

    const seekRange = this.player.seekRange();
    const seekRangeSize = seekRange.end - seekRange.start;

    this.setRange(seekRange.start, seekRange.end);

    if (!this.shouldBeDisplayed_()) {
      shaka.ui.Utils.setDisplay(this.container, false);
    } else {
      shaka.ui.Utils.setDisplay(this.container, true);

      if (bufferedLength == 0) {
        this.container.style.background = colors.base;
      } else {
        const clampedBufferStart = Math.max(bufferedStart, seekRange.start);
        const clampedBufferEnd = Math.min(bufferedEnd, seekRange.end);
        const clampedCurrentTime = Math.min(
            Math.max(currentTime, seekRange.start),
            seekRange.end);

        const bufferStartDistance = clampedBufferStart - seekRange.start;
        const bufferEndDistance = clampedBufferEnd - seekRange.start;
        const playheadDistance = clampedCurrentTime - seekRange.start;

        // NOTE: the fallback to zero eliminates NaN.
        const bufferStartFraction = (bufferStartDistance / seekRangeSize) || 0;
        const bufferEndFraction = (bufferEndDistance / seekRangeSize) || 0;
        const playheadFraction = (playheadDistance / seekRangeSize) || 0;

        const unbufferedColor =
            this.config_.showUnbufferedStart ? colors.base : colors.played;

        const gradient = [
          'to right',
          this.makeColor_(unbufferedColor, bufferStartFraction),
          this.makeColor_(colors.played, bufferStartFraction),
          this.makeColor_(colors.played, playheadFraction),
          this.makeColor_(colors.buffered, playheadFraction),
          this.makeColor_(colors.buffered, bufferEndFraction),
          this.makeColor_(colors.base, bufferEndFraction),
        ];
        this.container.style.background =
            'linear-gradient(' + gradient.join(',') + ')';
      }
    }
  }

  /**
   * @private
   */
  markAdBreaks_() {
    if (!this.adCuePoints_.length) {
      this.adMarkerContainer_.style.background = 'transparent';
      this.adBreaksTimer_.stop();
      return;
    }

    const seekRange = this.player.seekRange();
    const seekRangeSize = seekRange.end - seekRange.start;
    const gradient = ['to right'];
    let pointsAsFractions = [];
    const adBreakColor = this.config_.seekBarColors.adBreaks;
    let postRollAd = false;
    for (const point of this.adCuePoints_) {
      // Post-roll ads are marked as starting at -1 in CS IMA ads.
      if (point.start == -1 && !point.end) {
        postRollAd = true;
        continue;
      }
      // Filter point within the seek range. For points with no endpoint
      // (client side ads) check that the start point is within range.
      if ((!point.end && point.start >= seekRange.start) ||
          (typeof point.end == 'number' && point.end > seekRange.start)) {
        const startDist =
            Math.max(point.start, seekRange.start) - seekRange.start;
        const startFrac = (startDist / seekRangeSize) || 0;
        // For points with no endpoint assume a 1% length: not too much,
        // but enough to be visible on the timeline.
        let endFrac = startFrac + 0.01;
        if (point.end) {
          const endDist = point.end - seekRange.start;
          endFrac = (endDist / seekRangeSize) || 0;
        }

        pointsAsFractions.push({
          start: startFrac,
          end: endFrac,
        });
      }
    }

    pointsAsFractions = pointsAsFractions.sort((a, b) => {
      return a.start - b.start;
    });

    for (const point of pointsAsFractions) {
      gradient.push(this.makeColor_('transparent', point.start));
      gradient.push(this.makeColor_(adBreakColor, point.start));
      gradient.push(this.makeColor_(adBreakColor, point.end));
      gradient.push(this.makeColor_('transparent', point.end));
    }

    if (postRollAd) {
      gradient.push(this.makeColor_('transparent', 0.99));
      gradient.push(this.makeColor_(adBreakColor, 0.99));
    }
    this.adMarkerContainer_.style.background =
            'linear-gradient(' + gradient.join(',') + ')';
  }


  /**
   * @param {string} color
   * @param {number} fract
   * @return {string}
   * @private
   */
  makeColor_(color, fract) {
    return color + ' ' + (fract * 100) + '%';
  }


  /**
   * @private
   */
  onAdCuePointsChanged_() {
    const action = () => {
      this.markAdBreaks_();
      const seekRange = this.player.seekRange();
      const seekRangeSize = seekRange.end - seekRange.start;
      const minSeekBarWindow =
          shaka.ui.Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR;
      // Seek range keeps changing for live content and some of the known
      // ad breaks might not be in the seek range now, but get into
      // it later.
      // If we have a LIVE seekable content, keep checking for ad breaks
      // every second.
      if (this.player.isLive() && seekRangeSize > minSeekBarWindow) {
        this.adBreaksTimer_.tickEvery(/* seconds= */ 0.25);
      }
    };
    if (this.player.isFullyLoaded()) {
      action();
    } else {
      this.eventManager.listenOnce(this.player, 'loaded', action);
    }
  }


  /**
   * @return {boolean}
   * @private
   */
  shouldBeDisplayed_() {
    // The seek bar should be hidden when the seek window's too small or
    // there's an ad playing.
    const seekRange = this.player.seekRange();
    const seekRangeSize = seekRange.end - seekRange.start;

    if (this.player.isLive() &&
        (seekRangeSize < shaka.ui.Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR ||
        !isFinite(seekRangeSize))) {
      return false;
    }

    return this.ad == null || !this.ad.isLinear();
  }

  /** @private */
  updateAriaLabel_() {
    this.bar.ariaLabel = this.localization.resolve(shaka.ui.Locales.Ids.SEEK);
  }

  /** @private */
  showTime_(pixelPosition, value) {
    const offsetTop = -10;
    const width = this.timeContainer_.clientWidth;
    const height = 20;
    this.timeContainer_.style.width = 'auto';
    this.timeContainer_.style.height = height + 'px';
    this.timeContainer_.style.top = -(height - offsetTop) + 'px';
    const leftPosition = Math.min(this.bar.offsetWidth - width,
        Math.max(0, pixelPosition - (width / 2)));
    this.timeContainer_.style.left = leftPosition + 'px';
    this.timeContainer_.style.visibility = 'visible';
    if (this.player.isLive()) {
      const seekRange = this.player.seekRange();
      const totalSeconds = seekRange.end - value;
      if (totalSeconds < 1) {
        this.timeContainer_.textContent =
            this.localization.resolve(shaka.ui.Locales.Ids.LIVE);
      } else {
        this.timeContainer_.textContent =
            '-' + this.timeFormatter_(totalSeconds);
      }
    } else {
      this.timeContainer_.textContent = this.timeFormatter_(value);
    }
  }


  /**
   * @private
   */
  async showThumbnail_(pixelPosition, value) {
    const thumbnailTrack = this.getThumbnailTrack_();
    if (!thumbnailTrack) {
      this.hideThumbnail_();
      return;
    }
    if (value < 0) {
      value = 0;
    }
    const seekRange = this.player.seekRange();
    const playerValue = Math.max(Math.ceil(seekRange.start),
        Math.min(Math.floor(seekRange.end), value));
    const thumbnail =
        await this.player.getThumbnails(thumbnailTrack.id, playerValue);
    if (!thumbnail || !thumbnail.uris.length) {
      this.hideThumbnail_();
      return;
    }
    if (this.player.isLive()) {
      const totalSeconds = seekRange.end - value;
      if (totalSeconds < 1) {
        this.thumbnailTime_.textContent =
            this.localization.resolve(shaka.ui.Locales.Ids.LIVE);
      } else {
        this.thumbnailTime_.textContent =
            '-' + this.timeFormatter_(totalSeconds);
      }
    } else {
      this.thumbnailTime_.textContent = this.timeFormatter_(value);
    }
    const offsetTop = -10;
    const width = this.thumbnailContainer_.clientWidth;
    let height = Math.floor(width * 9 / 16);
    this.thumbnailContainer_.style.height = height + 'px';
    this.thumbnailContainer_.style.top = -(height - offsetTop) + 'px';
    const leftPosition = Math.min(this.bar.offsetWidth - width,
        Math.max(0, pixelPosition - (width / 2)));
    this.thumbnailContainer_.style.left = leftPosition + 'px';
    this.thumbnailContainer_.style.visibility = 'visible';
    let uri = thumbnail.uris[0].split('#xywh=')[0];
    if (!this.lastThumbnail_ ||
        uri !== this.lastThumbnail_.uris[0].split('#xywh=')[0] ||
        thumbnail.segment.getStartByte() !=
            this.lastThumbnail_.segment.getStartByte() ||
        thumbnail.segment.getEndByte() !=
            this.lastThumbnail_.segment.getEndByte()) {
      this.lastThumbnail_ = thumbnail;
      if (this.lastThumbnailPendingRequest_) {
        this.lastThumbnailPendingRequest_.abort();
        this.lastThumbnailPendingRequest_ = null;
      }
      if (thumbnailTrack.codecs == 'mjpg' || uri.startsWith('offline:')) {
        this.thumbnailImage_.src = shaka.ui.SeekBar.Transparent_Image_;
        try {
          const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
          const type =
              shaka.net.NetworkingEngine.AdvancedRequestType.MEDIA_SEGMENT;
          const request = shaka.util.Networking.createSegmentRequest(
              thumbnail.segment.getUris(),
              thumbnail.segment.getStartByte(),
              thumbnail.segment.getEndByte(),
              this.player.getConfiguration().streaming.retryParameters);
          this.lastThumbnailPendingRequest_ = this.player.getNetworkingEngine()
              .request(requestType, request, {type});
          const response = await this.lastThumbnailPendingRequest_.promise;
          this.lastThumbnailPendingRequest_ = null;
          if (thumbnailTrack.codecs == 'mjpg') {
            const parser = new shaka.util.Mp4Parser()
                .box('mdat', shaka.util.Mp4Parser.allData((data) => {
                  const blob = new Blob([data], {type: 'image/jpeg'});
                  uri = URL.createObjectURL(blob);
                }));
            parser.parse(response.data, /* partialOkay= */ false);
          } else {
            const mimeType = thumbnailTrack.mimeType || 'image/jpeg';
            const blob = new Blob([response.data], {type: mimeType});
            uri = URL.createObjectURL(blob);
          }
        } catch (error) {
          if (error.code == shaka.util.Error.Code.OPERATION_ABORTED) {
            return;
          }
          throw error;
        }
      }
      try {
        this.thumbnailContainer_.removeChild(this.thumbnailImage_);
      } catch (e) {
        // The image is not a child
      }
      this.thumbnailImage_ = /** @type {!HTMLImageElement} */ (
        shaka.util.Dom.createHTMLElement('img'));
      this.thumbnailImage_.id = 'shaka-player-ui-thumbnail-image';
      this.thumbnailImage_.draggable = false;
      this.thumbnailImage_.src = uri;
      this.thumbnailImage_.onload = () => {
        if (uri.startsWith('blob:')) {
          URL.revokeObjectURL(uri);
        }
      };
      this.thumbnailContainer_.insertBefore(this.thumbnailImage_,
          this.thumbnailContainer_.firstChild);
    }
    const scale = width / thumbnail.width;
    if (thumbnail.imageHeight) {
      this.thumbnailImage_.height = thumbnail.imageHeight;
    } else if (!thumbnail.sprite) {
      this.thumbnailImage_.style.height = '100%';
      this.thumbnailImage_.style.objectFit = 'contain';
    }
    if (thumbnail.imageWidth) {
      this.thumbnailImage_.width = thumbnail.imageWidth;
    } else if (!thumbnail.sprite) {
      this.thumbnailImage_.style.width = '100%';
      this.thumbnailImage_.style.objectFit = 'contain';
    }
    this.thumbnailImage_.style.left = '-' + scale * thumbnail.positionX + 'px';
    this.thumbnailImage_.style.top = '-' + scale * thumbnail.positionY + 'px';
    this.thumbnailImage_.style.transform = 'scale(' + scale + ')';
    this.thumbnailImage_.style.transformOrigin = 'left top';
    // Update container height and top
    height = Math.floor(width * thumbnail.height / thumbnail.width);
    this.thumbnailContainer_.style.height = height + 'px';
    this.thumbnailContainer_.style.top = -(height - offsetTop) + 'px';
  }


  /**
   * @return {?shaka.extern.Track} The thumbnail track.
   * @private
   */
  getThumbnailTrack_() {
    const imageTracks = this.player.getImageTracks();
    if (!imageTracks.length) {
      return null;
    }
    const mimeTypesPreference = [
      'image/avif',
      'image/webp',
      'image/jpeg',
      'image/png',
      'image/svg+xml',
    ];
    for (const mimeType of mimeTypesPreference) {
      const estimatedBandwidth = this.player.getStats().estimatedBandwidth;
      const bestOptions = imageTracks.filter((track) => {
        return track.mimeType.toLowerCase() === mimeType &&
            track.bandwidth < estimatedBandwidth * 0.01;
      }).sort((a, b) => {
        return b.bandwidth - a.bandwidth;
      });
      if (bestOptions && bestOptions.length) {
        return bestOptions[0];
      }
    }
    const mjpgTrack = imageTracks.find((track) => {
      return track.mimeType == 'application/mp4' && track.codecs == 'mjpg';
    });
    return mjpgTrack || imageTracks[0];
  }


  /**
   * @private
   */
  hideThumbnail_() {
    this.thumbnailContainer_.style.visibility = 'hidden';
    this.thumbnailTime_.textContent = '';
  }


  /**
   * @private
   */
  hideTime_() {
    this.timeContainer_.style.visibility = 'hidden';
  }


  /**
   * @param {number} totalSeconds
   * @private
   */
  timeFormatter_(totalSeconds) {
    const secondsNumber = Math.round(totalSeconds);
    const hours = Math.floor(secondsNumber / 3600);
    let minutes = Math.floor((secondsNumber - (hours * 3600)) / 60);
    let seconds = secondsNumber - (hours * 3600) - (minutes * 60);
    if (seconds < 10) {
      seconds = '0' + seconds;
    }
    if (hours > 0) {
      if (minutes < 10) {
        minutes = '0' + minutes;
      }
      return hours + ':' + minutes + ':' + seconds;
    } else {
      return minutes + ':' + seconds;
    }
  }
};


/**
 * @const {string}
 * @private
 */
shaka.ui.SeekBar.Transparent_Image_ =
    'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>';


/**
 * @implements {shaka.extern.IUISeekBar.Factory}
 * @export
 */

shaka.ui.SeekBar.Factory = class {
  /**
   * Creates a shaka.ui.SeekBar. Use this factory to register the default
   * SeekBar when needed
   *
   * @override
   */
  create(rootElement, controls) {
    return new shaka.ui.SeekBar(rootElement, controls);
  }
};