As part of my efforts to over-engineer my TV watching I had to eventually build my own Chromecast receiver. I decided I wanted to leverage the extra features and speed offered by the hls.js library, taking for granted the fact that the integration with the Chromecast would have been easy. Boy I was wrong!

All Chromecast receivers are supposed to use the Cast Receiver Framework which does the heavy lifting necessary to interpret the Google Cast protocol and send the necessary lifecycle messages. It’s very ergonomic… as long as you follow the suggested golden paths.

If you take a close look at the documentation, you’ll see that the entire framework assumes that you’re going to use its own video player and video tag. If you try to search for a way to do otherwise, you’ll find a StackOverflow answer where a Cast engineer basically says “oh well, you can’t”. This is made funnier by the fact that this used to be possible with the previous edition of the Cast Receiver Framework… except that it has now been deprecated and for which it’s impossible to find any documentation.

All of this clearly wasn’t enough to stop me and my intention to use hls.js. After a lot of trial and error, here’s my definitive manual on “How to circumvent the restrictions of the Cast Receiver Framework and use your own player”!

Step 1: Page structure

Make sure you include the CAF JS library as well as your own player JS library. Here’s how to structure the HTML of your receiver:

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<!-- include CAF & your own player library -->
<style type="text/css">
html, body, video { /* fit the player to the screen's dimensions */
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    color: white;
}
.loader { /* primitive text-based loading indicator */
    position: absolute;
    inset: 0;
    justify-content: center;
    align-content: center;
    font-size: 50px;
}
</style>
</head>
<body>
<div class="loader">Loading...</div>
<video></video>
<script type="application/javascript">/* ... */</script>
</body>
</html>

Step 2: Intercept loading messages

To handle loading requests and actually play media, you will need to intercept incoming LOAD requests and dispatch them to your own player library. The interceptor can return a Promise which you can use to report loading failures.

// This example integrates `hls.js`, but anything else can be used.
const context       = cast.framework.CastReceiverContext.getInstance(),
      playerManager = context.getPlayerManager(),
      videoElement  = document.querySelector('video');

let hls; // static instance of hls.js (destroyed when playing another media is requested)

// For brevity, all the code handling the `.loader` element is stripped.
const setStatus = ({ isLoading, error }) => ...;

// When the video element has successfully loaded data, hide the loader.
videoElement.addEventListener('loadeddata', () => setStatus({ isLoading: false }));

// Intercept loading requests.
playerManager.setMessageInterceptor(cast.framework.messages.MessageType.LOAD, event => {
  if (!event.media.entity) // legacy
      event.media.entity = event.media.contentId;
  // event.media.entity contains the user-defined URL to load.

  const reqId = event.requestId, sender = event.senderId;
  setStatus({ isLoading: true });

  // Return a promise 
  return new Promise((resolve, reject) => {
    // Destroy any pre-existing player.
    if (hls) {
      hls.destroy();
      hls = null;
    }

    // Instantiate a new player and attach it to the video element.
    hls = new Hls();
    hls.attachMedia(videoElement);

    // Once the player is successfully attached to the video tag, request to play the provided
    // media & buffer data.
    hls.once(Hls.Events.MEDIA_ATTACHED, () => {
      hls.loadSource(event.media.entity);
      videoElement.play();
    });

    // When the manifest has been parsed, we can assume that playback is going to succeed.
    // Resolve the promise.
    hls.once(Hls.Events.MANIFEST_PARSED, () => resolve(event));

    // For all other fatal errors, do special handling.
    hls.on(Hls.Events.ERROR, (e, data) => {
      if (!data.fatal) return;
      switch (data.type) {
      case Hls.ErrorTypes.NETWORK_ERROR:
        // Playlist parsing / loading failed altogether. Fail the promise.
        const error = new cast.framework.messages.ErrorData(cast.framework.messages.ErrorType.LOAD_FAILED);
        error.reason = cast.framework.messages.ErrorReason.GENERIC_LOAD_ERROR;
        hls.destroy();
        setStatus({ error: 'Network error :-(' });
        return resolve(error);
      case Hls.ErrorTypes.MEDIA_ERROR:
        // attempt to recover media errors (no guarantee that this will succeed)
        hls.recoverMediaError();
        break;
      default:
        // Other unrecoverable error. Panic. (The promise should have already been resolved here.)
        playerManager.sendError(sender, reqId, cast.framework.messages.ErrorType.LOAD_FAILED);
        setStatus({ error: 'Unrecoverable error' });
        hls.destroy();
        break;
      }
    });
  });
});

Step 3: Handle pause and play messages

This is optional, but it allows to control the player via the Google Home interface (or explicit play/pause messages). For non-live broadcasts, additional handling might be required.

const changePlaybackStatus = ({ shouldPlay }) => m => {
  videoElement[shouldPlay ? 'play' : 'pause']();
  playerManager.broadcastStatus(true);
  return m;
};

playerManager.setMessageInterceptor(cast.framework.messages.MessageType.PAUSE,
  changePlaybackStatus({ shouldPlay: false }));
playerManager.setMessageInterceptor(cast.framework.messages.MessageType.PLAY,
  changePlaybackStatus({ shouldPlay: true }));

Step 4: Start the Cast Receiver Framework

There are two limitations that need to be worked around for all of this to work:

  1. The Cast Receiver Framework loads the implementations of the player it uses behind the scenes after it starts. This is unnecessary since we’re using our own player.
  2. By default, the framework automatically kills the app if playing has been idle for more than a certain timeout. This does not work properly when using a custom player, so this timeout needs to go.
const options = new cast.framework.CastReceiverOptions();

// Do not load unnecessary JS files for players we don't need.
options.skipPlayersLoad = true;

// Disable the idle timeout. Note that this is something actually useful to have, but it should
// be easy to implement with some bookkeeping and `setTimeout`.
options.disableIdleTimeout = true;

// Enable basic media commands.
options.supportedCommands =
  cast.framework.messages.Command.ALL_BASIC_MEDIA;

// Optional, maximize the debug level to diagnose problems.
// context.setLoggerLevel(cast.framework.LoggerLevel.DEBUG);

context.start(options);