Mixtapes used to be simple: record, rewind, scribble on the label. But it’s 2025, and cassette players are extinct. That didn’t stop me from making one.
This is just a story about a personal project — built with plain HTML, pico.css, and some JavaScript. Because I liked someone, and it didn’t work out. I was listening to songs, slowly piecing together the soundtrack of what I was feeling.
And then I thought:
What if I made a mixtape?
But who has a tape deck nowadays — or even a CD player?
Not in people’s cars. Not in living rooms. Not on desks.
The era of handing someone a mixtape and knowing they could pop it in? Long gone.
So when the idea hit me, I had to ask:
What is a mixtape in 2025?
Not a Spotify or YouTube playlist. Not just a bunch of links to a streaming platform. I don't even have Spotify. It had to be something physical. Something offline. Something curated.
A USB stick felt like the only option left.
🎨 Artwork That Followed the Feeling
Once the songs were in order, it became clear that this wasn’t just a collection of tracks. There was structure, a flow. Each side had an arc. It became two themes. Something to help frame what the listener was about to experience.
And that meant the tape needed visuals too. It wasn’t just audio anymore. Before writing any HTML, I played around with creating a more physical manifestation in addition to the USB disk. The goal was to make it feel like an actual tape, not just a folder of files, but something intentional. A USB stick with a card attached, with the playlist in hard copy.
🔧 From Playlist to Project
The playlist wasn’t meant to live in a browser. Not at first.
I kept reshuffling tracks, changing the order, cutting songs from the night before, after listening in the car. AI wasn’t helping — it kept messing up my hard copy designs, even in fresh chats with no memory of what came before.
Making the playlist in HTML felt like the easiest fix. I’d been playing around with pico.css the week before, so I dropped it in via a CDN (it now lives on the USB device btw) and started with the playlist.
I wanted something that looked clean: video, audio, and a YouTube link. And maybe some artwork, ffmpegthumbnailer
isn't unknown to me.
To keep things clean, I made a custom Web Component:
<track-item
song="My song"
artist="Some artist"
youtube="https://youtube.com/somesong"
track="A01-Some_song_-_Artist-Name"
></track-item>
This gave me a reusable way to display each track. Less HTML, more flexibility. I didn’t want to modify every entry in case I needed to change the layout. In hindsight, this was an excellent choice. I've made several iterations on how to display the songs. I only had to modify the web component JavaScript code when required.
class TrackItem extends HTMLElement {
connectedCallback() {
const song = this.getAttribute('song') || 'Unknown Title';
const artist = this.getAttribute('artist') || 'Unknown Artist';
const youtube = this.getAttribute('youtube') || '#';
const track = this.getAttribute('track') || '#';
const container = document.createElement('div');
container.className = 'track-row';
container.innerHTML = `
<img src="../art/${track}.jpg" alt="Track artwork">
<div class="track-info">
<div><strong>${song}</strong> – ${artist}</div>
<div>
<play-track video src="../video/${track}.mp4">Video</play-track> |
<play-track audio src="../audio/${track}.mp3">Audio</play-track> |
<a href="${youtube}" target="_blank">YouTube</a>
</div>
</div>
`;
this.appendChild(container);
}
}
customElements.define('track-item', TrackItem);
While building the playlist, I added a block with a short message. The offline design I’d made earlier became the artwork shown before the playlist. I even added some .m3u
files — a poor man’s fix to make Play All feel like hitting a button on a tape deck.
It started to take shape. Without really planning for it, I was building an HTML mixtape.
📼 Offline Playback Isn’t Automatic
At first, I just used <a href>
to link directly to the audio and video files. Worked fine — or so I thought. But as soon as I disconnected from the network, nothing played.
Browsers can play audio and video inline, but without a web server to provide proper MIME types, it falls apart offline. The browser doesn’t know what to do. I’m guessing that’s the problem, anyway. The behavior fits, and Googling didn’t help — the terms are too vague to pin it down, and I wasn’t about to read the browser’s source code. The fix was to embed actual media elements with these two lines:
<audio id="audioPlayer" controls></audio>
<video id="videoPlayer" controls></video>
I handled everything with JavaScript: loading the correct file and stopping one before playing the next. That way, switching between audio and video didn’t leave autoplay quirks. This is where <play-track>
came from. Initially, I had two different ways of triggering playback — one for individual tracks, another for Play All. Just a couple of click handlers, patched into separate bits of player logic. It worked, but it wasn’t DRY.
I’d been considering turning it into a web component from the beginning, but at the time, it felt like overkill — especially since Play All didn’t even exist yet.
Eventually, it made more sense to unify things — one consistent logic layer, no duplicated JS. So that little event handler grew up and became a component:
class PlayTrack extends HTMLElement {
connectedCallback() {
this.style.cursor = 'pointer';
this.addEventListener('click', () => {
const src = this.getAttribute('src');
if (!src) return;
const type = this.hasAttribute('video') ? 'video' :
this.hasAttribute('audio') ? 'audio' :
src.endsWith('.mp4') ? 'video' : 'audio';
const parent = this.closest('track-item');
mediaQueue = [{
src,
title: parent.getAttribute('song') || 'Unknown Title',
artist: parent.getAttribute('artist') || 'Unknown Artist'
}];
currentIndex = 0;
currentType=type;
playCurrent();
});
}
}
customElements.define('play-track', PlayTrack);
With that working, I could play songs offline. And if you look closely, the player even shows the title and artist. A tiny bonus I added last-minute.
Once offline playback was sorted, I wanted to replace the .m3u
files with a proper Play button. Most people have no idea what a .m3u
file is, and I didn’t want to rely on external formats now that I had a built-in player. This was going to run fully in the browser.
So I added:
<queue-playlist type="video">▶ Play All Video</queue-playlist> |
<queue-playlist type="audio">▶ Play All Audio</queue-playlist>
Each one finds all <play-track>
s in its playlist section, pulls their track attributes, builds file paths, and starts playing them in order using the central player.
class QueuePlaylist extends HTMLElement {
connectedCallback() {
this.style.cursor = 'pointer';
this.style.textDecoration = 'underline';
this.addEventListener('click', () => {
const type = this.getAttribute('type');
if (!type || (type !== 'audio' && type !== 'video')) return;
mediaQueue = Array.from(document.querySelectorAll('track-item'))
.map(trackInfo => {
const play = trackInfo.querySelector(`play-track[${type}]`);
if (!play || !play.hasAttribute('src')) {
return null;
}
return {
src: play.getAttribute('src'),
title: trackInfo.getAttribute('song') || 'Unknown Title',
artist: trackInfo.getAttribute('artist') || 'Unknown Artist'
};
})
.filter(Boolean);
currentIndex = 0;
currentType = type;
playCurrent();
});
}
}
customElements.define('queue-playlist', QueuePlaylist);
So yeah — it plays. Offline, in the browser, with a bit of JavaScript and no dependencies.
But I didn’t want it to open straight into a player. That felt too abrupt. There needed to be a pause — something to land on before diving in.
So I created a minimal landing page: a cassette image, centered, styled to echo the tone of the artwork. A visual cue to set the mood before the music starts. Click to play the mixtape.
🔍 The hidden tracks
I spent real time writing the note that lived inside the <details>
block — not just filler, but something meant to be read. But it didn't need to draw attention — just a folded message, waiting.
That quiet mechanic — something you could ignore, or open — sparked an idea:
What else could be hidden?
Not every song made the final cut. Some were too much. Others didn’t fit the flow. But I didn’t want to lose them. Some never made it. Others just didn’t belong on either side — but still mattered. So I made space — just not upfront.
They’re tucked inside a <section>
marked hidden, styled the same as the rest, and picked up by the same Play All logic. Fully part of the tape — just not immediately visible. To reveal them? Click the artwork. Then click it again. Five times in a row, and the hidden tracks appear. Why five? No reason. Android uses six taps to unlock developer mode. Five felt obscure enough.
🔊 Gentlemen, you cannot shout in the war room!
How I Learned to Conquer Codecs and Remove the Silence
I wanted the media to work everywhere — in any browser, on any device. The original <a href>
setup worked fine: the files opened, and the browser played them. But once I switched to using native media elements, things started breaking. Some videos wouldn’t play. So I re-encoded all the .webm
files to .mp4
using H.264 for video and AAC for audio — the safest bet for compatibility:
ffmpeg -i "$i" -c:v libx264 -preset slow -crf 23 -c:a aac -b:a 192k "$(basename "$i" .webm).mp4"
After this, I created .mp3 versions too:
ffmpeg -i "$mp4" -c:a libmp3lame -q:a 2 "$mp3"
Fast-forward to today. Once the mixtape was fully working, I turned to one last polish pass: trimming silence from a few of the tracks. Some had a lot of silence from credits and extended intros. I needed to remove those silences. So I wrote silencio.sh
, a zsh script that deals with silence detection. The main tools are ffmpeg and ffprobe.
First, we do silence detection and clean up the output a bit:
typeset -a silence_lines;
silence_lines=("${(@f)$(ffmpeg -hide_banner -i "$input_file" -af silencedetect=noise=-50dB:d=0.5 -f null - 2>&1 | grep -v 'size=N/A' | grep silence_ | sed -e 's/\[silencedetect @ .*] //')}")
The (${(@f)$(mycommand)})
ensures that we split lines on the new line character in zsh without having to mess with IFS.
ffmpeg isn’t always consistent: sometimes you only get a silence_end
and a silence_duration
, no matching silence_start
. That throws off the logic. You’ll need to calculate the missing start based on the end and duration. Then, some files don't have silences, but the array isn't empty; it just contains an empty element. So check accordingly.
The logic was pretty simple:
- If we have a silence_lines which aren't mod 2, we know we are missing the silence_start. So we calculate those based on silence_end and silence_duration.
- If silence_start is 0, we know the first silence is at the beginning and we can trim the start.
- If the silence_end is at the end of the file we know that we can trim from the that silence_start segment. To get the duration we need ffprobe:
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$input_file")
- If silence_lines has two elements we know we have a silence at the beginning or end of the file (potentially in the middle, but we skip those).
- Else we know the first two are most likely at the beginning and the last two at the end.
- Allow for 0.5 seconds of silence on either side to allow for fades or breathing space without the track feeling chopped.
I re-encoded the output to avoid ffmpeg warnings. For previews, I used -c copy
:
reencode="-c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 192k"
eval ffmpeg -hide_banner -ss "$strip_begin" -to "$strip_end" -i "$mp4" $reencode -avoid_negative_ts make_zero "$out_mp4"
and re-encode the .mp3
from our new .mp4
:
ffmpeg -i "$out_mp4" -c:a libmp3lame -q:a 2 "$out_mp3"
One file I had to manually process because it had an interview in the beginning and a talkative DJ at the end as well. Don't take your timings from your preview file. I accidentally cut the song short because I inspected the timing of the wrong file.
Done. Tag it and bag it.
⛵ Float your boat:
Some shells don't like floats, but zsh does, so I don't really need this. However, they are still nice to know. My friend bc can tell you if a float is smaller than some value:
echo "2 -1 < 1" | bc -l # 0
echo "2 - 1 < 2" | bc -l # 1
I could just add two floats with zsh by doing $(( 0.5 - 0.25 ))
, but with bc you need to account for a quirk: echo "0.5 - 0.25"| bc -l
yields .25 and not 0.25.
🪓 When Tech Fights Back
I’d been developing everything in Firefox. Smooth sailing. No red flags. Then it hit me: they have an iPhone, probably immersed in the Apple ecosystem. I don’t have a Mac, but I do have Chrome — time to test this thing in a different browser.
And then things broke. Audio controls vanished. Custom elements misbehaved. Stuff that looked fine before just fell apart. Here’s what broke — and how I tried to fix it.
🎛️ Audio Controls Vanished in Chrome
In Firefox, everything looked perfect. The player showed up just fine — controls and all. But in Chrome? Nothing. Just a blank box and my custom buttons. No player UI.
I assumed it was a timing glitch. Tried toggling display, moved code to a DOMContentLoaded
handler — still nothing.
Turns out Chrome likes to fade in media controls. I only noticed this after looking at why <video>
was working. You see the video without controls, then you hover the mouse over it, and — poof, controls. But if the player has no width? There's nothing to fade in. 💡 Turns out width: 100% on a parent with no width is still zero. So Chrome had no room to draw anything. Setting width: 250px
was enough to bring the controls back from the void. Maybe min-width
works too — didn’t check.
I want that hour back.
🐞 The <ul><li>
Fail
Initially, I used a <ul>
and had each <track-item>
generate a <li>
inside. So the structure looked like ul > li > div > audio
, etc. But it turns out browsers really don’t like it when you insert non-<li>
tags inside or after a <ul>
. My <track-item>
elements came after, and things fell apart — semantically and visually. It got messy. One workaround was to remove the <li>
from the web component and insert it manually inside the <ul>
. But at that point, I realized I didn’t need a <ul>
at all.
So I dropped it. Wrapped everything in a <playlist>
tag instead: no nesting quirks, no semantic weirdness. Then I updated the CSS to match.
🐉 Web Component Syntax Fail
Here be dragons. This was the one that nearly broke me — aside from the reason I was making this mixtape in the first place.
While fixing Chrome’s audio control bug, I moved some of the player logic into a DOMContentLoaded
event handler. That’s when my playlists started falling apart.
Suddenly, only one <track-item>
showed up per playlist. I suspected a browser rendering issue with the <ul><li>
element, as the web component inserted it, making <ul><track-item>
invalid. The browser shows only one item and ignores the rest. But fixing it didn't resolve it. I tried injecting items with appendChild. Now the order was reversed. It didn't make sense until I looked at the DOM. I had nested <track-item>
tags:
<track-item ...>
<track-item ...>
<track-item ...>
</track-item>
</track-item>
</track-item>
What? Why was everything nesting?
The culprit?
<track-item .../>
Custom elements must not use self-closing syntax. They nest (with appendChild) and break everything. Use:
<track-item ...></track-item>
One character drove me to the brink of insanity.
🔌 Plug and... Pray
I dumped everything onto the USB and gave it a test run.
My Android phone flat-out refused. I plugged in the USB, got to the landing page — but no images loaded. Clicked to go to the next screen? Nothing. Error galore.
Android’s file access model doesn’t allow the browser to access multiple files from a USB stick. It opens the HTML file, but that file can’t reference the others.
iOS? Same deal, or worse, if I understood correctly. They have an iPhone, so out of luck there.
We have cars with USB ports. My car? Completely random playback order. I named them A01-..., A02-..., B01-..., etc. Alphabetical should work, right? RIGHT? A03, B06, A02, etc. Random order, zero logic.
If it had played alphabetically, the whole arc would've been intact.
Instead: Chaos, courtesy of your local infotainment system.
🛠️ Built to Break
And that was the build. A browser-based, offline-ready, USB-delivered, heartbreak mixtape. Built with plain HTML, pico.css, JavaScript, and feelings.
Some things didn’t work, some things worked better than expected. It was a fun little project. I learned some new things about HTML, Web Components, and CSS. But mostly, I learned that building something — even something small—can say what words can’t.
Because sometimes you need to say something.
Even if the browser doesn’t cooperate.
Even if the tech fights back.
Even if no one ever hits play.
🤖 AI disclosure
This post was proofread with the assistance of AI (ChatGPT) to improve structure and flow, and Grammarly was used for spellchecking and grammar. In some ways, the process echoed the dynamic between William Forrester and Jamal Wallace in Finding Forrester—the AI initiated the paragraph flow, and I carried it forward, discovering the words as I went.
Top comments (0)