Source: lib/cast/cast_receiver.js

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

goog.provide('shaka.cast.CastReceiver');

goog.require('goog.asserts');
goog.require('shaka.Player');
goog.require('shaka.cast.CastUtils');
goog.require('shaka.log');
goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.Platform');
goog.require('shaka.util.Timer');


/**
 * A receiver to communicate between the Chromecast-hosted player and the
 * sender application.
 *
 * @implements {shaka.util.IDestroyable}
 * @export
 */
shaka.cast.CastReceiver = class extends shaka.util.FakeEventTarget {
  /**
   * @param {!HTMLMediaElement} video The local video element associated with
   *   the local Player instance.
   * @param {!shaka.Player} player A local Player instance.
   * @param {function(Object)=} appDataCallback A callback to handle
   *   application-specific data passed from the sender.  This can come either
   *   from a Shaka-based sender through CastProxy.setAppData, or from a
   *   sender using the customData field of the LOAD message of the standard
   *   Cast message namespace.  It can also be null if no such data is sent.
   * @param {function(string):string=} contentIdCallback A callback to
   *   retrieve manifest URI from the provided content id.
   */
  constructor(video, player, appDataCallback, contentIdCallback) {
    super();

    /** @private {HTMLMediaElement} */
    this.video_ = video;

    /** @private {shaka.Player} */
    this.player_ = player;

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

    /** @private {Object} */
    this.targets_ = {
      'video': video,
      'player': player,
    };

    /** @private {?function(Object)} */
    this.appDataCallback_ = appDataCallback || (() => {});

    /** @private {?function(string):string} */
    this.contentIdCallback_ = contentIdCallback ||
                          /** @param {string} contentId
                              @return {string} */
                          ((contentId) => contentId);

    /**
     * A Cast metadata object, one of:
     *  - https://developers.google.com/cast/docs/reference/messages#GenericMediaMetadata
     *  - https://developers.google.com/cast/docs/reference/messages#MovieMediaMetadata
     *  - https://developers.google.com/cast/docs/reference/messages#TvShowMediaMetadata
     *  - https://developers.google.com/cast/docs/reference/messages#MusicTrackMediaMetadata
     * @private {Object}
     */
    this.metadata_ = null;

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

    /** @private {boolean} */
    this.isIdle_ = true;

    /** @private {number} */
    this.updateNumber_ = 0;

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

    /** @private {boolean} */
    this.initialStatusUpdatePending_ = true;

    /** @private {cast.receiver.CastMessageBus} */
    this.shakaBus_ = null;

    /** @private {cast.receiver.CastMessageBus} */
    this.genericBus_ = null;

    /** @private {shaka.util.Timer} */
    this.pollTimer_ = new shaka.util.Timer(() => {
      this.pollAttributes_();
    });

    this.init_();
  }

  /**
   * @return {boolean} True if the cast API is available and there are
   *   receivers.
   * @export
   */
  isConnected() {
    return this.isConnected_;
  }

  /**
   * @return {boolean} True if the receiver is not currently doing loading or
   *   playing anything.
   * @export
   */
  isIdle() {
    return this.isIdle_;
  }

  /**
   * Set all Cast content metadata, as defined by the Cast SDK.
   * Should be called from an appDataCallback.
   *
   * For a simpler way to set basic metadata, see:
   *  - setContentTitle()
   *  - setContentImage()
   *  - setContentArtist()
   *
   * @param {Object} metadata
   *   A Cast metadata object, one of:
   *    - https://developers.google.com/cast/docs/reference/messages#GenericMediaMetadata
   *    - https://developers.google.com/cast/docs/reference/messages#MovieMediaMetadata
   *    - https://developers.google.com/cast/docs/reference/messages#TvShowMediaMetadata
   *    - https://developers.google.com/cast/docs/reference/messages#MusicTrackMediaMetadata
   * @export
   */
  setContentMetadata(metadata) {
    this.metadata_ = metadata;
  }

  /**
   * Clear all Cast content metadata.
   * Should be called from an appDataCallback.
   *
   * @export
   */
  clearContentMetadata() {
    this.metadata_ = null;
  }

  /**
   * Set the Cast content's title.
   * Should be called from an appDataCallback.
   *
   * @param {string} title
   * @export
   */
  setContentTitle(title) {
    if (!this.metadata_) {
      this.metadata_ = {
        'metadataType': cast.receiver.media.MetadataType.GENERIC,
      };
    }
    this.metadata_['title'] = title;
  }

  /**
   * Set the Cast content's thumbnail image.
   * Should be called from an appDataCallback.
   *
   * @param {string} imageUrl
   * @export
   */
  setContentImage(imageUrl) {
    if (!this.metadata_) {
      this.metadata_ = {
        'metadataType': cast.receiver.media.MetadataType.GENERIC,
      };
    }
    this.metadata_['images'] = [
      {
        'url': imageUrl,
      },
    ];
  }

  /**
   * Set the Cast content's artist.
   * Also sets the metadata type to music.
   * Should be called from an appDataCallback.
   *
   * @param {string} artist
   * @export
   */
  setContentArtist(artist) {
    if (!this.metadata_) {
      this.metadata_ = {};
    }
    this.metadata_['artist'] = artist;
    this.metadata_['metadataType'] =
        cast.receiver.media.MetadataType.MUSIC_TRACK;
  }

  /**
   * Destroys the underlying Player, then terminates the cast receiver app.
   *
   * @override
   * @export
   */
  async destroy() {
    if (this.eventManager_) {
      this.eventManager_.release();
      this.eventManager_ = null;
    }

    const waitFor = [];
    if (this.player_) {
      waitFor.push(this.player_.destroy());
      this.player_ = null;
    }

    if (this.pollTimer_) {
      this.pollTimer_.stop();
      this.pollTimer_ = null;
    }

    this.video_ = null;
    this.targets_ = null;
    this.appDataCallback_ = null;
    this.isConnected_ = false;
    this.isIdle_ = true;
    this.shakaBus_ = null;
    this.genericBus_ = null;

    // FakeEventTarget implements IReleasable
    super.release();

    await Promise.all(waitFor);

    const manager = cast.receiver.CastReceiverManager.getInstance();
    manager.stop();
  }

  /** @private */
  init_() {
    const manager = cast.receiver.CastReceiverManager.getInstance();
    manager.onSenderConnected = () => this.onSendersChanged_();
    manager.onSenderDisconnected = () => this.onSendersChanged_();
    manager.onSystemVolumeChanged = () => this.fakeVolumeChangeEvent_();

    this.genericBus_ = manager.getCastMessageBus(
        shaka.cast.CastUtils.GENERIC_MESSAGE_NAMESPACE);
    this.genericBus_.onMessage = (event) => this.onGenericMessage_(event);

    this.shakaBus_ = manager.getCastMessageBus(
        shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE);
    this.shakaBus_.onMessage = (event) => this.onShakaMessage_(event);

    if (goog.DEBUG) {
      // Sometimes it is useful to load the receiver app in Chrome to work on
      // the UI.  To avoid log spam caused by the SDK trying to connect to web
      // sockets that don't exist, in uncompiled mode we check if the hosting
      // browser is a Chromecast before starting the receiver manager.  We
      // wouldn't do browser detection except for debugging, so only do this in
      // uncompiled mode.
      if (shaka.util.Platform.isChromecast()) {
        manager.start();
      }
    } else {
      manager.start();
    }

    for (const name of shaka.cast.CastUtils.VideoEvents) {
      this.eventManager_.listen(
          this.video_, name, (event) => this.proxyEvent_('video', event));
    }

    for (const key in shaka.Player.EventName) {
      const name = shaka.Player.EventName[key];
      this.eventManager_.listen(
          this.player_, name, (event) => this.proxyEvent_('player', event));
    }

    // In our tests, the original Chromecast seems to have trouble decoding
    // above 1080p.  It would be a waste to select a higher res anyway, given
    // that the device only outputs 1080p to begin with.

    // Chromecast has an extension to query the device/display's resolution.
    if (cast.__platform__ && cast.__platform__.canDisplayType(
        'video/mp4; codecs="avc1.640028"; width=3840; height=2160')) {
      // The device and display can both do 4k.  Assume a 4k limit.
      this.player_.setMaxHardwareResolution(3840, 2160);
    } else {
      // Chromecast has always been able to do 1080p.  Assume a 1080p limit.
      this.player_.setMaxHardwareResolution(1920, 1080);
    }

    // Do not start excluding values from update messages until the video is
    // fully loaded.
    this.eventManager_.listen(this.video_, 'loadeddata', () => {
      this.startUpdatingUpdateNumber_ = true;
    });

    // Maintain idle state.
    this.eventManager_.listen(this.player_, 'loading', () => {
      // No longer idle once loading.  This allows us to show the spinner during
      // the initial buffering phase.
      this.isIdle_ = false;
      this.onCastStatusChanged_();
    });
    this.eventManager_.listen(this.video_, 'playing', () => {
      // No longer idle once playing.  This allows us to replay a video without
      // reloading.
      this.isIdle_ = false;
      this.onCastStatusChanged_();
    });
    this.eventManager_.listen(this.video_, 'pause', () => {
      this.onCastStatusChanged_();
    });
    this.eventManager_.listen(this.player_, 'unloading', () => {
      // Go idle when unloading content.
      this.isIdle_ = true;
      this.onCastStatusChanged_();
    });
    this.eventManager_.listen(this.video_, 'ended', () => {
      // Go idle 5 seconds after 'ended', assuming we haven't started again or
      // been destroyed.
      const timer = new shaka.util.Timer(() => {
        if (this.video_ && this.video_.ended) {
          this.isIdle_ = true;
          this.onCastStatusChanged_();
        }
      });

      timer.tickAfter(shaka.cast.CastReceiver.IDLE_INTERVAL);
    });

    // Do not start polling until after the sender's 'init' message is handled.
  }

  /** @private */
  onSendersChanged_() {
    // Reset update message frequency values, to make sure whomever joined
    // will get a full update message.
    this.updateNumber_ = 0;
    // Don't reset startUpdatingUpdateNumber_, because this operation does not
    // result in new data being loaded.
    this.initialStatusUpdatePending_ = true;

    const manager = cast.receiver.CastReceiverManager.getInstance();
    this.isConnected_ = manager.getSenders().length != 0;
    this.onCastStatusChanged_();
  }

  /**
   * Dispatch an event to notify the receiver app that the status has changed.
   * @private
   */
  async onCastStatusChanged_() {
    // Do this asynchronously so that synchronous changes to idle state (such as
    // Player calling unload() as part of load()) are coalesced before the event
    // goes out.
    await Promise.resolve();
    if (!this.player_) {
      // We've already been destroyed.
      return;
    }

    const event = new shaka.util.FakeEvent('caststatuschanged');
    this.dispatchEvent(event);
    // Send a media status message, with a media info message if appropriate.
    if (!this.maybeSendMediaInfoMessage_()) {
      this.sendMediaStatus_();
    }
  }

  /**
   * Take on initial state from the sender.
   * @param {shaka.cast.CastUtils.InitStateType} initState
   * @param {Object} appData
   * @private
   */
  async initState_(initState, appData) {
    // Take on player state first.
    for (const k in initState['player']) {
      const v = initState['player'][k];
      // All player state vars are setters to be called.
      /** @type {Object} */(this.player_)[k](v);
    }

    // Now process custom app data, which may add additional player configs:
    this.appDataCallback_(appData);

    const autoplay = this.video_.autoplay;

    // Now load the manifest, if present.
    if (initState['manifest']) {
      // Don't autoplay the content until we finish setting up initial state.
      this.video_.autoplay = false;
      try {
        await this.player_.load(initState['manifest'], initState['startTime']);
      } catch (error) {
        // Pass any errors through to the app.
        goog.asserts.assert(error instanceof shaka.util.Error,
            'Wrong error type!');
        const eventType = shaka.Player.EventName.Error;
        const data = (new Map()).set('detail', error);
        const event = new shaka.util.FakeEvent(eventType, data);
        // Only dispatch the event if the player still exists.
        if (this.player_) {
          this.player_.dispatchEvent(event);
        }
        return;
      }
    } else {
      // Ensure the below happens async.
      await Promise.resolve();
    }

    if (!this.player_) {
      // We've already been destroyed.
      return;
    }

    // Finally, take on video state and player's "after load" state.
    for (const k in initState['video']) {
      const v = initState['video'][k];
      this.video_[k] = v;
    }

    for (const k in initState['playerAfterLoad']) {
      const v = initState['playerAfterLoad'][k];
      // All player state vars are setters to be called.
      /** @type {Object} */(this.player_)[k](v);
    }

    // Restore original autoplay setting.
    this.video_.autoplay = autoplay;
    if (initState['manifest']) {
      // Resume playback with transferred state.
      this.video_.play();
      // Notify generic controllers of the state change.
      this.sendMediaStatus_();
    }
  }

  /**
   * @param {string} targetName
   * @param {!Event} event
   * @private
   */
  proxyEvent_(targetName, event) {
    if (!this.player_) {
      // The receiver is destroyed, so it should ignore further events.
      return;
    }

    // Poll and send an update right before we send the event.  Some events
    // indicate an attribute change, so that change should be visible when the
    // event is handled.
    this.pollAttributes_();

    this.sendMessage_({
      'type': 'event',
      'targetName': targetName,
      'event': event,
    }, this.shakaBus_);
  }

  /** @private */
  pollAttributes_() {
    // The poll timer may have been pre-empted by an event (e.g. timeupdate).
    // Calling |start| will cancel any pending calls and therefore will avoid us
    // polling too often.
    this.pollTimer_.tickAfter(shaka.cast.CastReceiver.POLL_INTERVAL);

    const update = {
      'video': {},
      'player': {},
    };

    for (const name of shaka.cast.CastUtils.VideoAttributes) {
      update['video'][name] = this.video_[name];
    }

    // TODO: Instead of this variable frequency update system, instead cache the
    // previous player state and only send over changed values, with complete
    // updates every ~20 updates to account for dropped messages.

    if (this.player_.isLive()) {
      const PlayerGetterMethodsThatRequireLive =
          shaka.cast.CastUtils.PlayerGetterMethodsThatRequireLive;
      for (const name in PlayerGetterMethodsThatRequireLive) {
        const frequency = PlayerGetterMethodsThatRequireLive[name];
        if (this.updateNumber_ % frequency == 0) {
          update['player'][name] = /** @type {Object} */ (this.player_)[name]();
        }
      }
    }
    for (const name in shaka.cast.CastUtils.PlayerGetterMethods) {
      const frequency = shaka.cast.CastUtils.PlayerGetterMethods[name];
      if (this.updateNumber_ % frequency == 0) {
        update['player'][name] = /** @type {Object} */ (this.player_)[name]();
      }
    }

    // Volume attributes are tied to the system volume.
    const manager = cast.receiver.CastReceiverManager.getInstance();
    const systemVolume = manager.getSystemVolume();
    if (systemVolume) {
      update['video']['volume'] = systemVolume.level;
      update['video']['muted'] = systemVolume.muted;
    }

    // Only start progressing the update number once data is loaded,
    // just in case any of the "rarely changing" properties with less frequent
    // update messages changes significantly during the loading process.
    if (this.startUpdatingUpdateNumber_) {
      this.updateNumber_ += 1;
    }

    this.sendMessage_({
      'type': 'update',
      'update': update,
    }, this.shakaBus_);

    this.maybeSendMediaInfoMessage_();
  }

  /**
   * Composes and sends a mediaStatus message if appropriate.
   * @return {boolean}
   * @private
   */
  maybeSendMediaInfoMessage_() {
    if (this.initialStatusUpdatePending_ &&
        (this.video_.duration || this.player_.isLive())) {
      // Send over a media status message to set the duration of the cast
      // dialogue.
      this.sendMediaInfoMessage_();
      this.initialStatusUpdatePending_ = false;
      return true;
    }
    return false;
  }

  /**
   * Composes and sends a mediaStatus message with a mediaInfo component.
   *
   * @param {number=} requestId
   * @private
   */
  sendMediaInfoMessage_(requestId = 0) {
    const media = {
      'contentId': this.player_.getAssetUri(),
      'streamType': this.player_.isLive() ? 'LIVE' : 'BUFFERED',
      // Sending an empty string for now since it's a mandatory field.
      // We don't have this info, and it doesn't seem to be useful, anyway.
      'contentType': '',
    };
    if (!this.player_.isLive()) {
      // Optional, and only sent when the duration is known.
      media['duration'] = this.video_.duration;
    }
    if (this.metadata_) {
      media['metadata'] = this.metadata_;
    }
    this.sendMediaStatus_(requestId, media);
  }

  /**
   * Dispatch a fake 'volumechange' event to mimic the video element, since
   * volume changes are routed to the system volume on the receiver.
   * @private
   */
  fakeVolumeChangeEvent_() {
    // Volume attributes are tied to the system volume.
    const manager = cast.receiver.CastReceiverManager.getInstance();
    const systemVolume = manager.getSystemVolume();
    goog.asserts.assert(systemVolume, 'System volume should not be null!');

    if (systemVolume) {
      // Send an update message with just the latest volume level and muted
      // state.
      this.sendMessage_({
        'type': 'update',
        'update': {
          'video': {
            'volume': systemVolume.level,
            'muted': systemVolume.muted,
          },
        },
      }, this.shakaBus_);
    }

    // Send another message with a 'volumechange' event to update the sender's
    // UI.
    this.sendMessage_({
      'type': 'event',
      'targetName': 'video',
      'event': {'type': 'volumechange'},
    }, this.shakaBus_);
  }

  /**
   * Since this method is in the compiled library, make sure all messages are
   * read with quoted properties.
   * @param {!cast.receiver.CastMessageBus.Event} event
   * @private
   */
  onShakaMessage_(event) {
    const message = shaka.cast.CastUtils.deserialize(event.data);
    shaka.log.debug('CastReceiver: message', message);

    switch (message['type']) {
      case 'init':
        // Reset update message frequency values after initialization.
        this.updateNumber_ = 0;
        this.startUpdatingUpdateNumber_ = false;
        this.initialStatusUpdatePending_ = true;

        this.initState_(message['initState'], message['appData']);
        // The sender is supposed to reflect the cast system volume after
        // connecting.  Using fakeVolumeChangeEvent_() would create a race on
        // the sender side, since it would have volume properties, but no
        // others.
        // This would lead to hasRemoteProperties() being true, even though a
        // complete set had never been sent.
        // Now that we have init state, this is a good time for the first update
        // message anyway.
        this.pollAttributes_();
        break;
      case 'appData':
        this.appDataCallback_(message['appData']);
        break;
      case 'set': {
        const targetName = message['targetName'];
        const property = message['property'];
        const value = message['value'];

        if (targetName == 'video') {
          // Volume attributes must be rerouted to the system.
          const manager = cast.receiver.CastReceiverManager.getInstance();
          if (property == 'volume') {
            manager.setSystemVolumeLevel(value);
            break;
          } else if (property == 'muted') {
            manager.setSystemVolumeMuted(value);
            break;
          }
        }

        this.targets_[targetName][property] = value;
        break;
      }
      case 'call': {
        const targetName = message['targetName'];
        const methodName = message['methodName'];
        const args = message['args'];
        const target = this.targets_[targetName];
        // eslint-disable-next-line prefer-spread
        target[methodName].apply(target, args);
        break;
      }
      case 'asyncCall': {
        const targetName = message['targetName'];
        const methodName = message['methodName'];
        if (targetName == 'player' && methodName == 'load') {
          // Reset update message frequency values after a load.
          this.updateNumber_ = 0;
          this.startUpdatingUpdateNumber_ = false;
        }
        const args = message['args'];
        const id = message['id'];
        const senderId = event.senderId;
        const target = this.targets_[targetName];
        // eslint-disable-next-line prefer-spread
        let p = target[methodName].apply(target, args);
        if (targetName == 'player' && methodName == 'load') {
          // Wait until the manifest has actually loaded to send another media
          // info message, so on a new load it doesn't send the old info over.
          p = p.then(() => {
            this.initialStatusUpdatePending_ = true;
          });
        }
        // Replies must go back to the specific sender who initiated, so that we
        // don't have to deal with conflicting IDs between senders.
        p.then(
            () => this.sendAsyncComplete_(senderId, id, /* error= */ null),
            (error) => this.sendAsyncComplete_(senderId, id, error));
        break;
      }
    }
  }

  /**
   * @param {!cast.receiver.CastMessageBus.Event} event
   * @private
   */
  onGenericMessage_(event) {
    const message = shaka.cast.CastUtils.deserialize(event.data);
    shaka.log.debug('CastReceiver: message', message);
    // TODO(ismena): error message on duplicate request id from the same sender
    switch (message['type']) {
      case 'PLAY':
        this.video_.play();
        // Notify generic controllers that the player state changed.
        // requestId=0 (the parameter) means that the message was not
        // triggered by a GET_STATUS request.
        this.sendMediaStatus_();
        break;
      case 'PAUSE':
        this.video_.pause();
        this.sendMediaStatus_();
        break;
      case 'SEEK': {
        const currentTime = message['currentTime'];
        const resumeState = message['resumeState'];
        if (currentTime != null) {
          this.video_.currentTime = Number(currentTime);
        }
        if (resumeState && resumeState == 'PLAYBACK_START') {
          this.video_.play();
          this.sendMediaStatus_();
        } else if (resumeState && resumeState == 'PLAYBACK_PAUSE') {
          this.video_.pause();
          this.sendMediaStatus_();
        }
        break;
      }
      case 'STOP':
        this.player_.unload().then(() => {
          if (!this.player_) {
            // We've already been destroyed.
            return;
          }

          this.sendMediaStatus_();
        });
        break;
      case 'GET_STATUS':
        // TODO(ismena): According to the SDK this is supposed to be a
        // unicast message to the sender that requested the status,
        // but it doesn't appear to be working.
        // Look into what's going on there and change this to be a
        // unicast.
        this.sendMediaInfoMessage_(Number(message['requestId']));
        break;
      case 'VOLUME': {
        const volumeObject = message['volume'];
        const level = volumeObject['level'];
        const muted = volumeObject['muted'];
        const oldVolumeLevel = this.video_.volume;
        const oldVolumeMuted = this.video_.muted;
        if (level != null) {
          this.video_.volume = Number(level);
        }
        if (muted != null) {
          this.video_.muted = muted;
        }
        // Notify generic controllers if the volume changed.
        if (oldVolumeLevel != this.video_.volume ||
            oldVolumeMuted != this.video_.muted) {
          this.sendMediaStatus_();
        }
        break;
      }
      case 'LOAD': {
        // Reset update message frequency values after a load.
        this.updateNumber_ = 0;
        this.startUpdatingUpdateNumber_ = false;
        // This already sends an update.
        this.initialStatusUpdatePending_ = false;

        const mediaInfo = message['media'];
        const contentId = mediaInfo['contentId'];
        const currentTime = message['currentTime'];
        const assetUri = this.contentIdCallback_(contentId);
        const autoplay = message['autoplay'] || true;
        const customData = mediaInfo['customData'];

        this.appDataCallback_(customData);

        if (autoplay) {
          this.video_.autoplay = true;
        }
        this.player_.load(assetUri, currentTime).then(() => {
          if (!this.player_) {
            // We've already been destroyed.
            return;
          }

          // Notify generic controllers that the media has changed.
          this.sendMediaInfoMessage_();
        }).catch((error) => {
          goog.asserts.assert(error instanceof shaka.util.Error,
              'Wrong error type!');

          // Load failed.  Dispatch the error message to the sender.
          let type = 'LOAD_FAILED';
          if (error.category == shaka.util.Error.Category.PLAYER &&
              error.code == shaka.util.Error.Code.LOAD_INTERRUPTED) {
            type = 'LOAD_CANCELLED';
          }

          this.sendMessage_({
            'requestId': Number(message['requestId']),
            'type': type,
          }, this.genericBus_);
        });
        break;
      }
      default:
        shaka.log.warning(
            'Unrecognized message type from the generic Chromecast controller!',
            message['type']);
        // Dispatch an error to the sender.
        this.sendMessage_({
          'requestId': Number(message['requestId']),
          'type': 'INVALID_REQUEST',
          'reason': 'INVALID_COMMAND',
        }, this.genericBus_);
        break;
    }
  }

  /**
   * Tell the sender that the async operation is complete.
   * @param {string} senderId
   * @param {string} id
   * @param {shaka.util.Error} error
   * @private
   */
  sendAsyncComplete_(senderId, id, error) {
    if (!this.player_) {
      // We've already been destroyed.
      return;
    }

    this.sendMessage_({
      'type': 'asyncComplete',
      'id': id,
      'error': error,
    }, this.shakaBus_, senderId);
  }

  /**
   * Since this method is in the compiled library, make sure all messages passed
   * in here were created with quoted property names.
   * @param {!Object} message
   * @param {cast.receiver.CastMessageBus} bus
   * @param {string=} senderId
   * @private
   */
  sendMessage_(message, bus, senderId) {
    // Cuts log spam when debugging the receiver UI in Chrome.
    if (!this.isConnected_) {
      return;
    }

    const serialized = shaka.cast.CastUtils.serialize(message);
    if (senderId) {
      bus.getCastChannel(senderId).send(serialized);
    } else {
      bus.broadcast(serialized);
    }
  }

  /**
   * @return {string}
   * @private
   */
  getPlayState_() {
    const playState = shaka.cast.CastReceiver.PLAY_STATE;
    if (this.isIdle_) {
      return playState.IDLE;
    } else if (this.player_.isBuffering()) {
      return playState.BUFFERING;
    } else if (this.video_.paused) {
      return playState.PAUSED;
    } else {
      return playState.PLAYING;
    }
  }

  /**
   * @param {number=} requestId
   * @param {Object=} media
   * @private
   */
  sendMediaStatus_(requestId = 0, media = null) {
    const mediaStatus = {
      // mediaSessionId is a unique ID for the playback of this specific
      // session.
      // It's used to identify a specific instance of a playback.
      // We don't support multiple playbacks, so just return 0.
      'mediaSessionId': 0,
      'playbackRate': this.video_.playbackRate,
      'playerState': this.getPlayState_(),
      'currentTime': this.video_.currentTime,
      // supportedMediaCommands is a sum of all the flags of commands that the
      // player supports.
      // The list of comands with respective flags is:
      // 1 - Pause
      // 2 - Seek
      // 4 - Stream volume
      // 8 - Stream mute
      // 16 - Skip forward
      // 32 - Skip backward
      // We support all of them, and their sum is 63.
      'supportedMediaCommands': 63,
      'volume': {
        'level': this.video_.volume,
        'muted': this.video_.muted,
      },
    };

    if (media) {
      mediaStatus['media'] = media;
    }

    const ret = {
      'requestId': requestId,
      'type': 'MEDIA_STATUS',
      'status': [mediaStatus],
    };

    this.sendMessage_(ret, this.genericBus_);
  }
};

/** @type {number} The interval, in seconds, to poll for changes. */
shaka.cast.CastReceiver.POLL_INTERVAL = 0.5;

/** @type {number} The interval, in seconds, to go "idle". */
shaka.cast.CastReceiver.IDLE_INTERVAL = 5;

/**
 * @enum {string}
 */
shaka.cast.CastReceiver.PLAY_STATE = {
  IDLE: 'IDLE',
  PLAYING: 'PLAYING',
  BUFFERING: 'BUFFERING',
  PAUSED: 'PAUSED',
};