Revisiting a Web Audio Player I Built 10 Years Ago
While cleaning up an old server, I found the source code for a music player I built 10 years ago. It was a preview player for an anime ending theme — copyrighted material — so I put a lot of effort into protecting the audio.
Looking back at the old code, I’m amazed at how complex it was. Things I can do in one line today took 200+ lines back then.
The Architecture Back Then
Four files.
index.html— jQuery 1.8.3-based UIapi.php— Path obfuscation + one-time token generationaudio.php— Token validation + iOS Range Request handlingaudio.js— Blob URL conversion + all event monitoring + fallback handling
Problems I Had to Solve
1. Preventing Direct Links
To stop people from accessing the audio URL directly, I used an API to issue one-time tokens.
function get_unique_id($t)
{
$pin = /* generate 32 random chars */;
$str = $pin.'_'.$t;
return 'r='.$pin.'&u='.hash_hmac('sha256',$str,false).'&t='.$t;
}
On top of that, the path was split into an ASCII code array.
function splitPath($path, $t)
{
$data = array('a'=>array(), 'b'=>array());
$strs = preg_split("//u", $path, -1, PREG_SPLIT_NO_EMPTY);
foreach($strs as $key => $val)
{
$data['a'][] = ord($val); // convert each character to ASCII code
}
// ...
return $data;
}
The JS side reconstructed it before accessing.
// Reconstruct string from ASCII code array
var d3 = '';
for(var i = 0; i < d1.length; i++){
d3 += String.fromCharCode(d1[i]);
}
One look in DevTools and it’s obvious, but at the time this was considered “sufficient protection.”
2. Preventing Downloads
Audio was fetched via XHR as binary and converted to a Blob URL.
var xhr = new XMLHttpRequest();
xhr.responseType = 'arraybuffer';
xhr.onload = function() {
var blob = new Blob([new Uint8Array(this.response)], {'type':'audio/mp3'});
var url = URL.createObjectURL(blob);
audio.src = url; // blob://... format URL
};
This way, the original URL doesn’t appear in browser history or cache. When the page is closed, the Blob URL is invalidated.
3. Playback Failing on iOS
This was the hardest part. iOS Safari at the time always sent a Range Request when playing audio.
GET /audio.php HTTP/1.1
Range: bytes=0-
Returning all data with 200 OK caused playback to silently fail. The response had to be 206 Partial Content.
if(isset($_SERVER['HTTP_RANGE']))
{
list($dummy, $range) = explode('=', $_SERVER['HTTP_RANGE']);
list($start, $end) = explode('-', $range);
if(empty($end)){ $end = filesize($music_path) - 1; }
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
header('Accept-Ranges: bytes');
header('Content-Length: ' . $length);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . filesize($music_path));
$handle = fopen($music_path, 'rb');
fseek($handle, $start);
echo fread($handle, $length);
fclose($handle);
}
The catch: downloader tools also use Range Requests for segmented downloads. So I added a branch to reject Range Requests that lacked a specific parameter.
4. Browser Inconsistencies
The Audio element behaved differently across browsers. I ended up monitoring every event to debug.
$(this.audio).on({
'loadstart': function(){ consoleLine('loadstart'); },
'progress': function(){ consoleLine('progress'); },
'suspend': function(){ consoleLine('suspend'); },
'abort': function(){ consoleLine('abort'); },
'error': function(){ consoleLine('error'); },
'emptied': function(){ consoleLine('emptied'); },
'stalled': function(){ consoleLine('stalled'); },
'loadedmetadata': function(){ consoleLine('loadedmetadata'); },
'loadeddata': function(){ consoleLine('loadeddata'); },
'canplay': function(){ consoleLine('canplay'); },
'canplaythrough': function(){ consoleLine('canplaythrough'); },
// ... many more
});
How I’d Write It Today
For a basic audio player, this is all you need.
<audio controls src="/audio/song.mp3"></audio>
For custom UI, something like this:
const audio = document.querySelector('audio');
const btn = document.querySelector('#playBtn');
btn.addEventListener('click', () => {
audio.paused ? audio.play() : audio.pause();
});
What About the iOS Range Request Issue?
Web servers handle it automatically. nginx, Apache, Vercel, Cloudflare — all of them handle Range Requests correctly for static files. No need to implement it in PHP anymore.
What If Protection Is Still Needed?
Honestly, client-side protection has limits. DevTools exposes everything anyway.
Modern approaches:
- Signed URLs — CloudFront Signed URLs etc., valid for a limited time
- HLS/DASH streaming — Deliver audio in fragments; can be combined with DRM
- Just serve a short preview — Accept the tradeoff and deliver 30 seconds
10 years ago vs. today:
| Item | 10 Years Ago | Today |
|---|---|---|
| Basic playback | jQuery + complex JS | <audio controls> |
| iOS support | Range Request implemented in PHP | Server handles it automatically |
| Direct link prevention | Token + obfuscation | Signed URL or give up |
| Custom UI | A real hassle | Easy with plain JS |
Browser standardization improved, server-side handling became automatic. Web development got a lot easier.
And honestly, for copyrighted music previews these days — just embed YouTube. Copyright management is on YouTube’s side, and you can even monetize. Building your own audio hosting only makes sense for podcasts or doujin audio.