The Need: a 75-minute file with no way to move around

The audio files in my family book library are long. One of them runs 75 minutes. The mini player I built had a play button, a time label and a speed button. That was all.

If you missed a sentence, you could not go back. If you wanted to skip an intro, you listened to it. On a long file, that is not a small problem.

So the request was simple: add buttons for 10 and 30 seconds back and forward, and let me drag to any point in the file.

The Proof: it works now, after two tries

This is the player after the change: four buttons (−30, −10, +10, +30) and a seek bar under the title. While you drag, the time label shows the new position. When you let go, the audio jumps there.

💬 Prompt that worked “Add buttons to the player to move 10 or 30 seconds forward and back, and let me drag to any point in the playback and play from there.”

The first version did not work on my phone. My follow-up was three words in Korean: “Dragging doesn’t work.” Ten minutes after that, the second version did. Then I wrote: “It was implemented well.”

The Story: it passed on the PC and failed in my hand

The first version was a plain <input type="range">. Buttons worked. Dragging did not, on the phone.

Before touching the code, I checked one thing: does the server allow jumping into the middle of a file at all? An audio element can only seek if the server answers “range” requests (give me bytes 5,000,000 to the end). The route uses Flask’s send_file(..., conditional=True), which handles that. So the server was fine, and the problem was in the page.

I can’t say for sure which of these caused it, so here are the two suspects:

  1. The player fights the finger. While audio plays, the browser fires timeupdate several times a second. My code redrew the bar on every one, setting the bar back to the current playback time. If that happens in the moment between touching the bar and the first input event, the bar jumps away from your finger.
  2. The “finish” event is not reliable on touch. I only jumped to the new position on the change event. On a phone, I think that event can be missed or reset by the redraw. And I had set touch-action: none on the bar, which may also block a sideways drag.

I fixed all of it together, so I can’t say which one mattered most.

The How: four small changes

1. Skip with clamping. One function handles all four buttons:

function skip(sec) {
  if (!audio.duration) return;
  audio.currentTime = Math.min(audio.duration, Math.max(0, audio.currentTime + sec));
}

The buttons carry data-skip="-30", data-skip="10" and so on, so there is one click handler for all of them. The lock-screen “back” and “forward” buttons use the same function (−10 and +30).

2. Freeze the bar the moment a finger touches it.

['pointerdown', 'touchstart', 'mousedown'].forEach((ev) =>
  seekEl.addEventListener(ev, () => { dragging = true; }, { passive: true }));

The redraw code skips the bar while dragging is true.

3. Commit on release, from several events.

const commitSeek = () => {
  if (!dragging) return;
  dragging = false;
  audio.currentTime = Number(seekEl.value);
  save(true);
};
seekEl.addEventListener('change', commitSeek);
['pointerup', 'touchend', 'mouseup', 'pointercancel']
  .forEach((ev) => seekEl.addEventListener(ev, commitSeek));

The dragging check makes sure only the first event does the work. save(true) stores the new listening position right away.

4. Change the touch rule. touch-action: none became touch-action: pan-y, so the bar accepts sideways drags and the page can still scroll up and down.

🗂 Claude.md Rule A seek bar on a page that redraws on timeupdate needs a “dragging” flag set on pointer-down, and it should apply the new position on release from pointerup/touchend, not only change. Always test a slider on a real phone, not only on the PC.

One trap that is not code: the app caches its JavaScript in a service worker. I raised the cache version (v25 to v26), but a phone still shows the old file until the installed app is fully closed and opened again. If a fix “didn’t work”, try this before you change code.

The player is taller now, so I also raised the bottom padding of the page and the toast position. Otherwise the player covers the last lines of content.

Cost: $0. No AI API calls were needed for this. It was a few dozen lines of JavaScript and CSS. I have not tested it on iPhone Safari. I only tried it on my own Android phone.