Media Seek from URL

By Tyler Gaw

I recently had a need to be able to control the time of audio elements via a URL query string. I whipped up a bit of JavaScript to handle it. There’s a demo here and the source is on GitHub. I had fun figuring it out and thought maybe you’d enjoy reading about it. The following are details about why I needed it and the code I wrote.

As with most projects, this one started with a problem that needed fixin’ The site this was for had a listing of audio tracks. Each list item included; the audio element, a title/link to the page for that track, and a short description. Some of list items had extra links to the track’s page in the description.

With this setup, the links to the track pages were causing the problem. Here’s what was happening; Users were pressing play on one of the tracks in the list. They would then click on one of the track’s links. That would take them away from the listing to that track’s page. The navigation would cause them to lose their place in the track they were listening to. The tracks were podcasts that ran from 45 to 90 minutes in length so losing your spot at 33 minutes and 45 seconds was annoying.

This is where controlling the time of the audio element came into play. Clicking on a link should take the user to the track’s page and the track should resume playing where they left off.

Writing The Thing

YouTube does this style of jumping around in videos via the URL. I think the query string style they use works well, so I took my cues from them. I decided I’d use URLs like:
http://site.com/tracks/track-name?t=1h22m35s.

Two quick things about the code examples and descriptions:

  1. I wrote the original code for a specific project. The code I’ll be showing here is a modified version of that project’s code. The main difference is that there are no links to separate pages. I left that out because I felt like the interesting thing is not the navigation between pages.
  2. The examples only show audio elements, but you can use the same code with video elements.

On page load, we run a function called seekIfNeeded. This function checks the window.location.search for the presence of the string “t=”. This determines if we need to bother trying to parse a time from the URL. This check is by no means fool-proof, but it gets the job done.

function seekIfNeeded () {
  var q = window.location.search;

  if (q.indexOf('t=') > -1) {
    // Do parsing stuff
  }
}

Once it’s determined there’s a time to parse the fun code starts. We declare a couple convenience variables for later use. We bind a canplay event to the audio. We convert the query string to seconds and then update currentTime property of the audio element.

function seekIfNeeded () {
  var q = window.location.search;

  if (q.indexOf('t=') > -1) {
    // Store the "1h34m27s" part of the query string
    var timeString = q.split('=')[1],

      // Store a reference to the audio element with an id of "media"
      media = document.getElementById('media'),

      // Have we updated the time of the media element from the URL before?
      seekedFromURL = false;

    // We can only interact with audio elements when they are ready.
    // Listen for the "canplay" event to know when that is.
    media.addEventListener('canplay', function () {

      // The "canplay" event is triggered every time the audio element
      // is able to play. We only want to change the currentTime of
      // the audio the first time this event fires.
      if (!seekedFromURL) {

        // The currentTime property seeks to a value of seconds in
        // the media element.
        media.currentTime = secondsFromTimeParts(partsFromTimeString(timeString));

        // We've done the seeking, don't do this again.
        seekedFromURL = true;
      }
    });

    media.play();
  }
}

Converting a String to Time

Things get interesting with the line that sets media.currentTime. It’s being set to the return value of the secondsFromTimeParts function. That function is given the return value of another function, partsFromTimeString.

I’ll break down that line of code from the inside out. timeString is a string like; “1h32m23s” or “15m10s” or “12s”. Any combination of hours, minutes, and seconds. Even though humans can figure out the string represents time, the audio element isn’t going to understand it. We pass the string to partsFromTimeString. That function converts the string into an object of key/value pairs. In the object, the keys represent a part of the time and the values the amount of time for each part.

function partsFromTimeString (str) {
  var parts = {h: 0, m: 0, s: 0};

  // Wrapping in a try to avoid an error in case someone gives the 't='
  // query string with no time. It'll just default to zero without it.
  try {

    // The regex match breaks the string into an array
    // like ["1h", "32m", "6s"]
    str.match(/[0-9]+[hms]+/g).forEach(function (val) {

      // Creates an array with two elements, the time and part
      // key like ["32", "m"]
      var part = val.match(/[hms]+|[0-9]+/g);
      parts[part[1]] = parseInt(part[0], 10);
    });
  }
  catch (e) {}

  return parts;
}

My first idea wasn’t to use a regular expression. I tend to avoid them because I only have a cursory knowledge of them. But the query string posed a problem I couldn’t solve without a regex. When trying to break up a string into specific parts, I look for patterns. A common letter or symbol or certain number or anything that repeats. A string like “1h32m23s” doesn’t have that, so I needed a more complex pattern.

The pattern that sticks out is; a number, followed by one of three letters. That letter could be “h”, “m”, or “s”. There’s no certainty that all the letters will be there. And they may not appear in the same order. Ambiguity lead me down the regular expression path. I suppose that’s what they’re for?

The string.match method returns an array of all matches found by a given regex. The regex /[0-9]+[hms]+/g reads the string from left to right and says, “I want an integer with a value of 0 through 9, followed by the letter ‘h’, ‘m’, or ‘s’.” If the regex finds that alphanumeric combination, it puts it in the array.

We’re getting closing to being able to separate the letters from the numbers. That’s what we’re after. Even though we want to separate them, we don’t want to disassociate them. Each letter gives us valuable information about the number. It tells us what part of the time it represents.

The next step is to iterate over the array of number/letter pairs one by one.

Again, we use the match method with a regex. The /[hms]+|[0-9]+/g regex says, “I want either the letter ‘h’, ‘m’, ‘s’ or a number, 0 through 9.” When either of those are found they’re placed in the array we named “part”.

The last bit of work is to add each of the time parts to the parts object we created at the start of the function. Since we get the time value from a string we need to use parseInt to convert it to a integer.

Now we have an object of time parts. The object looks like {h: 1, m: 22, s: 37}. The next step in seekIfNeeded is to get seconds from those parts. For that, we use the secondsFromTimeParts function.

function secondsFromTimeParts (parts) {
  var seconds = 0;

  seconds += parts.s;
  seconds += parts.m * 60;
  seconds += parts.h * 3600;

  return seconds;
}

secondsFromTimeParts adds up the values of the time parts object to get a total number of seconds. To get the number of seconds from minutes we multiply by 60. To get the number of seconds from hours we multiply by 3600. 3600 equals 60 minutes per hour times 60 seconds per minute.

At this point, we’ve converted a string that represents a time to an actual time value the media element can understand. We use that value–the number of seconds into the media we want to go–to set the currentTime property and then tell it to play.

Converting a Time to String

So now we can update the time of a media element with a query string, but what if we want to go the other way? What if we have the seconds and we want to convert that to our time string?

For that we’ll follow the previous process in reverse. We’ll get an object of time parts from the seconds and then a time string from the parts.

var timeStr = timeStringFromParts(timePartsFromSeconds(media.currentTime));

Again, from the inside out, let’s look at the timePartsFromSeconds function.

function timePartsFromSeconds (seconds) {
  var parts = {},
    secondsInt = Math.floor(seconds);

  parts.h = Math.floor((secondsInt / 3600) % 24);
  parts.m = Math.floor(secondsInt / 60);
  parts.s = secondsInt % 60;

  return parts;
}

We start by creating the empty parts object where we’ll store the key/value pairs. media.currentTime gives the number of seconds and milliseconds as a float. In this script I decided that I didn’t need milliseconds. Math.floor(seconds) removes the decimal point and everything after it.

The next few lines use the total number of seconds to determine the number of hours and minutes. Those values are set to the appropriate members of the parts object. These calculations are the reverse of what we used in secondsFromTimeParts. We know there are 3600 seconds in an hour so we divide the total seconds by it. We use the modulus–% 24–because anything over 24 hours would be a new time part: days. This script doesn’t handle days, but with a handful of additions it would be able to. We determine the number of minutes by dividing the total seconds by the number of seconds in a minute: 60.

That gives us an object of parts–{h: 1, m: 32, s: 27}–that we need to convert into a time string. We’ll do that with the timeStringFromParts function.

function timeStringFromParts (parts) {
  var str = '';

  for (key in parts) {
    if (parts[key] > 0) {
      str += parts[key] + key;
    }
  }

  return str;
}

This small function iterates over the keys in the parts object. For each key, if the value is greater than zero we add the value followed by the key to the str variable that we’ll return. The string returned will look something like “1h32m28s”.

secondsFromTimeParts and timeStringFromParts could have been combine into a single function like timeStringFromSeconds. I chose to separate them because I like to make functions that are as small and as specific as possible. Doing so provides clarity and allows for reuse. This approach is a tenet of functional programming.

And with that, we have our script soup to nuts. If you take a look at the source code you’ll see a function named displayTimeURL. That function converts a time to a time string for display. It also contains a couple other tidbits that may be useful if you need to build something like this yourself.

Bonus: Soundcloud

Soundcloud allows for embedding of most of their tracks. The embedded player also has a pretty cool API. I whipped up a version of this script to work with Soundcloud players. I’d say it’s about 98% the same. All the little functions I wrote aren’t concerned with the whole story. They just want numbers, strings, arrays, etc. Because of that I was able to use them with the Soundcloud version. Have a look at the demo. Like the HTML demo, you can add "?t=1m35s" to the URL to jump to that point in the track.

The only differences are how you interact with the media element and that Soundcloud takes milliseconds instead of seconds to set the current time. Soundcloud provides events and methods that you can use to control the player. They aren’t the same as their HTML counterparts, but the ideas are the same. The Soundcloud example code is in the same repo as the HTML version.

This was a fun problem to solve. Both from the UX view and from the code. I’m sure there are quite a few similar implementations, but I’m sure there are none just like this one.

Thanks for reading