Press / to search

    No results

    Search failed

    npm GitHub

    Media player

    Every media player library starts by taking the player away from you: you hand over an <audio> and a config object, and back comes someone else's control bar. This is the other way round โ€” you write the controls, in your markup, in the order you put them, and the page wires them to the media.

    It is <toolbar-elemental> holding two <slider-elemental>s, and it is the composition that makes the point: neither of them knows what media is, and neither needed a line of new code.

    Audio: MDN's shared assets, the same source the video gallery plays from.

    Tab through the bar and it is three stops for four controls: the two buttons share one, because that is what a toolbar is, and each slider keeps its own. โ†’ on the play button lands on mute, straight past the scrubber between them; โ† on the volume moves the volume. Nothing on this page had to say which control was which.

    The markup

    <figure class="player">
      <audio controls preload="metadata" src="/episode.mp3"></audio>
    
      <toolbar-elemental aria-label="Playback" hidden>
        <button type="button" class="play" aria-label="Play">โ–ถ</button>
    
        <slider-elemental class="scrubber" tooltip="thumb track">
          <input type="range" min="0" max="100" step="0.01" value="0"
                 aria-label="Seek" aria-valuetext="0:00 of 0:00" />
        </slider-elemental>
    
        <p class="time"><span class="now">0:00</span> / <span class="total">0:00</span></p>
    
        <button type="button" class="mute" aria-label="Mute" aria-pressed="false">๐Ÿ”Š</button>
    
        <slider-elemental class="volume" tooltip>
          <input type="range" min="0" max="1" step="0.05" value="1" aria-label="Volume" />
        </slider-elemental>
      </toolbar-elemental>
    </figure>

    Two things in there are the whole progressive-enhancement story. The <audio> keeps controls, so a browser with no script still shows a player that works. The bar is hidden, so the controls nobody is driving yet are not on the page. The glue swaps them, and that swap is the first thing it does.

    The glue

    const clock = (s) =>
      Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}` : '0:00';
    
    function wire(player) {
      const media = player.querySelector('audio, video');
      const bar = player.querySelector('toolbar-elemental');
      const scrubber = player.querySelector('.scrubber');
      const volume = player.querySelector('.volume');
      const seek = scrubber.querySelector('input');
      const level = volume.querySelector('input');
      const play = player.querySelector('.play');
      const mute = player.querySelector('.mute');
      const now = player.querySelector('.now');
      const total = player.querySelector('.total');
    
      // the swap: the browser's controls go and ours arrive, and only now that there is script here
      media.controls = false;
      bar.hidden = false;
    
      // what the bubbles say, because a scrubber reading 137 and a volume reading 0.4 are both
      // the number and neither is the answer
      scrubber.format = clock;
      volume.format = (value) => `${Math.round(value * 100)}%`;
    
      media.addEventListener('loadedmetadata', () => {
        seek.max = media.duration;
        total.textContent = clock(media.duration);
        scrubber.apply();
      });
    
      let scrubbing = false;
      seek.addEventListener('pointerdown', () => { scrubbing = true; });
      document.addEventListener('pointerup', () => { scrubbing = false; });
    
      media.addEventListener('timeupdate', () => {
        if (!scrubbing) {
          seek.value = media.currentTime;
          scrubber.apply();
        }
        now.textContent = clock(media.currentTime);
        seek.setAttribute('aria-valuetext', `${clock(media.currentTime)} of ${clock(media.duration)}`);
      });
    
      media.addEventListener('play', () => { player.dataset.playing = ''; play.setAttribute('aria-label', 'Pause'); });
      media.addEventListener('pause', () => { delete player.dataset.playing; play.setAttribute('aria-label', 'Play'); });
    
      play.addEventListener('click', () => (media.paused ? media.play() : media.pause()));
      seek.addEventListener('input', () => { media.currentTime = Number(seek.value); });
      level.addEventListener('input', () => { media.volume = Number(level.value); });
      mute.addEventListener('click', () => {
        media.muted = !media.muted;
        mute.setAttribute('aria-pressed', String(media.muted));
      });
    }
    
    document.querySelectorAll('.player').forEach(wire);

    Forty lines, ten of them a querySelector, and every one of the rest is about the media โ€” not about focus, not about roles, not about which key does what. That is the whole return on the two elements.

    It is a function rather than a script because the same bar goes over a <video> further down without a line changed: querySelector("audio, video") is the only place on this page that had to know which it is.

    A value written from script has to say so

    scrubber.apply() in there is not decoration, and leaving it out is the bug this page had until it did not. Assigning input.value fires no event. Not input, not change โ€” that is the HTML spec's rule for programmatic value changes, and it is what stops a script that syncs two fields from looping forever. <slider-elemental> paints its fill from the input event, so a scrubber driven by timeupdate moves its thumb โ€” the browser draws that โ€” while the fill behind it stays frozen wherever the last drag left it.

    apply() is the element's public catch-up for exactly this case: it re-reads the inputs and rewrites --slider-elemental-start and --slider-elemental-end. Two places on this page need it, and both are places where script moved something the element could not see:

    After Why the element cannot see it
    seek.value = media.currentTime An assigned value fires nothing
    seek.max = media.duration The scale moved under a value that did not, so every ratio is stale

    <media-player> does the same thing in its paint(), and for the same reason โ€” this is the one line of glue that a hand-rolled player and a finished one have identically.

    Nothing else needs it. A drag, an arrow key, Home, a form reset, a back-navigation โ€” the element hears all of those on its own.

    The arrows do not fight the scrubber

    A control bar with sliders in it is where the toolbar pattern usually breaks: the bar wants โ† and โ†’ for walking between controls, and a range input wants exactly the same two keys for its own value. One of them has to lose, and in most implementations it is the slider โ€” a scrubber you cannot move with the keyboard.

    <toolbar-elemental> walks button and a[href] and nothing else, so the question never comes up. The sliders are not in the roving tabindex, so they keep their own arrows and their own tab stop, and the two buttons share one between them. Nothing on this page configures that; it is the element's own answer to MDN's advice that a control wanting the arrow keys should be kept out of a toolbar or put last.

    What a scrubber announces

    A range input at 137 announces 137, and 137 of what is the reader's problem. So the glue keeps aria-valuetext on it:

    seek.setAttribute('aria-valuetext', `${clock(media.currentTime)} of ${clock(media.duration)}`);

    That is the page's job rather than the element's, and deliberately. <slider-elemental> writes no ARIA at all โ€” no role="slider", no aria-valuenow โ€” because the <input type="range"> inside it already is the APG Slider pattern and rewriting it would be replacing something correct with something copied. What it cannot know is that this particular slider is seconds, and that seconds are read as minutes and seconds.

    aria-valuemin and aria-valuemax stay off for the reason that page gives: HTML-ARIA says authors should not put them on a range input.

    What the bubbles say

    The same problem again, for the reader who is looking rather than listening. Hover the scrubber and the bubble reads 1:12, not 72; hover the volume and it reads 60%, not 0.6. Both are tooltip in the markup and format in the glue:

    Slider Attribute format What it buys
    Scrubber tooltip="thumb track" clock The time under the thumb, and the time under the pointer anywhere on the track โ€” which is the "where would this seek to" every media player has
    Volume tooltip (bare, so thumb only) (v) => Math.round(v * 100) + "%" The one thing 0.6 will not say

    clock is already there for the <span>s and the aria-valuetext, so the scrubber's formatter is that same function handed over โ€” one spelling of a time on this page, in three places that would otherwise be three chances to disagree.

    The bubbles are pointer-only and aria-hidden. A keyboard reader never sees one and is not missing anything: the input under it announces its own value, with aria-valuetext already saying it as a time. That is why the clock beside the scrubber stays โ€” the bubble is a convenience for a hovering hand, never the only place a number lives.

    Two buttons, two conventions

    Button Name State
    Play / pause swaps โ€” Play, then Pause none
    Mute fixed โ€” Mute aria-pressed

    That looks inconsistent and is not. Mute is a setting: it stays on until you turn it off, which is exactly the shape aria-pressed is for and the reasoning <password-elemental> spells out at length. Play is an action, and the action changes: the button that was going to start the audio is now going to stop it, and a name that stayed Play while aria-pressed="true" would be describing the state rather than the press.

    The icons swap on one attribute โ€” player.dataset.playing โ€” and CSS shows one of the two paths. Nothing in the glue touches an icon.

    The same bar over a video

    Same markup, same glue, <video> instead of <audio>:

    Video: Friday, from MDN's shared assets โ€” convenient for docs, wrong for a site you ship: host your own.

    <figure class="player">
      <video controls playsinline preload="metadata" src="/clip.mp4"></video>
    
      <toolbar-elemental aria-label="Playback" hidden>
        <!-- byte for byte the bar from the top of this page -->
      </toolbar-elemental>
    </figure>

    The demo above adds one thing the sample does not: its src ends #t=0.1. A <video> with no poster and preload="metadata" has a duration but no decoded frame, so it sits there as an empty box until something plays it โ€” and a media fragment asking for a tenth of a second in is a frame the browser has to fetch and paint. A real page writes poster instead; this one is borrowing a file it does not host.

    Nothing in the glue changed. currentTime, duration, volume, muted, play, pause and timeupdate are HTMLMediaElement's, not HTMLAudioElement's, so the glue that knew how to drive an <audio> already knew how to drive this. The one edit is querySelector("audio, video"), and it exists so a page can hold both.

    playsinline is the one attribute a video wants and audio has no use for: without it, an iPhone takes a playing video into its own fullscreen player, and the bar you wrote is not the one on screen.

    What a video wants next is a fullscreen button and a <track> of captions, and neither is composition โ€” the first is requestFullscreen() on the figure, and on an iPhone only the <video> itself can go fullscreen, by webkitEnterFullscreen(); the second is real work. That is where <media-player> starts.

    Without script

    The <audio controls> or <video controls> you wrote, playing, with the browser's own player and every keyboard shortcut it comes with. The custom bar is hidden in the markup, so it is not there at all โ€” and the swap happens in script, which means it cannot happen when there is no script to do it.

    This is the one arrangement where a custom media player is not a downgrade. The usual one writes <audio> with no controls and builds a bar next to it, and a script that fails to load leaves a silent rectangle.

    What the two elements are already doing

    Part What it already does
    <audio> / <video> Decoding, buffering, currentTime, duration, volume, muted, and every event above
    <toolbar-elemental> role="toolbar", one tab stop for the buttons, the arrows walking them, and staying out of the sliders' way
    <slider-elemental> The track fill CSS cannot place on its own โ€” --slider-elemental-start and --slider-elemental-end โ€” with the half-thumb correction that makes the fill line up with the thumb at both ends; the pointer bubble, and apply() for the values script moves
    <input type="range"> The whole APG Slider pattern: arrows, Home, End, PageUp/PageDown, touch, and step
    Neither element Anything about media. This page is the only thing here that knows what a scrubber is

    When you want the whole thing

    This is a demonstration of composition, not a media player. What it leaves out is most of one: captions and <track>, fullscreen, playback rate, picture-in-picture, a buffered range behind the scrubber, keyboard shortcuts on the media itself, a playlist, remembering where you stopped.

    That is <media-player> โ€” one custom element over the <audio> or <video> you already wrote, which keeps this arrangement and finishes it: you still write the controls, and it wires them by name instead of by the forty lines above. Its own control bar is a <toolbar-elemental> with a <slider-elemental> scrubber inside it, so what you have just read is that element's skeleton. npm i media-player-element.

    The buffered range is the one omission with a piece already in the book: <progress-elemental>'s buffer exists for exactly the part that is loaded but not played.