shift + click to delete track
Hunter
Wednesday, May 6th, 2026 at 12:52 AM
43
44 2. **serve it**
45 - run `./serve.py` to start a local HTTP server for testing. you can scan the QR code printed to your terminal to test the app from any device on your local network.
- - while the server is running, you can drag tracks up or down in the list to reorder them.
46 + - while the server is running, you can drag tracks up or down in the list to reorder them, or `shift + click` to delete them.
47
48 3. **build it**
49 - once you're happy with your mix, run `./build.py` and follow the interactive prompts to generate `manifest.json` and `service-worker.js`, which enable PWA installation and offline functionality.
12 item.addEventListener('mousedown', (e) => {
13 // Ignore non-primary buttons
14 if (e.button !== 0) return;
15 + if (e.shiftKey) {
16 + e.preventDefault();
17 + e.stopPropagation();
18 + item._suppressClick = true;
19 + const t = tracks[index];
20 + if (!t) return;
21 + if (!confirm(`Delete ${t.artist} – ${t.title}?`)) return;
22 + deleteTrack(index);
23 + return;
24 + }
25 startReorder(item, index, e.clientX, e.clientY);
26 });
27
377 }).catch(err => console.error('Failed to save order:', err));
378 }
379
380 +// Shift+click delete
381 +
382 +function deleteTrack(index) {
383 + const removed = tracks[index];
384 + if (!removed) return;
385 +
386 + // Snapshot old positions of every playlist row keyed by filename, so we
387 + // can FLIP-animate the survivors after re-rendering.
388 + const beforeItems = Array.from(playlist.querySelectorAll('.playlist-item'));
389 + const oldTopByFilename = new Map();
390 + beforeItems.forEach((el, i) => {
391 + const t = tracks[i];
392 + if (t) oldTopByFilename.set(t.filename, el.getBoundingClientRect().top);
393 + });
394 +
395 + const wasPlaying = currentTrackIndex === index;
396 +
397 + // Free any preloaded blob for the deleted track
398 + if (preloadedAudio[removed.filename]) {
399 + try { URL.revokeObjectURL(preloadedAudio[removed.filename].blobUrl); } catch (e) {}
400 + delete preloadedAudio[removed.filename];
401 + }
402 +
403 + tracks.splice(index, 1);
404 +
405 + if (wasPlaying) {
406 + try { audio.pause(); } catch (e) {}
407 + audio.removeAttribute('src');
408 + audio.load();
409 + isPlaying = false;
410 + resetProgressBar();
411 + if (tracks.length === 0) {
412 + playerReady = false;
413 + updateCurrentTrackDisplay('No tracks found');
414 + updatePlayPauseButton();
415 + } else {
416 + const nextIndex = Math.min(index, tracks.length - 1);
417 + setCurrentTrackIndex(nextIndex);
418 + const next = tracks[nextIndex];
419 + updateCurrentTrackDisplay(`Ready to play: ${next.artist} – ${next.title}`);
420 + updatePlayPauseButton();
421 + }
422 + } else if (currentTrackIndex > index) {
423 + setCurrentTrackIndex(currentTrackIndex - 1);
424 + }
425 +
426 + renderPlaylist();
427 +
428 + // FLIP: translate each surviving row from its old top to its new top.
429 + const afterItems = playlist.querySelectorAll('.playlist-item');
430 + afterItems.forEach((el, i) => {
431 + const t = tracks[i];
432 + if (!t) return;
433 + const oldTop = oldTopByFilename.get(t.filename);
434 + if (oldTop === undefined) return;
435 + const newTop = el.getBoundingClientRect().top;
436 + const dy = oldTop - newTop;
437 + if (!dy) return;
438 + el.style.transition = 'none';
439 + el.style.transform = `translateY(${dy}px)`;
440 + void el.offsetHeight;
441 + el.style.transition = 'transform 180ms ease';
442 + el.style.transform = '';
443 + const clear = (e) => {
444 + if (e.propertyName !== 'transform') return;
445 + el.style.transition = '';
446 + el.style.transform = '';
447 + el.removeEventListener('transitionend', clear);
448 + };
449 + el.addEventListener('transitionend', clear);
450 + });
451 +
452 + fetch('/tracks/' + encodeURIComponent(removed.filename), { method: 'DELETE' })
453 + .then(r => { if (!r.ok) throw new Error('delete failed: ' + r.status); })
454 + .catch(err => console.error('Failed to delete track:', err));
455 +}
456 +
457 // Live sync over SSE
458
459 // Swap the now-playing text without disturbing an in-flight marquee
14 import threading
15 import subprocess
16 import time
17 +import urllib.parse
18 from pathlib import Path
19
20 DEFAULT_PORT = 8000
21 SCRIPT_DIR = Path(__file__).parent.absolute()
22 VENV_DIR = SCRIPT_DIR / "venv"
-TRACKS_PATH = SCRIPT_DIR / "mix" / "tracks.json"
23 +MIX_DIR = SCRIPT_DIR / "mix"
24 +TRACKS_PATH = MIX_DIR / "tracks.json"
25
26 # Serializes all reads/writes of tracks.json so rescans, reorders, and future
27 # uploads can't race each other.
283 self.send_response(204)
284 self.end_headers()
285
286 + def do_DELETE(self):
287 + # Local-only: remove a track's audio file from /mix and prune
288 + # tracks.json. Path is /tracks/<url-encoded-filename>.
289 + prefix = '/tracks/'
290 + if not self.path.startswith(prefix):
291 + self.send_response(404)
292 + self.end_headers()
293 + return
294 +
295 + filename = urllib.parse.unquote(self.path[len(prefix):])
296 + target = (MIX_DIR / filename).resolve()
297 + try:
298 + target.relative_to(MIX_DIR.resolve())
299 + except ValueError:
300 + self.send_response(400)
301 + self.end_headers()
302 + return
303 + if target == TRACKS_PATH.resolve() or target.name != filename:
304 + self.send_response(400)
305 + self.end_headers()
306 + return
307 + with tracks_lock:
308 + try:
309 + target.unlink()
310 + except FileNotFoundError:
311 + pass
312 + except OSError as e:
313 + self.send_response(500)
314 + self.end_headers()
315 + self.wfile.write(str(e).encode('utf-8'))
316 + return
317 + tracks = [t for t in read_tracks() if t.get('filename') != filename]
318 + TRACKS_PATH.write_text(
319 + json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
320 + encoding='utf-8',
321 + )
322 + broadcast_tracks(tracks)
323 + self.send_response(204)
324 + self.end_headers()
325 +
326 try:
327 with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd:
328 httpd.daemon_threads = True