shift + click to delete track

Hunter
Wednesday, May 6th, 2026 at 12:52 AM

readme.md+1-1
resources/dev.js+87 
serve.py+43-1

readme.md
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.
resources/dev.js
 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
serve.py
 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