Source: ui/media_session.js

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

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

goog.require('shaka.log');
goog.require('shaka.ads.Utils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.TXml');

goog.requireType('shaka.Player');
goog.requireType('shaka.ui.Controls');

/**
 * @export
 * @implements {shaka.util.IReleasable}
 */
shaka.ui.MediaSession = class {
  /**
   * @param {!shaka.ui.Controls} controls
   */
  constructor(controls) {
    /** @private {!shaka.ui.Controls} */
    this.controls_ = controls;

    /** @private {shaka.Player} */
    this.player_ = this.controls_.getPlayer();

    /** @private {HTMLMediaElement} */
    this.video_ = this.controls_.getVideo();

    /** @private {shaka.extern.IAdManager} */
    this.adManager_ = this.controls_.getAdManager();

    /** @private {shaka.extern.IQueueManager} */
    this.queueManager_ = this.controls_.getQueueManager();

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

    /** @private {shaka.util.EventManager} */
    this.loadEventManager_ = new shaka.util.EventManager();

    /** @private {shaka.util.EventManager} */
    this.eventManager_ = new shaka.util.EventManager();

    /** @private {boolean} */
    this.supported_ = !!navigator.mediaSession;

    /** @private {boolean} */
    this.enabled_ = false;

    /** @private {boolean} */
    this.supportsChapterInfo_ =
        // eslint-disable-next-line no-restricted-syntax
        this.supported_ && 'chapterInfo' in MediaMetadata.prototype;

    /** @private {!Set<string>} */
    this.actionsHandled_ = new Set();

    if (this.player_.isFullyLoaded()) {
      this.init_();
    }
    this.loadEventManager_.listen(this.player_, 'loading', () => {
      this.init_();
    });
    this.loadEventManager_.listen(this.player_, 'unloading', () => {
      this.stop_();
    });
  }

  /**
   * @param {!shaka.extern.UIConfiguration} config
   * @export
   */
  configure(config) {
    this.stop_();
    this.config_ = config;
    this.init_();
  }

  /**
   * @override
   * @export
   */
  release() {
    if (this.loadEventManager_) {
      this.loadEventManager_.release();
      this.loadEventManager_ = null;
    }
    if (this.eventManager_) {
      this.eventManager_.release();
      this.eventManager_ = null;
    }
    this.stop_();
  }

  /**
   * @private
   */
  stop_() {
    if (!this.enabled_) {
      return;
    }
    this.enabled_ = false;
    if (this.eventManager_) {
      this.eventManager_.removeAll();
    }
    if (this.config_.mediaSession.handleMetadata) {
      navigator.mediaSession.metadata = new MediaMetadata({});
    }
    if (this.config_.mediaSession.handlePosition) {
      this.clearPositionState_();
    }
    for (const actionName of Array.from(this.actionsHandled_)) {
      this.addMediaSessionHandler(actionName);
    }
    this.actionsHandled_.clear();
  }

  /**
   * @private
   */
  init_() {
    if (!this.supported_ || this.enabled_ ||
        !this.config_.mediaSession.enabled) {
      return;
    }
    this.enabled_ = true;

    this.setupMediaSessionActions_();
    this.setupMediaSessionMetadata_();
    this.setupMediaSessionPosition_();
  }

  /**
   * @return {!{title: string, artist: string, album: string,
   *         artwork: Object, chapterInfo: ?Object}}
   */
  getMediaMetadata() {
    const metadata = {
      title: '',
      artist: '',
      album: '',
      artwork: [],
    };
    if (this.supportsChapterInfo_) {
      metadata.chapterInfo = [];
    }
    if (this.supported_ && navigator.mediaSession.metadata) {
      metadata.title = navigator.mediaSession.metadata.title;
      metadata.artist = navigator.mediaSession.metadata.artist;
      metadata.album = navigator.mediaSession.metadata.album;
      metadata.artwork = navigator.mediaSession.metadata.artwork;
      if (this.supportsChapterInfo_) {
        metadata.chapterInfo = navigator.mediaSession.metadata.chapterInfo;
      }
    }
    return metadata;
  }

  /**
   * @param {string} title
   * @export
   */
  setupTitle(title) {
    const castReceiver = this.controls_.getCastReceiver();
    if (castReceiver) {
      castReceiver.setContentTitle(title);
    }
    if (this.supported_) {
      const metadata = this.getMediaMetadata();
      metadata.title = title;
      navigator.mediaSession.metadata = new MediaMetadata(metadata);
    }
  }

  /**
   * @param {string} imageUrl
   * @export
   */
  setupPoster(imageUrl) {
    const video = /** @type {HTMLVideoElement} */ (this.video_);
    if (imageUrl != video.poster) {
      video.poster = imageUrl;
    }
    const castReceiver = this.controls_.getCastReceiver();
    if (castReceiver) {
      castReceiver.setContentImage(imageUrl);
    }
    if (this.supported_) {
      const metadata = this.getMediaMetadata();
      metadata.artwork = [{src: imageUrl}];
      navigator.mediaSession.metadata = new MediaMetadata(metadata);
    }
  }

  /**
   * @param {!Array<!shaka.extern.Chapter>} chapters
   * @export
   */
  setupChapters(chapters) {
    if (!this.supportsChapterInfo_) {
      return;
    }
    const chapterInfo = [];
    for (const chapter of chapters) {
      chapterInfo.push({
        title: chapter.title,
        startTime: chapter.startTime,
        artwork: [],
      });
    }
    const metadata = this.getMediaMetadata();
    metadata.chapterInfo = chapterInfo;
    navigator.mediaSession.metadata = new MediaMetadata(metadata);
  }

  /**
   * @param {string} type
   * @param {?Function=} callback
   * @export
   */
  addMediaSessionHandler(type, callback = null) {
    if (!this.supported_) {
      return;
    }
    try {
      if (callback) {
        if (!this.config_.mediaSession.supportedActions.includes(type)) {
          return;
        }
        this.actionsHandled_.add(type);
      } else {
        if (!this.actionsHandled_.has(type)) {
          return;
        }
        this.actionsHandled_.delete(type);
      }
      navigator.mediaSession.setActionHandler(type, callback);
    } catch (error) {
      shaka.log.debug(
          `The "${type}" media session action is not supported.`);
    }
  }

  /**
   * @param {!{action: string, seekOffset: ?number,
   *         seekTime: ?number}} details
   * @export
   */
  commonActionHandler(details) {
    const ad = this.controls_.getAd();
    const keyboardSeekDistance = this.config_.keyboardSeekDistance;
    switch (details.action) {
      case 'pause':
        this.controls_.playPausePresentation();
        break;
      case 'play':
        this.controls_.playPausePresentation();
        break;
      case 'seekbackward':
        if (details.seekOffset && !isFinite(details.seekOffset)) {
          break;
        }
        if (!ad || !ad.isLinear()) {
          this.controls_.seekIncrement(
              -(details.seekOffset || keyboardSeekDistance));
        }
        break;
      case 'seekforward':
        if (details.seekOffset && !isFinite(details.seekOffset)) {
          break;
        }
        if (!ad || !ad.isLinear()) {
          this.controls_.seekIncrement(
              details.seekOffset || keyboardSeekDistance);
        }
        break;
      case 'seekto':
        if (details.seekTime && !isFinite(details.seekTime)) {
          break;
        }
        if (!ad || !ad.isLinear()) {
          this.controls_.seekTo(
              this.player_.seekRange().start + details.seekTime);
        }
        break;
      case 'stop':
        this.player_.unload();
        break;
      case 'enterpictureinpicture':
        if (!ad || !ad.isLinear()) {
          this.controls_.togglePiP();
        }
        break;
      case 'nexttrack':
        this.queueManager_.playItem(
            this.queueManager_.getCurrentItemIndex() + 1);
        break;
      case 'previoustrack':
        this.queueManager_.playItem(
            this.queueManager_.getCurrentItemIndex() - 1);
        break;
      case 'skipad':
        if (ad) {
          ad.skip();
        }
        break;
    }
  };

  /**
   * @private
   */
  clearPositionState_() {
    try {
      navigator.mediaSession.setPositionState();
    } catch (error) {
      shaka.log.v2(
          'setPositionState in media session is not supported.');
    }
  }

  /**
   * @private
   */
  setupMediaSessionMetadata_() {
    if (!this.config_.mediaSession.handleMetadata) {
      return;
    }
    this.eventManager_.listen(this.player_, 'trackschanged', () => {
      this.setupChapters(this.controls_.getChapters());
    });
    this.eventManager_.listen(this.player_, 'metadata', (event) => {
      const payload = event['payload'];
      if (!payload) {
        return;
      }
      let title;
      if (payload['key'] == 'TIT2' && payload['data']) {
        title = payload['data'];
      }
      let imageUrl;
      if (payload['key'] == 'APIC' && payload['mimeType'] == '-->') {
        imageUrl = payload['data'];
      }
      if (title) {
        this.setupTitle(title);
      }
      if (imageUrl) {
        this.setupPoster(imageUrl);
      }
    });
    this.eventManager_.listen(this.player_, 'sessiondata', (event) => {
      const id = event['id'];
      switch (id) {
        case 'com.apple.hls.title': {
          const title = event['value'];
          if (title) {
            this.setupTitle(title);
          }
          break;
        }
        case 'com.apple.hls.poster': {
          let imageUrl = event['value'];
          if (imageUrl) {
            imageUrl = imageUrl.replace('{w}', '512')
                .replace('{h}', '512')
                .replace('{f}', 'jpeg');
            this.setupPoster(imageUrl);
          }
          break;
        }
      }
    });
    this.eventManager_.listen(this.player_, 'programinformation', (event) => {
      if (!event['detail']) {
        return;
      }
      const TXml = shaka.util.TXml;
      /** @type {!shaka.extern.xml.Node} */
      const detail =
      /** @type {!shaka.extern.xml.Node} */(event['detail']);
      const titleNode = TXml.findChild(detail, 'Title');
      if (titleNode) {
        const title = TXml.getContents(titleNode);
        if (title) {
          this.setupTitle(title);
        }
      }
    });
    this.eventManager_.listen(this.player_, 'unloading', () => {
      navigator.mediaSession.metadata = new MediaMetadata({});
    });
  }

  /**
   * @private
   */
  setupMediaSessionPosition_() {
    if (!this.config_.mediaSession.handlePosition) {
      return;
    }
    const updatePositionState = () => {
      const ad = this.controls_.getAd();
      if (ad && ad.isLinear()) {
        this.clearPositionState_();
        return;
      }
      const seekRange = this.player_.seekRange();
      let duration = seekRange.end - seekRange.start;
      const position = parseFloat(
          (this.video_.currentTime - seekRange.start).toFixed(2));
      if (this.player_.isLive() && Math.abs(duration - position) < 1) {
        // Positive infinity indicates media without a defined end, such as a
        // live stream.
        duration = Infinity;
      }
      try {
        navigator.mediaSession.setPositionState({
          duration: Math.max(0, duration),
          playbackRate: this.video_.playbackRate,
          position: Math.max(0, position),
        });
      } catch (error) {
        shaka.log.v2(
            'setPositionState in media session is not supported.');
      }
    };
    const playerLoaded = () => {
      if (this.player_.isLive() || this.player_.seekRange().start != 0) {
        updatePositionState();
        this.eventManager_.listen(
            this.video_, 'timeupdate', updatePositionState);
      } else {
        this.clearPositionState_();
      }
    };
    if (this.player_.isFullyLoaded()) {
      playerLoaded();
    }
    this.eventManager_.listen(
        this.player_, 'loaded', playerLoaded);
    this.eventManager_.listen(this.player_, 'unloading', () => {
      this.eventManager_.unlisten(
          this.video_, 'timeupdate', updatePositionState);
    });
  }

  /** @private */
  setupMediaSessionActions_() {
    if (!this.config_.mediaSession.handleActions) {
      return;
    }
    const actionHandler = (details) => {
      this.commonActionHandler(details);
    };
    this.addMediaSessionHandler('pause', actionHandler);
    this.addMediaSessionHandler('play', actionHandler);
    this.addMediaSessionHandler('seekbackward', actionHandler);
    this.addMediaSessionHandler('seekforward', actionHandler);
    this.addMediaSessionHandler('seekto', actionHandler);
    this.addMediaSessionHandler('stop', actionHandler);
    this.addMediaSessionHandler('enterpictureinpicture', actionHandler);

    const checkQueueItems = () => {
      const itemsLength = this.queueManager_.getItems().length;
      const currentIndex = this.queueManager_.getCurrentItemIndex();
      if (itemsLength <= 1 || currentIndex == -1) {
        this.addMediaSessionHandler('previoustrack', null);
        this.addMediaSessionHandler('nexttrack', null);
        return;
      }
      if (currentIndex > 0) {
        this.addMediaSessionHandler('previoustrack', actionHandler);
      } else {
        this.addMediaSessionHandler('previoustrack', null);
      }
      if ((currentIndex + 1) < itemsLength) {
        this.addMediaSessionHandler('nexttrack', actionHandler);
      } else {
        this.addMediaSessionHandler('nexttrack', null);
      }
    };

    this.eventManager_.listen(
        this.queueManager_, 'currentitemchanged', checkQueueItems);
    this.eventManager_.listen(
        this.queueManager_, 'itemsinserted', checkQueueItems);
    this.eventManager_.listen(
        this.queueManager_, 'itemsremoved', checkQueueItems);
    this.eventManager_.listen(
        this.player_, 'loading', checkQueueItems);

    checkQueueItems();

    const checkSkipAd = () => {
      const ad = this.controls_.getAd();
      if (!ad || !ad.isSkippable() || !ad.canSkipNow()) {
        this.addMediaSessionHandler('skipad', null);
      } else {
        this.addMediaSessionHandler('skipad', actionHandler);
      }
    };

    this.eventManager_.listen(
        this.adManager_, shaka.ads.Utils.AD_STARTED, checkSkipAd);
    this.eventManager_.listen(
        this.adManager_, shaka.ads.Utils.AD_SKIP_STATE_CHANGED, checkSkipAd);
    this.eventManager_.listen(
        this.adManager_, shaka.ads.Utils.AD_STOPPED, checkSkipAd);

    checkSkipAd();
  }
};