commit b8e3b7a2c83241f3f5a87cc23d7e41eb4c4d4981
parent fa6978f95e36756bba728e7571fd2a9075cff212
Author: Hunter
Date:   Wed,  6 May 2026 00:52:31 -0400

shift + click to delete track

Diffstat:
Mreadme.md | 2+-
Mresources/dev.js | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserve.py | 44+++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 131 insertions(+), 2 deletions(-)

diff --git a/readme.md b/readme.md @@ -43,7 +43,7 @@ hits different, right?<br><br> 2. **serve it** - 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. + - while the server is running, you can drag tracks up or down in the list to reorder them, or `shift + click` to delete them. 3. **build it** - 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. diff --git a/resources/dev.js b/resources/dev.js @@ -12,6 +12,16 @@ function attachReorderHandlers(item, index) { item.addEventListener('mousedown', (e) => { // Ignore non-primary buttons if (e.button !== 0) return; + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + item._suppressClick = true; + const t = tracks[index]; + if (!t) return; + if (!confirm(`Delete ${t.artist} – ${t.title}?`)) return; + deleteTrack(index); + return; + } startReorder(item, index, e.clientX, e.clientY); }); @@ -367,6 +377,83 @@ function saveTrackOrder() { }).catch(err => console.error('Failed to save order:', err)); } +// Shift+click delete + +function deleteTrack(index) { + const removed = tracks[index]; + if (!removed) return; + + // Snapshot old positions of every playlist row keyed by filename, so we + // can FLIP-animate the survivors after re-rendering. + const beforeItems = Array.from(playlist.querySelectorAll('.playlist-item')); + const oldTopByFilename = new Map(); + beforeItems.forEach((el, i) => { + const t = tracks[i]; + if (t) oldTopByFilename.set(t.filename, el.getBoundingClientRect().top); + }); + + const wasPlaying = currentTrackIndex === index; + + // Free any preloaded blob for the deleted track + if (preloadedAudio[removed.filename]) { + try { URL.revokeObjectURL(preloadedAudio[removed.filename].blobUrl); } catch (e) {} + delete preloadedAudio[removed.filename]; + } + + tracks.splice(index, 1); + + if (wasPlaying) { + try { audio.pause(); } catch (e) {} + audio.removeAttribute('src'); + audio.load(); + isPlaying = false; + resetProgressBar(); + if (tracks.length === 0) { + playerReady = false; + updateCurrentTrackDisplay('No tracks found'); + updatePlayPauseButton(); + } else { + const nextIndex = Math.min(index, tracks.length - 1); + setCurrentTrackIndex(nextIndex); + const next = tracks[nextIndex]; + updateCurrentTrackDisplay(`Ready to play: ${next.artist} – ${next.title}`); + updatePlayPauseButton(); + } + } else if (currentTrackIndex > index) { + setCurrentTrackIndex(currentTrackIndex - 1); + } + + renderPlaylist(); + + // FLIP: translate each surviving row from its old top to its new top. + const afterItems = playlist.querySelectorAll('.playlist-item'); + afterItems.forEach((el, i) => { + const t = tracks[i]; + if (!t) return; + const oldTop = oldTopByFilename.get(t.filename); + if (oldTop === undefined) return; + const newTop = el.getBoundingClientRect().top; + const dy = oldTop - newTop; + if (!dy) return; + el.style.transition = 'none'; + el.style.transform = `translateY(${dy}px)`; + void el.offsetHeight; + el.style.transition = 'transform 180ms ease'; + el.style.transform = ''; + const clear = (e) => { + if (e.propertyName !== 'transform') return; + el.style.transition = ''; + el.style.transform = ''; + el.removeEventListener('transitionend', clear); + }; + el.addEventListener('transitionend', clear); + }); + + fetch('/tracks/' + encodeURIComponent(removed.filename), { method: 'DELETE' }) + .then(r => { if (!r.ok) throw new Error('delete failed: ' + r.status); }) + .catch(err => console.error('Failed to delete track:', err)); +} + // Live sync over SSE // Swap the now-playing text without disturbing an in-flight marquee diff --git a/serve.py b/serve.py @@ -14,12 +14,14 @@ import queue import threading import subprocess import time +import urllib.parse from pathlib import Path DEFAULT_PORT = 8000 SCRIPT_DIR = Path(__file__).parent.absolute() VENV_DIR = SCRIPT_DIR / "venv" -TRACKS_PATH = SCRIPT_DIR / "mix" / "tracks.json" +MIX_DIR = SCRIPT_DIR / "mix" +TRACKS_PATH = MIX_DIR / "tracks.json" # Serializes all reads/writes of tracks.json so rescans, reorders, and future # uploads can't race each other. @@ -281,6 +283,46 @@ def start_server(): self.send_response(204) self.end_headers() + def do_DELETE(self): + # Local-only: remove a track's audio file from /mix and prune + # tracks.json. Path is /tracks/<url-encoded-filename>. + prefix = '/tracks/' + if not self.path.startswith(prefix): + self.send_response(404) + self.end_headers() + return + + filename = urllib.parse.unquote(self.path[len(prefix):]) + target = (MIX_DIR / filename).resolve() + try: + target.relative_to(MIX_DIR.resolve()) + except ValueError: + self.send_response(400) + self.end_headers() + return + if target == TRACKS_PATH.resolve() or target.name != filename: + self.send_response(400) + self.end_headers() + return + with tracks_lock: + try: + target.unlink() + except FileNotFoundError: + pass + except OSError as e: + self.send_response(500) + self.end_headers() + self.wfile.write(str(e).encode('utf-8')) + return + tracks = [t for t in read_tracks() if t.get('filename') != filename] + TRACKS_PATH.write_text( + json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n', + encoding='utf-8', + ) + broadcast_tracks(tracks) + self.send_response(204) + self.end_headers() + try: with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd: httpd.daemon_threads = True