Improving Audio Player Accessibility with Ableplayer

May 30, 2020

A big shout out for this goes out to EJ Mason over at Webflow who pointed out that the default Mediaelement.js based audio player used in WordPress could be improved upon by switching over to Ableplayer. Ableplayer is an accessibility-first minded media player that combines clear keyboard controls with speed options, synchronized transcripts, and more. And obviously, it’s cross-browser compatible. So, how’d we do it?

By default, WordPress uses Mediaelement.js in combination with with the wp_audio_shortcode() function in WordPress. That shortcode takes an array of arguments and then renders a normal <audio> element. This creates a more “rich” media player experience that should normally degrade gracefully to the browser’s audio player implementation. Changing this out requires you to do a couple things: obviously you must include the Ableplayer assets in your theme, and then we need to override the default shortcode because Ableplayer requires some data attributes on the <audio> element.

There’s not much to dive into as far as uploading the assets goes. That’s straightforward, and the Ableplayer documentation is super clear about which files you need (assuming you’re doing it manually rather than through something like NPM). Just make sure they are available in your theme, and get the CSS and JS registered through your functions.php file like so:

 wp_register_script( 'ableplayer', get_stylesheet_directory_uri().'/vendor/ableplayer/build/ableplayer.dist.js', array( 'js-cookie' ), '4.3', true );
 wp_register_style( 'ableplayer', get_stylesheet_directory_uri().'/vendor/ableplayer/build/ableplayer.min.css', null, '4.3' );
 wp_register_script( 'js-cookie', get_stylesheet_directory_uri().'/vendor/ableplayer/thirdparty/js.cookie.js', null, false, true );

Just as a fast note, you’ll notice I also registered something called js-cookie. That’s also in the Ableplayer documentation and it’s included with it. Registering it isn’t a requirement – you could just manually code it into your theme – but doing it this way simplifies the process and ensures whenever the Ableplayer JS is loaded, js-cookie comes along with it automatically. You can learn more about wp_register_script() and wp_register_style() and all the arguments they take at the WordPress Developer site.

Also worth noting, obviously that code registers the scripts, but it doesn’t enqueue them on the site. This is intentional, and you’ll see down below in our function that the usage of the audio shortcode includes the enqueue hooks so that they only show up on pages where the audio player is rendered

Next up, we need to create our modified function to drive the shortcode. To do this, I just copied the default function, stripped out the stuff we don’t need to carry through since it’s not the default function (like the override components, since this IS the override), replaced mediaelement references with my now registered ableplayer library, and added the data attributes to the HTML rendering. I ended up with the function below (WARNING: long code block incoming. Also, you can name the function or namespace it as appropriate):

function dux_audio_shortcode( $html, $attr ) {
  $post_id = get_post() ? get_the_ID() : 0;

  static $instance = 0;
  $instance++;

  $audio = null;

  $default_types = wp_get_audio_extensions();
  $defaults_atts = array(
      'src'      => '',
      'loop'     => '',
      'autoplay' => '',
      'preload'  => 'none',
      'class'    => 'wp-audio-shortcode',
      'style'    => 'width: 100%;',
  );
  foreach ( $default_types as $type ) {
      $defaults_atts[ $type ] = '';
  }

  $atts = shortcode_atts( $defaults_atts, $attr, 'audio' );

  $primary = false;
  if ( ! empty( $atts['src'] ) ) {
      $type = wp_check_filetype( $atts['src'], wp_get_mime_types() );

      if ( ! in_array( strtolower( $type['ext'] ), $default_types, true ) ) {
          return sprintf( '<a class="wp-embedded-audio" href="%s">%s</a>', esc_url( $atts['src'] ), esc_html( $atts['src'] ) );
      }

      $primary = true;
      array_unshift( $default_types, 'src' );
  } else {
      foreach ( $default_types as $ext ) {
          if ( ! empty( $atts[ $ext ] ) ) {
              $type = wp_check_filetype( $atts[ $ext ], wp_get_mime_types() );

              if ( strtolower( $type['ext'] ) === $ext ) {
                  $primary = true;
              }
          }
      }
  }

  if ( ! $primary ) {
      $audios = get_attached_media( 'audio', $post_id );

      if ( empty( $audios ) ) {
          return;
      }

      $audio       = reset( $audios );
      $atts['src'] = wp_get_attachment_url( $audio->ID );

      if ( empty( $atts['src'] ) ) {
          return;
      }

      array_unshift( $default_types, 'src' );
  }

  /**
   * Filters the media library used for the audio shortcode.
   *
   * @since 3.6.0
   *
   * @param string $library Media library used for the audio shortcode.
   */
  $library = apply_filters( 'wp_audio_shortcode_library', 'ableplayer' );

  if ( 'ableplayer' === $library && did_action( 'init' ) ) {
      wp_enqueue_style( 'ableplayer' );
      wp_enqueue_script( 'ableplayer' );
  }

  /**
   * Filters the class attribute for the audio shortcode output container.
   *
   * @since 3.6.0
   * @since 4.9.0 The `$atts` parameter was added.
   *
   * @param string $class CSS class or list of space-separated classes.
   * @param array  $atts  Array of audio shortcode attributes.
   */
  $atts['class'] = apply_filters( 'wp_audio_shortcode_class', $atts['class'], $atts );

  $html_atts = array(
      'class'    => $atts['class'],
      'id'       => sprintf( 'audio-%d-%d', $post_id, $instance ),
      'loop'     => wp_validate_boolean( $atts['loop'] ),
      'autoplay' => wp_validate_boolean( $atts['autoplay'] ),
      'preload'  => $atts['preload'],
      'style'    => $atts['style'],
  );

  // These ones should just be omitted altogether if they are blank.
  foreach ( array( 'loop', 'autoplay', 'preload' ) as $a ) {
      if ( empty( $html_atts[ $a ] ) ) {
          unset( $html_atts[ $a ] );
      }
  }

  $attr_strings = array();

  foreach ( $html_atts as $k => $v ) {
      $attr_strings[] = $k . '="' . esc_attr( $v ) . '"';
  }

  $html = '';

  if ( 'ableplayer' === $library && 1 === $instance ) {
      $html .= "<!--[if lt IE 9]><script>document.createElement('audio');</script><![endif]-->\n";
  }

  $html .= sprintf( '<audio %s controls="controls" data-able-player data-skin="2020">', join( ' ', $attr_strings ) );

  $fileurl = '';
  $source  = '<source type="%s" src="%s" />';

  foreach ( $default_types as $fallback ) {
      if ( ! empty( $atts[ $fallback ] ) ) {
          if ( empty( $fileurl ) ) {
              $fileurl = $atts[ $fallback ];
          }

          $type  = wp_check_filetype( $atts[ $fallback ], wp_get_mime_types() );
          $url   = add_query_arg( '_', $instance, $atts[ $fallback ] );
          $html .= sprintf( $source, $type['type'], esc_url( $url ) );
      }
  }

  if ( 'ableplayer' === $library ) {
      $html .= wp_mediaelement_fallback( $fileurl );
  }

  $html .= '</audio>';

  return $html;
}

Now that we have our version of the shortcode, we need to make WordPress uses it instead of the default. We could just make this into its own shortcode, but I’d rather have it used in place of any default usage. To do this, WordPress gives us a hook aptly named wp_audio_shortcode_override(). To use it, all we have to do is add a filter that binds our function to the default one like so:

add_filter( 'wp_audio_shortcode_override', 'dux_audio_shortcode', 10, 2 );

With that, all your audio shortcode usage will render as the Ableplayer now. There is one note about this approach, however. This technique will only impact programmatic invocation of the shortcode in theme or plugin code. It will not affect usage of the audio block in the block editor (Gutenberg), which will render as a normal, unmodified audio element. Once I get a bit more time, I’ll look into overriding that, as well.