commit 771822d46c18c34ce978b2215918f9797e8c5bb2
parent bf946557ba27fde10e729466116014a9f659af58
Author: Hunter
Date: Mon, 27 Apr 2026 14:47:35 -0400
first pass at track reordering when running locally
Diffstat:
7 files changed, 516 insertions(+), 55 deletions(-)
diff --git a/index.html b/index.html
@@ -40,6 +40,14 @@
<audio id="audioPlayer"></audio>
<script src="resources/script.js"></script>
+ <script>
+ // Load reorder UI only on local networks.
+ if (typeof isLocal !== 'undefined' && isLocal) {
+ const s = document.createElement('script');
+ s.src = 'resources/reorder.js';
+ document.body.appendChild(s);
+ }
+ </script>
<script src="mix/custom.js" onerror="this.remove()"></script>
</body>
</html>
diff --git a/readme.md b/readme.md
@@ -42,12 +42,12 @@ hits different, right?<br><br>
- `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
2. **scan it**
- - run `./scan.py` to generate `tracks.json`, which defines the tracks available to the player.
- - after running `./scan.py` once, you can manually edit `tracks.json` to refine your mix.
+ - run `./scan.py` to create (or update) `tracks.json`, which defines the tracks available to the player.
3. **serve it**
- - run `./serve.py` to start a local HTTP server for testing. you can scan the QR code printed to the terminal to test the app from any device on your local network.
-
+ - 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 refine your mix (this automatically updates `tracks.json`).
+
4. **build it**
- 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/reorder.js b/resources/reorder.js
@@ -0,0 +1,364 @@
+const REORDER_DRAG_THRESHOLD = 5;
+const LONG_PRESS_MS = 400;
+const LONG_PRESS_MOVE_TOLERANCE = 10;
+let activeReorder = null;
+let longPressTimer = null;
+
+function attachReorderHandlers(item, index) {
+ item.addEventListener('mousedown', (e) => {
+ // Ignore non-primary buttons
+ if (e.button !== 0) return;
+ startReorder(item, index, e.clientX, e.clientY);
+ });
+
+ item.addEventListener('touchstart', (e) => {
+ if (e.touches.length !== 1) return;
+ const t = e.touches[0];
+ startLongPress(item, index, t.clientX, t.clientY);
+ }, { passive: true });
+}
+
+function startLongPress(item, index, startX, startY) {
+ if (longPressTimer) clearTimeout(longPressTimer);
+
+ const candidate = { item, index, startX, startY };
+ longPressTimer = setTimeout(() => {
+ longPressTimer = null;
+ cleanup();
+ startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true);
+ beginDrag();
+ if (navigator.vibrate) navigator.vibrate(10);
+ }, LONG_PRESS_MS);
+
+ const onMove = (e) => {
+ const t = e.touches[0];
+ if (!t) return;
+ const dx = t.clientX - startX;
+ const dy = t.clientY - startY;
+ if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel();
+ };
+ const onUp = () => cancel();
+
+ function cleanup() {
+ document.removeEventListener('touchmove', onMove);
+ document.removeEventListener('touchend', onUp);
+ }
+
+ function cancel() {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ longPressTimer = null;
+ }
+ cleanup();
+ }
+
+ document.addEventListener('touchmove', onMove, { passive: true });
+ document.addEventListener('touchend', onUp);
+}
+
+function startReorder(item, index, startX, startY, isTouch = false) {
+ activeReorder = {
+ item,
+ index,
+ startX,
+ startY,
+ started: false,
+ placeholder: null,
+ offsetY: 0,
+ itemHeight: 0,
+ autoScrollRaf: null,
+ autoScrollSpeed: 0,
+ lastClientY: startY,
+ isTouch,
+ };
+ if (isTouch) {
+ document.addEventListener('touchmove', onReorderTouchMove, { passive: false });
+ document.addEventListener('touchend', onReorderEnd);
+ document.addEventListener('touchcancel', onReorderEnd);
+ } else {
+ document.body.classList.add('reorder-pressed');
+ document.addEventListener('mousemove', onReorderMove);
+ document.addEventListener('mouseup', onReorderEnd);
+ }
+}
+
+function beginDrag() {
+ const r = activeReorder;
+ const rect = r.item.getBoundingClientRect();
+ r.itemHeight = rect.height;
+ r.offsetY = r.startY - rect.top;
+
+ const styles = window.getComputedStyle(r.item);
+ const placeholder = document.createElement('div');
+ placeholder.classList.add('playlist-item-placeholder');
+ placeholder.style.height = `${rect.height}px`;
+ placeholder.style.marginTop = styles.marginTop;
+ placeholder.style.marginBottom = styles.marginBottom;
+ placeholder.style.paddingLeft = styles.paddingLeft;
+ placeholder.style.paddingRight = styles.paddingRight;
+ placeholder.style.paddingBottom = styles.paddingBottom;
+ r.item.parentNode.insertBefore(placeholder, r.item);
+ r.placeholder = placeholder;
+
+ const playlistRect = playlist.getBoundingClientRect();
+ r.item.classList.add('dragging');
+ r.item.style.position = 'fixed';
+ r.item.style.left = `${playlistRect.left}px`;
+ r.item.style.top = `${rect.top}px`;
+ r.item.style.width = `${playlistRect.width}px`;
+ r.item.style.height = `${rect.height}px`;
+ r.item.style.zIndex = '10';
+ document.body.appendChild(r.item);
+ // Now we're actually dragging — disable pointer-events on the other items
+ document.body.classList.add('reordering');
+
+ r.started = true;
+ r.item._suppressClick = true;
+}
+
+function onReorderMove(e) {
+ const r = activeReorder;
+ if (!r) return;
+ r.lastClientY = e.clientY;
+
+ if (!r.started) {
+ const dx = e.clientX - r.startX;
+ const dy = e.clientY - r.startY;
+ if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return;
+ beginDrag();
+ }
+
+ positionDraggedItem();
+ updateAutoScroll();
+}
+
+function onReorderTouchMove(e) {
+ const r = activeReorder;
+ if (!r || !r.started) return;
+ e.preventDefault();
+ const t = e.touches[0];
+ if (!t) return;
+ r.lastClientY = t.clientY;
+ positionDraggedItem();
+ updateAutoScroll();
+}
+
+function positionDraggedItem() {
+ const r = activeReorder;
+ if (!r || !r.started) return;
+
+ // Clamp the dragged item between the bottom of the now-playing area
+ // (the wrapper's top) and the top of the navigation controls.
+ const wrapperRect = playlistWrapper.getBoundingClientRect();
+ const controlsRect = controls.getBoundingClientRect();
+ const desiredTop = r.lastClientY - r.offsetY;
+ const minTop = wrapperRect.top;
+ const maxTop = controlsRect.top - r.itemHeight;
+ const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop));
+ r.item.style.top = `${clampedTop}px`;
+
+ repositionPlaceholder();
+}
+
+function layoutMidY(el) {
+ const rect = el.getBoundingClientRect();
+ if (el._flipTargetTop !== undefined) {
+ return el._flipTargetTop + rect.height / 2;
+ }
+ return rect.top + rect.height / 2;
+}
+
+function repositionPlaceholder() {
+ const r = activeReorder;
+ if (!r || !r.started) return;
+
+ const draggedRect = r.item.getBoundingClientRect();
+ const draggedMid = draggedRect.top + draggedRect.height / 2;
+
+ const prev = r.placeholder.previousElementSibling;
+ if (prev && prev.classList.contains('playlist-item')) {
+ if (draggedMid < layoutMidY(prev)) {
+ flipItem(prev, () => playlist.insertBefore(r.placeholder, prev));
+ return;
+ }
+ }
+
+ const next = r.placeholder.nextElementSibling;
+ if (next && next.classList.contains('playlist-item')) {
+ if (draggedMid > layoutMidY(next)) {
+ flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling));
+ return;
+ }
+ }
+}
+
+const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling
+const AUTO_SCROLL_MAX_SPEED = 6; // px per frame
+
+function updateAutoScroll() {
+ const r = activeReorder;
+ if (!r || !r.started) return;
+
+ const wrapperRect = playlistWrapper.getBoundingClientRect();
+ const bottomBound = controls.getBoundingClientRect().top;
+ const y = r.lastClientY;
+
+ let speed = 0;
+ if (y < wrapperRect.top + AUTO_SCROLL_EDGE) {
+ const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE;
+ speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
+ } else if (y > bottomBound - AUTO_SCROLL_EDGE) {
+ const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE;
+ speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
+ }
+
+ r.autoScrollSpeed = speed;
+ if (speed !== 0 && !r.autoScrollRaf) {
+ const tick = () => {
+ const cur = activeReorder;
+ if (!cur || !cur.started || cur.autoScrollSpeed === 0) {
+ if (cur) cur.autoScrollRaf = null;
+ return;
+ }
+ const before = playlistWrapper.scrollTop;
+ playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed));
+ const delta = playlistWrapper.scrollTop - before;
+ if (delta !== 0) {
+ positionDraggedItem();
+ }
+ cur.autoScrollRaf = requestAnimationFrame(tick);
+ };
+ r.autoScrollRaf = requestAnimationFrame(tick);
+ }
+}
+
+function flipItem(el, mutate) {
+ el.style.transition = 'none';
+ el.style.transform = '';
+ const oldTop = el.getBoundingClientRect().top;
+
+ mutate();
+
+ const newTop = el.getBoundingClientRect().top;
+ const dy = oldTop - newTop;
+ if (!dy) return;
+ el.style.transform = `translateY(${dy}px)`;
+ void el.offsetHeight;
+ el.style.transition = 'transform 180ms ease';
+ el.style.transform = '';
+
+ el._flipTargetTop = newTop;
+ if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler);
+ el._flipEndHandler = (e) => {
+ if (e.propertyName !== 'transform') return;
+ delete el._flipTargetTop;
+ el.removeEventListener('transitionend', el._flipEndHandler);
+ el._flipEndHandler = null;
+ };
+ el.addEventListener('transitionend', el._flipEndHandler);
+}
+
+function onReorderEnd() {
+ const r = activeReorder;
+ document.removeEventListener('mousemove', onReorderMove);
+ document.removeEventListener('mouseup', onReorderEnd);
+ document.removeEventListener('touchmove', onReorderTouchMove);
+ document.removeEventListener('touchend', onReorderEnd);
+ document.removeEventListener('touchcancel', onReorderEnd);
+ activeReorder = null;
+ document.body.classList.remove('reorder-pressed');
+ document.body.classList.remove('reordering');
+ if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf);
+ if (!r) return;
+
+ if (!r.started) {
+ // No real drag — let the click handler fire normally
+ return;
+ }
+
+ // Compute the new order from placeholder position and rebuild tracks array
+ const fromIndex = r.index;
+ const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder'));
+ const toIndex = itemsInDom.indexOf(r.placeholder);
+
+ const droppedTop = r.item.getBoundingClientRect().top;
+ r.placeholder.remove();
+
+ if (toIndex === -1 || toIndex === fromIndex) {
+ renderPlaylist();
+ animateDroppedTrack(r.item, fromIndex, droppedTop);
+ return;
+ }
+
+ // Build the new tracks array directly from the DOM ordering.
+ const moved = tracks[fromIndex];
+ const remaining = tracks.filter((_, i) => i !== fromIndex);
+ const newTracks = [];
+ let remainingIdx = 0;
+ for (let i = 0; i < itemsInDom.length; i++) {
+ if (itemsInDom[i] === r.placeholder) {
+ newTracks.push(moved);
+ } else {
+ newTracks.push(remaining[remainingIdx++]);
+ }
+ }
+
+ const playingTrack = tracks[currentTrackIndex];
+ tracks.length = 0;
+ tracks.push(...newTracks);
+ const newCurrentIndex = tracks.indexOf(playingTrack);
+ if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex);
+
+ renderPlaylist();
+ animateDroppedTrack(r.item, toIndex, droppedTop);
+ saveTrackOrder();
+}
+
+function animateDroppedTrack(draggedEl, newIndex, oldTop) {
+ const items = playlist.querySelectorAll('.playlist-item');
+ const target = items[newIndex];
+ if (!target) {
+ draggedEl.remove();
+ return;
+ }
+
+ // Hide the rebuilt item but keep it in layout so sizing/scroll are stable.
+ target.style.visibility = 'hidden';
+ const newTop = target.getBoundingClientRect().top;
+
+ const cleanup = () => {
+ draggedEl.remove();
+ target.style.visibility = '';
+ };
+
+ if (oldTop === newTop) {
+ cleanup();
+ return;
+ }
+
+ const playlistRect = playlist.getBoundingClientRect();
+ draggedEl.style.transition = 'none';
+ draggedEl.style.top = `${oldTop}px`;
+ draggedEl.style.left = `${playlistRect.left}px`;
+ draggedEl.style.width = `${playlistRect.width}px`;
+ void draggedEl.offsetHeight;
+ draggedEl.style.transition = 'top 180ms ease';
+ draggedEl.style.top = `${newTop}px`;
+
+ const finish = (e) => {
+ if (e.propertyName !== 'top') return;
+ draggedEl.removeEventListener('transitionend', finish);
+ cleanup();
+ };
+ draggedEl.addEventListener('transitionend', finish);
+}
+
+function saveTrackOrder() {
+ // Strip the runtime-only `looping` flag so we don't persist transient UI state
+ const persisted = tracks.map(({ looping, ...rest }) => rest);
+ fetch('/tracks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(persisted),
+ }).catch(err => console.error('Failed to save order:', err));
+}
diff --git a/resources/script.js b/resources/script.js
@@ -1,5 +1,15 @@
-// Register service worker for PWA functionality (skip on localhost to avoid caching during development)
-if ('serviceWorker' in navigator && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
+const hostname = location.hostname;
+const isLocal =
+ hostname === 'localhost' ||
+ hostname === '127.0.0.1' ||
+ hostname === '::1' ||
+ /^10\./.test(hostname) ||
+ /^192\.168\./.test(hostname) ||
+ /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
+ /^169\.254\./.test(hostname);
+
+// Register service worker for PWA functionality (skip locally to avoid caching during development)
+if ('serviceWorker' in navigator && !isLocal) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('service-worker.js')
.then(registration => {
@@ -9,8 +19,8 @@ if ('serviceWorker' in navigator && location.hostname !== 'localhost' && locatio
console.log('Service Worker registration failed:', error);
});
});
-} else if ('serviceWorker' in navigator && (location.hostname === 'localhost' || location.hostname === '127.0.0.1')) {
- // On localhost, unregister any existing service worker and clear caches
+} else if ('serviceWorker' in navigator && isLocal) {
+ // Locally, unregister any existing service worker and clear caches
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(r => r.unregister());
});
@@ -23,6 +33,8 @@ const playPauseBtn = document.getElementById('playPause');
const prevBtn = document.getElementById('prev');
const nextBtn = document.getElementById('next');
const playlist = document.getElementById('playlist');
+const playlistWrapper = document.querySelector('.playlist-wrapper');
+const controls = document.querySelector('.controls');
const currentTrackDisplay = document.getElementById('currentTrack');
const progressBar = document.getElementById('progressBar');
const progressContainer = document.getElementById('progressContainer');
@@ -42,6 +54,7 @@ function shuffleArray(array) {
let currentTrackIndex = 0;
let isPlaying = false;
+let canReorder = false;
let progressInterval;
let playerReady = false;
let tracks = [];
@@ -86,6 +99,12 @@ async function fetchWithRetry(url, maxRetries = 4, baseDelay = 2000) {
}
}
+// Enable reordering UI only when served locally
+if (isLocal) {
+ canReorder = true;
+ document.body.classList.add('reorderable');
+}
+
// Load cache name from manifest.json first, then load tracks
fetch('manifest.json')
.then(response => {
@@ -298,11 +317,25 @@ function renderPlaylist() {
item.appendChild(contentDiv);
item.appendChild(loopIcon);
- item.addEventListener('click', () => toggleLooping(index));
+ item.addEventListener('click', (e) => {
+ if (item._suppressClick) {
+ item._suppressClick = false;
+ e.stopPropagation();
+ return;
+ }
+ toggleLooping(index);
+ });
+ if (canReorder) {
+ attachReorderHandlers(item, index);
+ }
playlist.appendChild(item);
});
}
+function setCurrentTrackIndex(i) {
+ currentTrackIndex = i;
+}
+
function toggleLooping(index) {
if (!playerReady) return;
if (index === currentTrackIndex) {
diff --git a/resources/styles.css b/resources/styles.css
@@ -130,6 +130,7 @@ body {
padding-bottom: 70px;
overscroll-behavior: none;
margin-top: -1px;
+ overflow-anchor: none;
}
.playlist {
@@ -196,6 +197,36 @@ body {
.playlist-item:hover .playlist-item-artist.current {
color: var(--accent);
}
+
+ body.reorderable .playlist,
+ body.reorderable .playlist-item:hover {
+ cursor: grab;
+ }
+}
+
+body.reorder-pressed,
+body.reorder-pressed * {
+ cursor: grabbing !important;
+}
+
+body.reordering .playlist-item {
+ pointer-events: none;
+ will-change: transform;
+}
+
+.playlist-item.dragging {
+ background-color: var(--background);
+ box-sizing: border-box;
+}
+
+.playlist-item.dragging,
+.playlist-item.dragging .playlist-item-title,
+.playlist-item.dragging .playlist-item-artist {
+ color: var(--accent);
+}
+
+.playlist-item-placeholder {
+ box-sizing: border-box;
}
.current-track {
diff --git a/scan.py b/scan.py
@@ -79,7 +79,7 @@ def scan_tracks():
"""Main function to scan audio files and generate tracks.json"""
# Import mutagen here (only after venv is active)
try:
- from mutagen import File as MutagenFile
+ from mutagen import File as MutagenFile # type: ignore
has_mutagen = True
except ImportError:
MutagenFile = None
@@ -109,58 +109,66 @@ def scan_tracks():
print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
- tracks = []
-
- for audio_file in sorted(audio_files):
+ existing_tracks = []
+ if OUTPUT_FILE.exists():
try:
- title = None
- artist = None
-
- if has_mutagen:
- audio = MutagenFile(audio_file, easy=True)
- if audio and audio.tags:
- title = audio.tags.get('title', [None])[0]
- artist = audio.tags.get('artist', [None])[0]
-
- # Fallback to filename for title if not found
- if not title:
- title = audio_file.stem # filename without extension
+ with open(OUTPUT_FILE, 'r', encoding='utf-8') as f:
+ loaded = json.load(f)
+ if isinstance(loaded, list):
+ existing_tracks = loaded
+ except (json.JSONDecodeError, OSError) as e:
+ print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
+
+ existing_by_filename = {
+ t['filename']: t for t in existing_tracks
+ if isinstance(t, dict) and 'filename' in t
+ }
+ on_disk_filenames = {f.name for f in audio_files}
+ files_by_name = {f.name: f for f in audio_files}
+
+ def read_metadata(audio_file):
+ title = None
+ artist = None
+ if has_mutagen:
+ audio = MutagenFile(audio_file, easy=True)
+ if audio and audio.tags:
+ title = audio.tags.get('title', [None])[0]
+ artist = audio.tags.get('artist', [None])[0]
+ if not title:
+ title = audio_file.stem
+ if not artist:
+ artist = "Unknown Artist"
+ return {"title": title, "artist": artist, "filename": audio_file.name}
- # Fallback to "Unknown Artist" if not found
- if not artist:
- artist = "Unknown Artist"
-
- track_info = {
- "title": title,
- "artist": artist,
- "filename": audio_file.name
- }
-
- tracks.append(track_info)
- print(f"✓ {track_info['artist']} - {track_info['title']}")
+ tracks = []
+ removed = []
+ for entry in existing_tracks:
+ if not isinstance(entry, dict) or 'filename' not in entry:
+ continue
+ if entry['filename'] in on_disk_filenames:
+ tracks.append(entry)
+ else:
+ removed.append(entry['filename'])
+ new_files = sorted(
+ (files_by_name[name] for name in on_disk_filenames if name not in existing_by_filename),
+ key=lambda f: f.name,
+ )
+ new_tracks = []
+ for audio_file in new_files:
+ try:
+ new_tracks.append(read_metadata(audio_file))
except Exception as e:
print(f"✗ Error reading {audio_file.name}: {e}")
- continue
- # Check if ALL titles start with numbers
- # If so, strip the leading numbers from all titles
- import re
- all_have_leading_numbers = all(
- re.match(r'^\d+\s*[-.]?\s*', track['title'])
- for track in tracks
- )
+ for track in new_tracks:
+ print(f"+ {track['artist']} - {track['title']}")
+ for filename in removed:
+ print(f"- {filename} (removed; no longer on disk)")
+ if not new_tracks and not removed:
+ print("No changes — tracks.json already matches /mix.")
- if all_have_leading_numbers and tracks:
- print("\nDetected track numbers in all titles. Stripping them...")
- for track in tracks:
- original_title = track['title']
- # Remove leading number pattern
- cleaned_title = re.sub(r'^\d+\s*[-.]?\s*', '', original_title)
- if cleaned_title: # Only update if something remains
- track['title'] = cleaned_title
- if cleaned_title != original_title:
- print(f" {original_title} → {cleaned_title}")
+ tracks.extend(new_tracks)
if not tracks:
print("\nNo valid audio files could be processed.")
diff --git a/serve.py b/serve.py
@@ -9,6 +9,7 @@ import socketserver
import socket
import sys
import os
+import json
import subprocess
from pathlib import Path
@@ -150,6 +151,7 @@ def start_server():
# Create server
Handler = http.server.SimpleHTTPRequestHandler
+ tracks_path = SCRIPT_DIR / "mix" / "tracks.json"
# Suppress default logging and broken pipe errors
class QuietHandler(Handler):
@@ -168,6 +170,21 @@ def start_server():
# Browser cancelled the request (normal for media streaming/preloading)
pass
+ def do_POST(self):
+ # Local-only: receive a reordered tracks array and overwrite tracks.json
+ if self.path != '/tracks':
+ self.send_response(404)
+ self.end_headers()
+ return
+ length = int(self.headers.get('Content-Length', '0'))
+ payload = json.loads(self.rfile.read(length))
+ tracks_path.write_text(
+ json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
+ encoding='utf-8',
+ )
+ self.send_response(204)
+ self.end_headers()
+
try:
with socketserver.TCPServer(("", port), QuietHandler) as httpd:
local_url = f"http://localhost:{port}"