Media controller

The media elements added in HTML5, <audio> and <video>, offer built-in UI via an attribute toggle. However, that UI isn't customizable in any real sense — certainly not in a cross-browser way. If you want to create a styled play button, you need to implement it yourself.

I built this element for a multimedia story at NPR. Being a radio organization, we've done lots of audio-centric projects before — in my retrospective for White Lies, for example, you can see sample code for a player that loaded files spread throughout the story (and read about how making it work on iOS poses an extra challenge). However, one of the nice things about a custom element is that it gives you more of a building block: instead of needing to build a monolithic player that runs across a whole presentation, I can instead build a single player control button as a discrete unit, then wire those pieces together as needed.

This component was built in our interactive template, which bundles JavaScript using CommonJS modules. That also allows us to load HTML templates from separate files using require(), which is handy — no long, clumsy string literals here. Tools like WebPack and Rollup also let you do this from ES6 import statements, and I highly recommend working out how. Otherwise, I've built tools that use fetch() and the customElements.whenDefined() function to support async component loading, but it definitely feels less seamless. If only HTML imports were still an option...

media-controls.js

var CustomElement = require("./customElement");
var { watchSelector, unwatchSelector } = require("./watchSelector");

class MediaControls extends CustomElement {
  constructor() {
    super();
    this.media = null;
    this.elements.playButton.addEventListener("click", this.onClickedPlay);
  }

  static get observedAttributes() {
    return ["for", "src"];
  }

  static get mirroredProps() {
    return ["for", "src"];
  }

  static get boundMethods() {
    return [
      "onWatch",
      "onMediaUpdate",
      "onClickedPlay"
    ];
  }

  static get subscriptions() {
    return [
      "play",
      "pause",
      "timeupdate",
      "canplaythrough"
    ]
  }

  attributeChangedCallback(attr, was, value) {
    switch (attr) {

      case "for":
        if (was) unwatchSelector(`[id="${was}"]`, this.onWatch);
        if (value) watchSelector(`[id="${value}"]`, this.onWatch)
        break;

      case "src":
        var media = document.createElement("audio");
        media.src = value;
        this.connect(media);
        break;

    }
  }

  connect(element) {
    if (element == this.media) return;
    if (this.media) {
      this.disconnect();
    }
    // subscribe to events
    this.media = element;
    if (element) {
      for (var e of MediaControls.subscriptions) {
        element.addEventListener(e, this.onMediaUpdate);
      }
    }
  }

  disconnect() {
    if (!this.media) return;
    // unsubscribe from events
    for (var e of MediaControls.subscriptions) {
      this.media.removeEventListener(e, this.onMediaUpdate);
    }
    this.media = null;
  }

  onWatch(element) {
    if (this.src) return;
    this.connect(element);
  }

  onMediaUpdate(e) {
    var { duration, currentTime, paused } = this.media;
    var ratio = currentTime / duration;
    var { labels, progress, playIcon, pauseIcon } = this.elements;
    try {
      var pLength = Math.ceil(progress.getTotalLength());
      var pDash = Math.ceil(ratio * pLength);
      progress.style.strokeDasharray = [pLength, pLength].join(" ");
      progress.style.strokeDashoffset = pDash;
      if (paused) {
        playIcon.style.display = "";
        pauseIcon.style.display = "none";
      } else {
        playIcon.style.display = "none";
        pauseIcon.style.display = "";
      }
    } catch (err) {
      // SVG code will fail if the button isn't immediately visible, it's fine.
    }
  }

  onClickedPlay() {
    if (!this.media) return;
    if (this.media.paused) {
      this.media.currentTime = 0;
      this.media.play();
      events.fire("media-play", this.media);
    } else {
      this.media.currentTime = 0;
      this.media.pause();
    }
  }

  static get template() {
    return require("./_media-controls.html");
  }

}

MediaControls.define("media-controls");

_media-controls.html

<style>
:host {
  --button-fg: currentColor;
  --button-bg: transparent;
  display: block;
  font-family: inherit;
}

.row {
  display: flex;
  align-items: center;
  justify-content: flex-start;
}

button.play-pause {
  background: transparent;
  border: none;
  color: inherit;
}

.play-pause svg {
  width: 64px;
  height: 64px;
}

.border {
  fill: var(--button-bg);
  stroke: var(--button-bg);
  stroke-width: 1px;
  stroke-dasharray: 1 2;
}

.play-pause .progress {
  fill: none;
  stroke: var(--button-fg);
  stroke-width: 2px;
  transform-origin: center;
  transform: rotate(-90deg);
}

.play-pause .icon {
  fill: var(--button-fg);
}

.labels {
  max-width: var(--label-width, auto);
  font-size: 20px;
  padding: 4px;
  text-align: left;
}

</style>
<div class="player">
  <div class="row">
    <button class="play-pause" as="playButton">
      <svg class="play-pause" viewBox="0 0 32 32" width="32" height="32">
        <circle cx=16 cy=16 r=15 class="border" />
        <circle cx=16 cy=16 r=15 class="progress" as="progress" />
        <path class="play icon" 
          as="playIcon"
          d="M12,9 L23,16 L12,23 Z"
        />
        <path class="pause icon" 
          as="pauseIcon" 
          style="display: none" 
          d="M11,9 L14,9 L14,23 L11,23 L11,9 M18,9 L21,9 L21,23 L18,23 L18,9"
        />
      </svg>
    </button>
    <div class="labels" as="labels">
      <slot as="slot"></slot>
    </div>
  </div>
</div>

Notes

Associated media elements

This element was the first real use of the association and control pattern I've written about in this book. It mimics the "for" attribute that's used on labels, or the way "aria-activedescendant" works — set the attribute to the ID of the media element, and our controller will automatically connect to it to trigger playback and show the current progress.

Using the watchSelector module means that we don't have to worry about whether the ID was set first, or whether the element exists when we set the "for" attribute. If the target element is created or ID'd after initialization, we'll still be notified, and the connect() method will be called to set up the association.

Here's the source for watchSelector.js, which is just an expanded version of the watchID() function in an earlier chapter.

var watchList = new Map();

var glance = function (watch) {
  var result = document.querySelector(watch.selector);
  watch.callbacks.forEach(function (c) {
    if (c.previous == result) return;
    c(result);
    c.previous = result;
  });
};

var observer = new MutationObserver(function (mutations) {
  watchList.forEach(glance);
});

observer.observe(document.body, {
  subtree: true,
  childList: true,
  attributeFilter: ["id"],
});

var watchSelector = function (selector, callback) {
  var watch = watchList.get(selector) || { selector, callbacks: [] };
  if (watch.callbacks.includes(callback)) return;
  watch.callbacks.push(callback);
  try {
    glance(watch);
    watchList.set(selector, watch);
  } catch (err) {
    console.error(err);
  }
};

var unwatchSelector = function (selector, callback) {
  var watching = watchList.get(selector);
  if (!watching) return;
  watching.callbacks = watching.callbacks.filter((c) => c != callback);
  if (!watching.callbacks.length) {
    watchList.delete(selector);
  }
};

module.exports = { watchSelector, unwatchSelector };

You can also call connect() manually, without setting the "for" attribute — that proves useful if you're nesting this component into another UI component, as I later did for a <simple-video> player that combined a video and a play button into one component. In that case, because the shadow DOM contained the control and video elements, we couldn't search the document for them by ID — but we also didn't need to, since we had references to them and could simply connect() them in our outer component's constructor.

If you don't want to create or control a media element, you just want to play an audio file, you can also set the "src" attribute, and the element will host its own <audio>. This isn't something we used much, but it's a nice way to quickly test something in the page.

Handling media events

Media events can be frustrating to handle at first, especially because they don't bubble like normal DOM events do. You have to add listeners directly to the <audio> or <video> element. However, once you get the hang of it, these events are actually often very easily to handle for the purposes of display, because you don't need to maintain any local state: you can get all the information you need directly from the media element.

Accordingly, our onMediaUpdate() is called for almost any media event ("play", "pause", "timeupdate", or "canplaythrough", in this case). Regardless of the type, we set our UI to reflect the current state of the media element:

In a more robust player, you might add indicators for seeking or stalled downloads, or output the time as text. But the more you leave state up to the media element, instead of trying to track it inside your component, the better off you'll be. Treat the DOM as the source of truth.

Style hooks

For this particular component, we're using two CSS variables to expose styling from outside. By setting --button-bg or --button-fg on a rule that targets our media elements, you can change them to whatever theme colors you want.

<media-controls src="example.mp3">
  This will be shown as the label for the audio player
</media-controls>

Originally, the label on this component was set as an attribute. However, it became clear very quickly that it was much easier — not to mention simpler — to simply expose the inner content through a slot. There's still a --label-width variable to control its size, but generally we found it easier to treat this element as a block and style its size from the outer layout, rather than trying to manage its width from the inside-out.