Skip to main content

Building a Lo-Fi Audio Player with Custom SVG Controls

A simple lo-fi stream player embedded on a site — no third-party widget, no branding, no <iframe> from a service that might go away or plaster ads over it. Just a clean play/pause button, a volume control, and a live indicator that feels like it belongs on the page.

Seemed straightforward. It mostly was, except for a few genuinely annoying edge cases that cost me more time than they should have.

Why Build It Yourself?

The obvious answer is a YouTube embed or a SoundCloud widget would work faster. Both would work, but they come with baggage: branding, autoplay restrictions tied to their own logic, layout constraints, and a dependency on a third-party service staying alive and keeping its embed API stable.

For a lo-fi stream specifically, I was pulling from a public Icecast stream URL — a direct audio stream endpoint, not a service with an embed API. The <audio> element handles this natively. There's no reason to reach for a library.

The tradeoff is that you're responsible for the controls. The browser's default audio controls are functional but ugly and inconsistent across browsers. Building custom controls means you get exactly the UI you want — but you have to handle state, icons, and a few browser quirks yourself.

How the HTML5 Audio Element Handles Streams

Before getting into the code, it's worth understanding how <audio> treats a live stream differently from a regular audio file.

With a file (song.mp3), the browser downloads it progressively and builds a seekable buffer. You can scrub, see duration, and pick up where you left off after pausing.

With a live stream, none of that applies. The stream has no defined length, no seekable buffer, and no concept of position. When you "pause" a stream and then hit play again, you're not resuming — you're reconnecting and picking up from wherever the stream currently is. Practically, this means:

  • No seek bar. There's nothing to seek through.
  • No duration. audio.duration returns Infinity for most streams.
  • Pause means disconnect. The stream keeps broadcasting while you're paused; you just rejoin it live when you hit play again.

This shapes the UI: a seek bar would be meaningless here. The right controls are play/pause, volume, and a "LIVE" indicator that communicates the real-time nature of the source.

The preload="none" attribute is important too. Without it, some browsers will start buffering the stream immediately on page load — not great for bandwidth or initial page performance. Setting preload="none" means nothing happens until the user (or your autoplay logic) calls play().

<audio id="stream" src="https://your-icecast-stream-url/stream" preload="none"></audio>

The Player Structure

The HTML is deliberately minimal. The buttons start empty — SVG icons are injected by JavaScript on initialization, which keeps all icon state management in one place rather than split between markup and script.

<div class="player">
  <button id="play-btn" aria-label="Play/Pause"></button>
  <div class="volume-controls">
    <button id="mute-btn" aria-label="Mute/Unmute"></button>
    <input type="range" id="volume" min="0" max="1" step="0.05" value="0.5">
  </div>
  <span class="live-badge"><span class="live-dot">●</span> LIVE</span>
</div>

<audio id="stream" src="https://your-icecast-stream-url/stream" preload="none"></audio>

The aria-label attributes on the buttons matter here. Since the buttons have no visible text — just icons — screen readers need something to announce. Don't skip these.

Styling: Dark Theme and the LIVE Badge

Most of the CSS is straightforward layout work, but the LIVE badge is worth calling out because the pulsing dot is the detail that makes the whole thing feel alive.

.player {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  background: #1a1a1a;
  padding: 12px 16px;
  border-radius: 8px;
  color: #fff;
  font-family: sans-serif;
}

.volume-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

button {
  background: none;
  border: none;
  color: #fff;
  cursor: pointer;
  padding: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
}

button:hover { opacity: 0.7; }

input[type="range"] {
  -webkit-appearance: none;
  width: 80px;
  height: 4px;
  background: #444;
  border-radius: 2px;
  outline: none;
  cursor: pointer;
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 12px;
  height: 12px;
  background: #fff;
  border-radius: 50%;
}

.live-badge {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.08em;
  color: #ff4444;
  display: flex;
  align-items: center;
  gap: 4px;
}

.live-dot {
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.2; }
}

The pulse animation is a simple opacity keyframe on the dot character. It reads immediately as "this is live" — which is the one thing the UI needs to communicate that a normal player doesn't.

The range input styling deserves a mention too. The default browser range input looks different on every platform, so you need -webkit-appearance: none and explicit styling for the thumb and track. The above covers Chrome, Safari, and Firefox (Firefox uses ::-moz-range-thumb if you want full cross-browser coverage, though it degrades gracefully without it).

The JavaScript

Storing SVGs as Strings

Storing SVG markup as template literal strings and swapping with innerHTML is the cleanest approach when you control the markup.

Using fill="currentColor" in each SVG means the icons inherit color from the CSS color property on the button. Hover states, active states, theme changes; set color in CSS and the icon follows automatically.

const ICONS = {
  play: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
         fill="currentColor" width="22" height="22">
    <polygon points="5,3 19,12 5,21"/>
  </svg>`,

  pause: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
          fill="currentColor" width="22" height="22">
    <rect x="6" y="4" width="4" height="16"/>
    <rect x="14" y="4" width="4" height="16"/>
  </svg>`,

  volume: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
           fill="currentColor" width="20" height="20">
    <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05
             c1.48-.73 2.5-2.25 2.5-4.02z"/>
  </svg>`,

  mute: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
         fill="currentColor" width="20" height="20">
    <path d="M16.5 12A4.5 4.5 0 0 0 14 7.97v2.21l2.45 2.45
             c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51
             A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06
             c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73
             l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06A8.99 8.99 0 0 0
             19.73 18L21 19.27 19.73 21l-16-16L4.27 3zM12 4L9.91 6.09 12
             8.18V4z"/>
  </svg>`
};

State and Event Wiring

Keep the audio element reference and all state variables declared once, at the top of scope. This is the mistake that caused my duplicate variable bug (more on that in a moment) and it's easy to avoid with a little discipline.

const audio = document.getElementById('stream');
const playBtn = document.getElementById('play-btn');
const muteBtn = document.getElementById('mute-btn');
const volumeSlider = document.getElementById('volume');

let playing = false;
let muted = false;

// Set initial icons on load
playBtn.innerHTML = ICONS.play;
muteBtn.innerHTML = ICONS.volume;

// Play / Pause toggle
playBtn.addEventListener('click', () => {
  if (playing) {
    audio.pause();
    playBtn.innerHTML = ICONS.play;
    playing = false;
  } else {
    audio.play().catch(() => {
      playing = false;
      playBtn.innerHTML = ICONS.play;
    });
    playBtn.innerHTML = ICONS.pause;
    playing = true;
  }
});

// Mute / Unmute toggle
muteBtn.addEventListener('click', () => {
  muted = !muted;
  audio.muted = muted;
  muteBtn.innerHTML = muted ? ICONS.mute : ICONS.volume;
});

// Volume slider — auto-unmute if user drags up while muted
volumeSlider.addEventListener('input', () => {
  audio.volume = volumeSlider.value;
  if (muted && volumeSlider.value > 0) {
    muted = false;
    audio.muted = false;
    muteBtn.innerHTML = ICONS.volume;
  }
});

One small quality-of-life detail: if the user drags the volume slider up while muted, automatically unmute. It's what you'd expect a real player to do, and it's a one-liner to add.


Problem 1: iOS Safari Turns Unicode Characters Into Emoji

My first pass used Unicode characters for the play and pause icons — &#9654; (▶) and &#9208; (⏸). Two characters with no SVG overhead and you get instantly recognizable icons. Worked fine on desktop Chrome, Firefox, and Safari.

On iOS Safari though, both rendered as full-color emoji, completely ignoring any CSS I applied to them. Font size, color, transforms. All irrelevant. iOS has its own emoji rendering layer that kicks in for certain Unicode characters in the "Miscellaneous Technical" and "Dingbats" blocks, and it overrides the normal text rendering pipeline entirely.

The Unicode Consortium has a mechanism for this: variation selectors. Appending U+FE0F to a character requests emoji presentation. Appending U+FE0E requests text presentation. iOS Safari respects the text variation selector for most characters:

// Force text presentation — no emoji rendering
playBtn.textContent = '\u25B6\uFE0E';  // ▶ as a text glyph
playBtn.textContent = '\u23F8\uFE0E';  // ⏸ as a text glyph

This works. But it's fragile — not every Unicode character has both presentation forms, and browser support for variation selectors isn't perfectly consistent. I moved to inline SVGs for the final version precisely because SVGs have no ambiguity: they always render as vector graphic which are fully styleable with CSS and they look identical everywhere.

Note: this bug is completely invisible until you test on a real iOS device. The Xcode simulator doesn't consistently reproduce it.

Problem 2: Autoplay Is Blocked (But There's a Reliable Workaround)

I wanted the stream to start playing on page load. The use case was a page where the whole point is ambient audio in the background — requiring the user to manually hit play felt like unnecessary friction for something that should just work.

Modern browsers block autoplay with audio by default. This policy rolled out across Chrome, Firefox, and Safari between 2017 and 2019, largely in response to user complaints about unexpected audio on page load. The rule is roughly: audio playback requires a user activation — a click, tap, or keypress somewhere on the page before it can start. Without that activation, audio.play() returns a rejected Promise.

The play() method returning a Promise at all was added specifically to make rejection handleable:

// If you call play() without .catch(), a failed autoplay attempt
// throws an unhandled Promise rejection in the console.
audio.play();

// Handle it properly:
audio.play().then(() => {
  updateUIToPlaying();
}).catch((err) => {
  // DOMException: play() failed because the user didn't interact first
  updateUIToPaused();
});

Handling the rejection is the minimum. But I wanted to actually autoplay. There's a legitimate loophole: browsers permit muted autoplay. The reasoning is that silent autoplay doesn't startle or annoy anyone, so it's allowed without user activation. Once muted playback is underway, you can unmute programmatically.

audio.muted = true;
audio.volume = 0.5;  // Set volume before unmuting

audio.play().then(() => {
  audio.muted = false;  // Unmute — playback continues at audio.volume
  playBtn.innerHTML = ICONS.pause;
  playing = true;
}).catch(() => {
  // Even muted autoplay failed (can happen in strict browser contexts)
  playing = false;
});

The key detail is setting audio.volume before flipping audio.muted back to false. When muted turns off, the audio plays immediately at whatever volume is already set to. The transition is seamless — no jump to full volume, no audible mute-unmute artifact, because the whole swap happens before the first audio frame reaches the speakers.

This works reliably across Chrome, Firefox, Safari desktop, and even iOS Safari.

Note: this works cleanly for live streams because there's no defined beginning. For a standard audio file, the muted-to-unmuted transition at the very start of playback can occasionally produce a faint click on some devices. Test it with your actual source.

Problem 3: Duplicate Variable Declarations Broke Everything

This one's not glamorous, but I'm including it because it's easy to do when you're building iteratively and the failure mode is total — nothing works and the error message isn't immediately obvious about where the problem is.

At some point during development I ended up with const audio declared twice — once at the top of the script, and once inside an event listener I'd added during a later iteration. JavaScript's const doesn't allow redeclaration in the same scope. The script threw a SyntaxError and nothing ran at all.

The error message did say audio was already declared, which pointed me in the right direction, but tracking down which of the two declarations was the "extra" one took a minute.

The fix is boring and obvious in retrospect: declare everything once, at the top.

// All references declared once here
const audio = document.getElementById('stream');
const playBtn = document.getElementById('play-btn');
const muteBtn = document.getElementById('mute-btn');
const volumeSlider = document.getElementById('volume');
let playing = false;
let muted = false;

// Handlers reference them directly — never re-declare inside
playBtn.addEventListener('click', () => {
  // `audio` and `playing` are in scope here
});

If you're prototyping in the browser console and then consolidating into a file, be especially careful. The console treats each execution as a fresh scope, so duplicate const declarations don't throw there. Move to a file and they immediately will.


The Complete Player

Here's the full self-contained version — swap in your stream URL and drop it anywhere:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Lo-Fi Player</title>
  <style>
    .player {
      display: inline-flex; align-items: center; gap: 12px;
      background: #1a1a1a; padding: 12px 16px; border-radius: 8px;
      color: #fff; font-family: sans-serif;
    }
    .volume-controls { display: flex; align-items: center; gap: 8px; }
    button {
      background: none; border: none; color: #fff; cursor: pointer;
      padding: 4px; display: flex; align-items: center; justify-content: center;
    }
    button:hover { opacity: 0.7; }
    input[type="range"] {
      -webkit-appearance: none; width: 80px; height: 4px;
      background: #444; border-radius: 2px; outline: none; cursor: pointer;
    }
    input[type="range"]::-webkit-slider-thumb {
      -webkit-appearance: none; width: 12px; height: 12px;
      background: #fff; border-radius: 50%;
    }
    .live-badge {
      font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
      color: #ff4444; display: flex; align-items: center; gap: 4px;
    }
    .live-dot { animation: pulse 1.5s ease-in-out infinite; }
    @keyframes pulse {
      0%, 100% { opacity: 1; }
      50% { opacity: 0.2; }
    }
  </style>
</head>
<body>

<div class="player">
  <button id="play-btn" aria-label="Play/Pause"></button>
  <div class="volume-controls">
    <button id="mute-btn" aria-label="Mute/Unmute"></button>
    <input type="range" id="volume" min="0" max="1" step="0.05" value="0.5">
  </div>
  <span class="live-badge"><span class="live-dot">●</span> LIVE</span>
</div>

<audio id="stream" src="https://your-icecast-stream-url/stream" preload="none"></audio>

<script>
  const ICONS = {
    play:   `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="22" height="22"><polygon points="5,3 19,12 5,21"/></svg>`,
    pause:  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="22" height="22"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`,
    volume: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>`,
    mute:   `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M16.5 12A4.5 4.5 0 0 0 14 7.97v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06A8.99 8.99 0 0 0 19.73 18L21 19.27 19.73 21l-16-16L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>`
  };

  const audio        = document.getElementById('stream');
  const playBtn      = document.getElementById('play-btn');
  const muteBtn      = document.getElementById('mute-btn');
  const volumeSlider = document.getElementById('volume');

  let playing = false;
  let muted   = false;

  playBtn.innerHTML = ICONS.play;
  muteBtn.innerHTML = ICONS.volume;

  // Muted autoplay — unmute once playback starts
  audio.muted  = true;
  audio.volume = 0.5;
  audio.play().then(() => {
    audio.muted       = false;
    playBtn.innerHTML = ICONS.pause;
    playing           = true;
  }).catch(() => { /* autoplay blocked — user must press play */ });

  playBtn.addEventListener('click', () => {
    if (playing) {
      audio.pause();
      playBtn.innerHTML = ICONS.play;
      playing = false;
    } else {
      audio.play().catch(() => { playing = false; });
      playBtn.innerHTML = ICONS.pause;
      playing = true;
    }
  });

  muteBtn.addEventListener('click', () => {
    muted        = !muted;
    audio.muted  = muted;
    muteBtn.innerHTML = muted ? ICONS.mute : ICONS.volume;
  });

  volumeSlider.addEventListener('input', () => {
    audio.volume = volumeSlider.value;
    if (muted && volumeSlider.value > 0) {
      muted = false; audio.muted = false;
      muteBtn.innerHTML = ICONS.volume;
    }
  });
</script>

</body>
</html>

End Result

Zero dependencies, around 90 lines, works on desktop and mobile. The three problems: emoji rendering on iOS, autoplay blocking, and duplicate declarations. They're exactly the kind of thing that only surfaces when you're working across multiple browsers and building iteratively. Hopefully having them documented in one place saves you some time.

Sometimes the best widget is the one you build yourself.