commit 771822d46c18c34ce978b2215918f9797e8c5bb2
parent bf946557ba27fde10e729466116014a9f659af58
Author: Hunter
Date:   Mon, 27 Apr 2026 14:47:35 -0400

first pass at track reordering when running locally

Diffstat:
Mindex.html | 8++++++++
Mreadme.md | 8++++----
Aresources/reorder.js | 364+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mresources/script.js | 43++++++++++++++++++++++++++++++++++++++-----
Mresources/styles.css | 31+++++++++++++++++++++++++++++++
Mscan.py | 100+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mserve.py | 17+++++++++++++++++
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}"