commit b8e3b7a2c83241f3f5a87cc23d7e41eb4c4d4981
parent fa6978f95e36756bba728e7571fd2a9075cff212
Author: Hunter
Date: Wed, 6 May 2026 00:52:31 -0400
shift + click to delete track
Diffstat:
| M | readme.md | | | 2 | +- |
| M | resources/dev.js | | | 87 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | serve.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