THU.JUN.18
2026
23:38:47
← back to modules MODULE · 04 · FINISH THE SITE
0 / 10 chapters complete · 0%

Real Music Playback — The Audio Player

Remember building the HomeStream player in Part 3? Same trick, smaller scope. By the end the Music tab's playlist is genuinely clickable — tap a track, it plays; the progress bar moves; prev/next/play-pause work; and when a song ends, the next one starts on its own.
One real <audio> element does all the hard work. We feed it file paths from the playlist via data-* attributes, wire the control glyphs to play()/pause(), listen to timeupdate for the progress bar, and use the ended event to auto-advance — the exact pattern from the HomeStream chapter.

HTML5 does the heavy lifting (again)

Just like with video in Part 3, the temptation is to think an audio player is a huge undertaking — decoding, buffering, a scrubber, time display. It's not, because the browser already built all of that. We hand it a single <audio> element and a source URL, and we get playback, buffering, and seeking for free. Our entire job is plumbing: tell it which file to play, and react to a few events it fires.

The Music tab already has the pretty shell — the "now playing" card, the progress bar, the ⏮ ▶ ⏭ glyphs, the playlist rows. Before this chapter those were pure decoration. Now there's one real, invisible <audio id="player"> element added to the card, and app.js connects the decoration to it.

The playlist as data — bridging PHP and JavaScript

Here's the key idea that makes server-rendered data usable by client-side code: data attributes. In index.php, the playlist is a PHP array, and each row is rendered with the file path baked into the HTML as data- attributes:

<li data-idx="0"
    data-src="assets/audio/plastic-love.mp3"
    data-title="Plastic Love"
    data-artist="Mariya Takeuchi"
    role="button" tabindex="0">
  ...
</li>

The browser ignores data-* attributes for display — they're a sanctioned place to stash custom data on an element. Then JavaScript reads them with row.getAttribute('data-src'). That's the whole bridge: PHP writes the data into the HTML at render time, JS reads it back out at click time. No API, no JSON endpoint, no fetch — for a handful of tracks, attributes are perfect.

🐍 Python brain: this is like rendering a Jinja2 template where you embed values into data- attributes, then a bit of JS reads them. The server is the source of truth; the client just reacts to what the server already printed.

The player controller

The player module in app.js is built around one function — load(i, autoplay) — that switches to track number i:

function load(i, autoplay) {
    if (i < 0 || i >= rows.length) return;
    current = i;
    var row = rows[i];
    audio.src = row.getAttribute('data-src');         // point at the file
    titleEl.textContent = row.getAttribute('data-title');
    metaEl.textContent  = row.getAttribute('data-artist');
    rows.forEach(function (r) { r.classList.remove('playing'); });
    row.classList.add('playing');                     // highlight active row
    if (autoplay) audio.play();
}

Everything else hangs off events the <audio> element fires on its own. We don't poll it; we subscribe:

  • play / pause → swap the big glyph between ▶ and ⏸.
  • timeupdate → fires a few times a second while playing; we compute currentTime / duration and set the progress bar width, and update the "01:23 / 04:55" readout.
  • ended → load the next track and autoplay it. That's the auto-advance, straight out of the HomeStream player chapter.
  • error → if the file's missing, show a friendly "track not found" note instead of failing silently.

Clicking a playlist row calls load(i, true). The ⏮/⏭ buttons call load with the previous/next index (wrapping around the ends). The ▶ button toggles play()/pause(), or starts the first track if nothing's loaded yet. Notice how much falls out of one load() function plus a few event listeners — that's the browser doing the work and us just conducting.

Where does the actual music live?

The player points at files in assets/audio/. The repo ships with that folder and a README but no audio — partly because audio files are big and bloat a git repo, but mostly because shipping someone else's music in a tutorial repo is a copyright mess we're not going to make. So you bring your own: drop an MP3 into assets/audio/ named to match a track (e.g. plastic-love.mp3), and that row springs to life.

Until you add a file, the player is still fully wired — controls respond, rows highlight — it just can't play a file that isn't there, so it shows the "track not found" hint. That graceful-degradation touch (handle the missing file instead of throwing) is what separates a demo from something that doesn't embarrass you when the data's incomplete.

Browsers block autoplay with sound until the user interacts with the page — that's a feature, not a bug (nobody wants surprise audio). Because our playback always starts from a click, we're fine. That's also why we wrap audio.play() in a .catch() that quietly ignores the rejection.

  1. Drop any MP3 into assets/audio/ and rename it plastic-love.mp3 (or edit the $tracks array in index.php to match a file you already have).
  2. Open the MUSIC tab. Click the "Plastic Love" row. It should start playing, the row highlights, the title updates, and the progress bar starts crawling.
  3. Hit ⏭ to skip, ⏮ to go back, the big ▶/⏸ to toggle.
  4. Let a track play to the very end — it should auto-advance to the next one.
Clicking a row plays it, the progress bar moves, the controls work, and tracks auto-advance. If you added no file, you should see "track not found — add an MP3 in assets/audio/" and no console errors — the missing-file path is handled. If clicks do nothing at all, check that each <li> has a data-src and that app.js is loading.

▣ Mini Project: Own the Player

The player works, so let's make it yours by extending it. This is the chapter where the dashboard stops being something you read about and becomes something you actually use while you study — your own little focus playlist, running in the same tab as your lessons.

  1. Add three real tracks. For each: drop the file in assets/audio/, then add a row to the $tracks array in index.php: ['Title', 'Artist', 'assets/audio/title.mp3'],. Reload — the playlist grows automatically because it's rendered from the array.
  2. Add keyboard shortcuts in app.js: listen for keydown on document, and map space to play/pause, J/L to prev/next (skip it if the user's typing in an input).
  3. Make the progress bar seekable: add a click handler on .np-bar that reads where you clicked as a fraction of its width and sets audio.currentTime = fraction * audio.duration.

Stretch goals:

  • Add a shuffle toggle that picks a random next index instead of the next one in order.
  • Persist "now playing" across tab switches by remembering the index in sessionStorage.
  • Foreshadow chapter 7: imagine the playlist coming from a database table instead of a hardcoded array. What would have to change? (Answer: almost nothing in app.js — only how index.php builds the rows. That's the payoff of the data-attribute bridge.)

What you flexed: the HTML5 <audio> element, the data-* bridge from server to client, event-driven programming (reacting to timeupdate/ended instead of polling), graceful handling of missing files, and a control scheme built from one load() function. You've now done audio in this dashboard and video in HomeStream — the pattern is officially in your hands.