Theme colour switcher

How I customized jPlayer for accessibility and added playback rate control, Drupal 9

One of my clients is a site which has extensive audio recordings. Their current site uses the default HTML5 audio player, but they want something that is more customizable to fit with their design and needs.

One of their requests was to be able to set audio playback speed. One of my priorities was that the audio player be fully accessible.

To display the audio player, they are using the Drupal Audiofield module. It has various player options which I tried out and determined that jPlayer was the most customizable and the closest to being accessible out of the box.

I also checked out Ableplayer, "a fully accessible cross-browser media player" created by an accessibility specialist. It indeed is pretty much fully accessible, but alas, options for visual customization are lacking.

So jPlayer it was. But apart from customizing the design, I had to add the functionality to select playback rate and solve the problem that jPlayer's time seek bar and volume bar controls are not accessible. They are controllable by mouse input (and presumably touch), but not keyboard. They aren't focusable, have no accessible name, and can't be adjusted with the expected keypresses.

After some research I found that the range input is the correct semantic element for volume and seek bar controls. This input allows the user to select a numeric value between a given minimum and maximum, and is fully accessible by default. Its UI is that of a slider:

It was easy enough to replace the seek and volume bars in the jPlayer Twig template (audioplayer--jplayer--default-single.html.twig) that comes with the Audiofield module (be sure to copy this template to your theme, rather than overriding the template in the module). Giving them the same CSS classes that jPlayer gives its seek and volume bars even caused them to work on mouse click.

For the seek bar, I gave it a minimum value of 0 and maximum of 100, with a "step" value of 1. The step value is the increment that the user can adjust the value by. For a time seek bar, you want finer control than for something like a volume bar. The volume bar has a minimum value of 0 and maximum of 10, and a step value of 1. This fits with jPlayer's default volume, which is a value between 0.1 and 1.

I also added a select list to the player template for choosing the playback speed. The options have values between 0.5 and 2, and the default selected option is 1x.

The template was modified to make it more accessible in a few other ways, such as enclosing the player in a <section> element and giving the section a label. I don't want to get bogged down too much in the template details, but you should be able to use it as an accessible example (it's a bit long, so I've put it in a collapsed widget). I will just note that I'm using CSS to toggle the visibility of the icons and button label on the play/pause button, targeting a CSS class that jPlayer adds when it's playing.

jPlayer Twig template code

{% for file in files %}
  {% set aria_label = file.description %}
{% endfor %}

<section class="audiofield jp-jplayer--container" aria-label="media player - {% for file in files %}{{ file.description|replace({'.mp3':''}) }}{% endfor %}">
  <div id="jquery_jplayer_{{ settings.id }}" class="jp-jplayer"></div>
  <div id="jp_container_{{ settings.id }}" class="jp-audio">
    <div class="jp-type-single">
      <div class="jp-gui jp-interface">
        <div class="jp-controls">
          <div class="jp-play-stop">
            <button class="jp-play">
              <svg width="14" height="18" viewBox="0 0 14 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" class="jp-play-icon">
                <path d="M12.8122 10.3584C13.5197 9.88676 13.5197 8.84713 12.8122 8.37546L1.85261 1.06908C1.06071 0.541149 0 1.10883 0 2.06056V16.6733C0 17.6251 1.06071 18.1927 1.85261 17.6648L12.8122 10.3584Z" fill="currentColor"/>
              </svg>
              <svg width="13" height="17" viewBox="0 0 13 17" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" class="jp-pause-icon">
                <rect width="5" height="17" rx="1" fill="currentColor"/>
                <rect x="8" width="5" height="17" rx="1" fill="currentColor"/>
              </svg>
              <span class="visually-hidden jp-play-label--play">{{ 'Play'|t }}</span>
              <span class="visually-hidden jp-play-label--pause">{{ 'Pause'|t }}</span>
            </button>
            <button class="jp-stop" aria-label="Stop">
              <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
                <path d="M0 1.28571C0 0.575634 0.575634 0 1.28571 0H16.7143C17.4244 0 18 0.575634 18 1.28571V16.7143C18 17.4244 17.4244 18 16.7143 18H1.28571C0.575634 18 0 17.4244 0 16.7143V1.28571Z" fill="currentColor"/>
              </svg>
            </button>
          </div>
          <div class="jp-playback-rate">
            <label for="jp-playback-rate-select--{{ settings.id }}" class="">{{ 'Speed:'|t }}</label>
            <select id="jp-playback-rate-select--{{ settings.id }}" class="jp-playback-rate-select">
              <option value="0.5">0.5x</option>
              <option value="0.75">0.75x</option>
              <option value="1" selected>1x</option>
              <option value="1.5">1.5x</option>
              <option value="2">2x</option>
            </select>
          </div>
        </div>
        <div class="jp-progress">
          <div class="jp-seek-time">
            <label for="jquery_jplayer_{{ settings.id }}--seek" class="visually-hidden">{{ 'Seek time'|t }}</label>
            <input id="jquery_jplayer_{{ settings.id }}--seek" type="range" min="0" max="100" step="1" value="0" class="jp-seek-bar" />
            <div class="jp-time-holder">
              <div class="jp-current-time--wrapper">
                <span role="text">
                  <span class="visually-hidden">{{ 'Current time:'|t }}</span>
                  <span class="jp-current-time" role="timer">&nbsp;</span>
                </span>
              </div>
              <div class="jp-time-divider">
                <span aria-hidden="true">/</span>
              </div>
              <div class="jp-duration--wrapper">
                <span role="text">
                  <span class="visually-hidden">{{ 'Audio length:'|t }}</span>
                  <span class="jp-duration">&nbsp;</span>
                </span>
              </div>
            </div>
          </div>
        </div>
        <div class="jp-volume-controls">
          <button class="jp-mute" aria-label="Mute">
            <svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
              <path d="M11.5714 0L5.14286 5.14286H0V12.8571H5.14286L11.5714 18V0Z" fill="currentColor"/>
              <path d="M14.5714 6L20.5714 12M14.5714 12L20.5714 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </button>
          <label for="jquery_jplayer_{{ settings.id }}--volume" class="visually-hidden">{{ 'Volume'|t }}</label>
          <input id="jquery_jplayer_{{ settings.id }}--volume" type="range" min="0" max="10" step="1" class="jp-volume-bar" />
          <button class="jp-volume-max" aria-label="Max volume">
            <svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg">
              <path d="M11.5714 0L5.14286 5.14286H0V12.8571H5.14286L11.5714 18V0Z" fill="currentColor"/>
              <path d="M17.1014 2C18.9761 3.87528 20.0293 6.41836 20.0293 9.07C20.0293 11.7216 18.9761 14.2647 17.1014 16.14M14.5714 5.53C15.5088 6.46764 16.0354 7.73918 16.0354 9.065C16.0354 10.3908 15.5088 11.6624 14.5714 12.6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </button>
        </div>
      </div>
      <div class="jp-no-solution">
        <span>Update Required</span>
        To play the media you will need to either update your browser to a recent version or update your <a href="http:// get.adobe.com/flashplayer/" target="_blank">Flash plugin</a>.
      </div>
    </div>
  </div>
</section>

The theme library

I created a new library in my theme, because we only want the audio player CSS and JS to load on pages where the audio player is found. We make sure to add a dependency on the jPlayer library, so our code loads after it. In THEMENAME.libraries.yml:

jplayer:
  css:
    theme:
      css/tim/jplayer.css: {}
  js:
    js/jplayer.js: {}
  dependencies:
    - audiofield/audiofield.jplayer

In the audio page template, we attach our library:

{{ attach_library('THEMENAME/jplayer') }}

The JavaScript

Next, I had to create the code that would make the controls active. jPlayer has a developer guide that made the process relatively painless. It has a whole bunch of methods and options that you can tap into to create a fully customized media player.

Note that you do have to use jQuery to get and set the player options. jPlayer also supports a library called Zepto, which admittedly I hadn't heard of.

In jplayer.js:

(function ($) {
    document.addEventListener('DOMContentLoaded', function() {
        // get all media players on the page
        const players = document.querySelectorAll('.jp-jplayer');

        // loop over them and add the functions
        players.forEach(function(player) {
            const ui = player.nextElementSibling;
            const select = ui.querySelector('select.jp-playback-rate-select');
            const seekBar = ui.querySelector('.jp-seek-bar');
            const muteButton = ui.querySelector('button.jp-mute');
            const maxVolButton = ui.querySelector('button.jp-volume-max');
            const volumeBar = ui.querySelector('input.jp-volume-bar');
            const jPlayerVol = $(player).jPlayer("option", "volume");
            const volumeMax = volumeBar.getAttribute('max');
           
            // when the seek bar is adjusted, set the playhead to the selected value
            seekBar.addEventListener('change', function() {
                const value = this.value;
                $(player).jPlayer("playHead", value);
            });
          
            // on page load, set the volume to whatever the default is in jPlayer. This is a multiplication operation because jPlayer volume is a number between 0.1 & 1
            volumeBar.value = (jPlayerVol * volumeMax);
          
            // reset the volume bar value to 0 when the mute button is pressed
            muteButton.addEventListener('click', function() {
                volumeBar.value = "0";
            });

            // set the volume bar value to its maximum when the max volume button is pressed
            maxVolButton.addEventListener('click', function() {
                volumeBar.value = volumeBar.getAttribute('max');
            });
           
            // change the volume when the volume bar value is adjusted
            volumeBar.addEventListener('change', function() {
                const volume = (this.value * 0.1);
                $(player).jPlayer("option", "volume", volume);
            });

            // change the playback rate when the select list value changes. Option values are numbers so we can use them without doing any processing on them. The labels have the "x" suffix
            select.addEventListener('change', function() {
                const selected = select.options[select.selectedIndex].value;
                $(player).jPlayer("option","playbackRate", selected);
            });
        });
    });
})(jQuery);

That's it for now. Another request the client has is for the player to remember the time the user last stopped listening. That seems likely to be a bit more complicated and involve setting cookies, but if it's interesting I may post the solution as well.

Tags:

Comments

Yo that's cool!

Add new comment

Plain text

  • No HTML tags allowed.
  • Lines and paragraphs break automatically.