From 1e59ef06eaeff3ff587c1baf933f1bfa15b7a259 Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 16:58:42 +0100 Subject: [PATCH 01/11] Add user authentication, admin panel, cross-device sync, and playlists Auth: - Login/logout with bcryptjs password hashing and express-session - First user created becomes admin; admin creates all other accounts - Setup flow shown on first visit when no users exist yet - Global fetch interceptor shows login overlay on 401 Access control: - Admin assigns top-level media folders per user via allowedPaths - /api/browse, /api/stream, /api/artwork, /api/metadata all require auth - Root browse filters to user's allowed dirs; sub-path access denied if not under an allowed path Cross-device state sync: - Device UUID stored in localStorage; state pushed to server every 5s while playing - On load: if another device was active within 30 min, offer to resume - On first play: if another device is active within 5 min, offer to take over - PUT /api/state stores queue, index, position, sort prefs, active device Custom playlists: - Create/rename/delete playlists; add tracks via long-press context menu - Play full playlist or jump to individual tracks - Stored per-user in data/playlists.json Admin panel: - Full-screen user management: create, edit (password/role/folders), delete - Folder picker uses /api/browse root listing for checkbox selection Data stored in data/ (gitignored); sessions persist via session-file-store Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + Dockerfile | 2 +- package.json | 5 +- public/app.js | 714 +++++++++++++++++++++++++++++++++++++++++++++- public/index.html | 128 +++++++++ public/style.css | 82 ++++++ server.js | 289 ++++++++++++++++++- 7 files changed, 1205 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index f8b40e2..2085616 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db # Claude .claude/ + +# User data +data/ diff --git a/Dockerfile b/Dockerfile index 58f457f..e89eceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci --omit=dev +RUN npm install --omit=dev COPY server.js . COPY public/ public/ EXPOSE 3000 diff --git a/package.json b/package.json index 378e964..82a272d 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,11 @@ "start": "node server.js" }, "dependencies": { + "bcryptjs": "^2.4.3", "express": "^4.18.2", + "express-session": "^1.17.3", + "mime-types": "^2.1.35", "music-metadata": "^10.2.1", - "mime-types": "^2.1.35" + "session-file-store": "^1.5.0" } } diff --git a/public/app.js b/public/app.js index bc6064c..274a30d 100644 --- a/public/app.js +++ b/public/app.js @@ -807,7 +807,9 @@ const QueueModal = (() => { btnCancel.addEventListener('click', hide); overlay.addEventListener('click', e => { if (e.target === overlay) hide(); }); - return { show }; + function getPendingPaths() { return [...pendingPaths]; } + + return { show, getPendingPaths }; })(); // ── Queue Panel ─────────────────────────────────────────────────────────────── @@ -1249,9 +1251,11 @@ const FileBrowser = (() => { let data; try { const res = await fetch(`/api/browse?path=${enc(currentPath)}`); + if (res.status === 401) return; // login overlay will appear via fetch interceptor if (!res.ok) throw new Error(res.statusText); data = await res.json(); } catch (e) { + if (!document.getElementById('login-overlay').hidden) return; browserEl.innerHTML = `
Error: ${e.message}
`; return; } @@ -1516,7 +1520,9 @@ const FileBrowser = (() => { function reload() { load(); } - return { navigate, setPlaying, refresh, reload }; + function getSelectedPaths() { return [...selectedPaths]; } + + return { navigate, setPlaying, refresh, reload, getSelectedPaths }; })(); // Keep --player-h in sync with the actual rendered player bar height @@ -1525,8 +1531,7 @@ new ResizeObserver(entries => { if (h > 0) document.documentElement.style.setProperty('--player-h', h + 'px'); }).observe(document.getElementById('player-bar')); -// Restore queue + position after all modules are initialised -Player.restore(); +// Player.restore() is called in onReady() after auth if (!Player.isActive()) document.body.classList.add('player-hidden'); // Keep the phone's WiFi radio alive via server-sent WebSocket pings. @@ -1624,3 +1629,704 @@ window.addEventListener('popstate', e => { FileBrowser.navigate(state.path, false); } }); + +// ── Auth ────────────────────────────────────────────────────────────────────── + +const Auth = (() => { + const overlay = document.getElementById('login-overlay'); + const subtitle = document.getElementById('login-subtitle'); + const userInput = document.getElementById('login-username'); + const passInput = document.getElementById('login-password'); + const submitBtn = document.getElementById('login-submit'); + const errorEl = document.getElementById('login-error'); + + let _user = null; + let _isSetup = false; + + function currentUser() { return _user; } + + function showOverlay(isSetup) { + _isSetup = isSetup; + subtitle.textContent = isSetup ? 'Create your admin account to get started.' : ''; + submitBtn.textContent = isSetup ? 'Create account' : 'Sign in'; + errorEl.textContent = ''; + overlay.hidden = false; + } + + function hideOverlay() { overlay.hidden = true; } + + async function submit() { + const username = userInput.value.trim(); + const password = passInput.value; + errorEl.textContent = ''; + if (!username || !password) { errorEl.textContent = 'Enter username and password.'; return; } + submitBtn.disabled = true; + try { + const endpoint = _isSetup ? '/api/auth/setup' : '/api/auth/login'; + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) { errorEl.textContent = data.error || 'Login failed.'; return; } + _user = data; + hideOverlay(); + onReady(); + } catch (e) { + errorEl.textContent = 'Network error.'; + } finally { + submitBtn.disabled = false; + } + } + + submitBtn.addEventListener('click', submit); + [userInput, passInput].forEach(el => { + el.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); + }); + + async function logout() { + await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); + location.reload(); + } + + async function init() { + try { + const setupRes = await fetch('/api/auth/setup-check'); + const { needsSetup } = await setupRes.json(); + if (needsSetup) { showOverlay(true); return; } + + const meRes = await fetch('/api/auth/me'); + if (meRes.ok) { + _user = await meRes.json(); + hideOverlay(); + onReady(); + } else { + showOverlay(false); + } + } catch { + showOverlay(false); + } + } + + // Intercept 401s globally + const _origFetch = window.fetch; + window.fetch = async function(...args) { + const res = await _origFetch(...args); + if (res.status === 401) { + const url = typeof args[0] === 'string' ? args[0] : args[0].url || ''; + if (!url.includes('/api/auth/')) { + _user = null; + showOverlay(false); + } + } + return res; + }; + + return { init, logout, currentUser }; +})(); + +// ── User menu ───────────────────────────────────────────────────────────────── + +const UserMenu = (() => { + const btn = document.getElementById('user-menu-btn'); + const label = document.getElementById('user-menu-label'); + const dropdown = document.getElementById('user-menu-dropdown'); + const adminBtn = document.getElementById('user-menu-admin'); + const logoutBtn= document.getElementById('user-menu-logout'); + + function setup(user) { + label.textContent = user.username; + adminBtn.hidden = user.role !== 'admin'; + } + + btn.addEventListener('click', e => { + e.stopPropagation(); + dropdown.hidden = !dropdown.hidden; + }); + + document.addEventListener('click', () => { dropdown.hidden = true; }); + dropdown.addEventListener('click', e => e.stopPropagation()); + + adminBtn.addEventListener('click', () => { dropdown.hidden = true; AdminPanel.open(); }); + logoutBtn.addEventListener('click', () => { Auth.logout(); }); + + return { setup }; +})(); + +// ── SyncManager ─────────────────────────────────────────────────────────────── + +const SyncManager = (() => { + const resumeBanner = document.getElementById('resume-banner'); + const resumeText = document.getElementById('resume-banner-text'); + const resumeBtn = document.getElementById('resume-btn'); + const resumeDismiss = document.getElementById('resume-dismiss'); + const takeoverBanner = document.getElementById('takeover-banner'); + const takeoverBtn = document.getElementById('takeover-btn'); + const takeoverDismiss= document.getElementById('takeover-dismiss'); + + let myDeviceId = localStorage.getItem('snap_device_id'); + if (!myDeviceId) { + myDeviceId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }); + localStorage.setItem('snap_device_id', myDeviceId); + } + + let pollTimer = null; + let takeoverPrompted = false; + let serverState = null; + + function getPlayerState() { + return { + queue: Player.getQueue(), + index: Player.getQueueIndex(), + position: (() => { + const el = document.getElementById('audio'); + return isFinite(el.currentTime) ? el.currentTime : 0; + })(), + sortKey: localStorage.getItem('snap_sort_key') || 'name', + sortDir: localStorage.getItem('snap_sort_dir') || 'asc', + deviceId: myDeviceId, + }; + } + + async function push(state) { + const body = state || getPlayerState(); + try { + await fetch('/api/state', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch {} + } + + function startPolling() { + if (pollTimer) return; + pollTimer = setInterval(() => { + if (!Player.paused()) push(); + }, 5000); + } + + function stopPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + } + + async function init() { + try { + const res = await fetch('/api/state'); + if (!res.ok) return; + serverState = await res.json(); + } catch { return; } + + if (!serverState || !serverState.queue || serverState.queue.length === 0) return; + if (!serverState.activeDeviceAt) return; + + const age = Date.now() - serverState.activeDeviceAt; + const within30min = age < 30 * 60 * 1000; + if (!within30min) return; + + if (serverState.activeDeviceId !== myDeviceId) { + // Different device had state - offer to resume + const track = serverState.queue[serverState.index] || serverState.queue[0]; + const trackName = track ? track.split('/').pop().replace(/\.[^.]+$/, '') : 'your queue'; + const pos = serverState.position ? formatTime(serverState.position) : '0:00'; + resumeText.textContent = `Continue playing "${trackName}" from ${pos}?`; + resumeBanner.hidden = false; + } + } + + resumeBtn.addEventListener('click', () => { + resumeBanner.hidden = true; + if (!serverState) return; + Player.loadState({ + queue: serverState.queue, + index: serverState.index, + position: serverState.position, + }); + }); + resumeDismiss.addEventListener('click', () => { resumeBanner.hidden = true; }); + + takeoverBtn.addEventListener('click', () => { + takeoverBanner.hidden = true; + push(); + }); + takeoverDismiss.addEventListener('click', () => { takeoverBanner.hidden = true; }); + + function checkTakeover() { + if (takeoverPrompted || !serverState) return; + if (!serverState.activeDeviceId || serverState.activeDeviceId === myDeviceId) return; + if (!serverState.activeDeviceAt) return; + const age = Date.now() - serverState.activeDeviceAt; + if (age < 5 * 60 * 1000) { + takeoverPrompted = true; + takeoverBanner.hidden = false; + } + } + + // Wire into audio play events for takeover check + document.getElementById('audio').addEventListener('play', () => { + checkTakeover(); + startPolling(); + push(); + }); + document.getElementById('audio').addEventListener('pause', () => { + stopPolling(); + push(); + }); + + return { init, push, startPolling, stopPolling, myDeviceId }; +})(); + +// ── Playlists ───────────────────────────────────────────────────────────────── + +const Playlists = (() => { + const panel = document.getElementById('playlists-panel'); + const listEl = document.getElementById('playlist-list'); + const closeBtn = document.getElementById('playlists-close'); + const newBtn = document.getElementById('new-playlist-btn'); + const topbarBtn = document.getElementById('btn-playlists'); + const fsBtnPl = document.getElementById('fs-btn-playlists'); + const addModal = document.getElementById('add-playlist-modal'); + const addListEl = document.getElementById('add-playlist-list'); + const apmNew = document.getElementById('apm-new'); + const apmCancel = document.getElementById('apm-cancel'); + + let _playlists = []; + let _addCallback = null; // { paths } pending add + + async function load() { + try { + const res = await fetch('/api/playlists'); + if (!res.ok) return; + _playlists = await res.json(); + } catch {} + } + + function open() { + panel.hidden = false; + load().then(render); + } + + function close() { panel.hidden = true; } + + function render() { + listEl.innerHTML = ''; + if (_playlists.length === 0) { + listEl.innerHTML = '
No playlists yet
'; + return; + } + for (const pl of _playlists) { + const item = document.createElement('div'); + item.className = 'playlist-item'; + + const nameEl = document.createElement('span'); + nameEl.className = 'playlist-item-name'; + nameEl.textContent = pl.name; + + const countEl = document.createElement('span'); + countEl.className = 'playlist-item-count'; + countEl.textContent = `${pl.tracks.length} track${pl.tracks.length !== 1 ? 's' : ''}`; + + const actions = document.createElement('span'); + actions.className = 'playlist-item-actions'; + + const playBtn = document.createElement('button'); + playBtn.className = 'admin-btn'; + playBtn.textContent = 'Play'; + playBtn.addEventListener('click', e => { e.stopPropagation(); playPlaylist(pl); }); + + const delBtn = document.createElement('button'); + delBtn.className = 'admin-btn danger'; + delBtn.textContent = 'Delete'; + delBtn.addEventListener('click', e => { e.stopPropagation(); deletePlaylist(pl.id); }); + + actions.append(playBtn, delBtn); + item.append(nameEl, countEl, actions); + + // Expand/collapse tracks + let expanded = false; + let tracksEl = null; + item.addEventListener('click', () => { + expanded = !expanded; + if (expanded) { + tracksEl = document.createElement('div'); + tracksEl.className = 'playlist-tracks'; + if (pl.tracks.length === 0) { + tracksEl.innerHTML = 'Empty playlist'; + } + for (const t of pl.tracks) { + const tr = document.createElement('div'); + tr.className = 'playlist-track'; + tr.textContent = (t.name || t.path.split('/').pop().replace(/\.[^.]+$/, '')); + tr.addEventListener('click', e => { e.stopPropagation(); Player.startQueue([t.path], 0); close(); }); + tracksEl.appendChild(tr); + } + item.after(tracksEl); + } else { + if (tracksEl) { tracksEl.remove(); tracksEl = null; } + } + }); + + listEl.appendChild(item); + } + } + + function playPlaylist(pl) { + if (pl.tracks.length === 0) return; + const paths = pl.tracks.map(t => t.path); + Player.startQueue(paths, 0); + close(); + } + + async function deletePlaylist(id) { + try { + await fetch(`/api/playlists/${id}`, { method: 'DELETE' }); + await load(); + render(); + } catch {} + } + + async function createPlaylist(name) { + try { + const res = await fetch('/api/playlists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!res.ok) return null; + const pl = await res.json(); + await load(); + render(); + return pl; + } catch { return null; } + } + + newBtn.addEventListener('click', async () => { + const name = prompt('Playlist name:'); + if (!name || !name.trim()) return; + await createPlaylist(name.trim()); + }); + + // Add-to-playlist modal + async function showAddModal(paths) { + _addCallback = paths; + await load(); + addListEl.innerHTML = ''; + if (_playlists.length === 0) { + addListEl.innerHTML = '
No playlists yet — create one below.
'; + } + for (const pl of _playlists) { + const btn = document.createElement('button'); + btn.className = 'modal-btn'; + btn.textContent = `${pl.name} (${pl.tracks.length} tracks)`; + btn.addEventListener('click', () => { addToPlaylist(pl, paths); addModal.hidden = true; }); + addListEl.appendChild(btn); + } + addModal.hidden = false; + } + + async function addToPlaylist(pl, paths) { + const newTracks = paths.map(p => ({ path: p, name: p.split('/').pop().replace(/\.[^.]+$/, '') })); + const tracks = [...pl.tracks, ...newTracks]; + try { + await fetch(`/api/playlists/${pl.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks }), + }); + } catch {} + } + + apmNew.addEventListener('click', async () => { + addModal.hidden = true; + const name = prompt('Playlist name:'); + if (!name || !name.trim()) return; + const pl = await createPlaylist(name.trim()); + if (pl && _addCallback) await addToPlaylist(pl, _addCallback); + _addCallback = null; + }); + apmCancel.addEventListener('click', () => { addModal.hidden = true; _addCallback = null; }); + addModal.addEventListener('click', e => { if (e.target === addModal) { addModal.hidden = true; _addCallback = null; } }); + + closeBtn.addEventListener('click', close); + topbarBtn.addEventListener('click', () => { panel.hidden ? open() : close(); }); + fsBtnPl.addEventListener('click', () => { FullscreenPlayer.close(); open(); }); + + return { open, close, showAddModal, createPlaylist }; +})(); + +// ── Admin Panel ─────────────────────────────────────────────────────────────── + +const AdminPanel = (() => { + const panel = document.getElementById('admin-panel'); + const closeBtn = document.getElementById('admin-close'); + const userListEl = document.getElementById('admin-user-list'); + const addUserBtn = document.getElementById('admin-add-user-btn'); + const addForm = document.getElementById('admin-add-user-form'); + const editForm = document.getElementById('admin-edit-user-form'); + + // Add form fields + const aaufUser = document.getElementById('aauf-username'); + const aaufPass = document.getElementById('aauf-password'); + const aaufRole = document.getElementById('aauf-role'); + const aaufFolders = document.getElementById('aauf-folders'); + const aaufErr = document.getElementById('aauf-error'); + const aaufCancel = document.getElementById('aauf-cancel'); + const aaufSubmit = document.getElementById('aauf-submit'); + + // Edit form fields + const aeufLabel = document.getElementById('aeuf-username-display'); + const aeufPass = document.getElementById('aeuf-password'); + const aeufRole = document.getElementById('aeuf-role'); + const aeufFolders = document.getElementById('aeuf-folders'); + const aeufErr = document.getElementById('aeuf-error'); + const aeufCancel = document.getElementById('aeuf-cancel'); + const aeufSubmit = document.getElementById('aeuf-submit'); + + let _users = []; + let _topDirs = []; + let _editUserId = null; + + async function fetchTopDirs() { + try { + const res = await fetch('/api/browse?path='); + if (!res.ok) return []; + const data = await res.json(); + return data.items.filter(i => i.type === 'dir').map(i => i.name); + } catch { return []; } + } + + function renderFolderChecks(container, topDirs, checked) { + container.innerHTML = ''; + if (topDirs.length === 0) { + container.innerHTML = 'No folders found'; + return; + } + for (const dir of topDirs) { + const lbl = document.createElement('label'); + lbl.className = 'folder-check'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = dir; + cb.checked = checked.includes(dir); + lbl.append(cb, document.createTextNode(dir)); + container.appendChild(lbl); + } + } + + function getCheckedFolders(container) { + return Array.from(container.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value); + } + + async function loadUsers() { + try { + const res = await fetch('/api/admin/users'); + if (!res.ok) return; + _users = await res.json(); + } catch {} + } + + function renderUsers() { + userListEl.innerHTML = ''; + if (_users.length === 0) { + userListEl.innerHTML = '
No users
'; + return; + } + const currentUser = Auth.currentUser(); + for (const u of _users) { + const row = document.createElement('div'); + row.className = 'user-row'; + const nameEl = document.createElement('span'); + nameEl.className = 'user-row-name'; + nameEl.textContent = u.username + (u.id === currentUser?.id ? ' (you)' : ''); + const roleEl = document.createElement('span'); + roleEl.className = 'user-row-role'; + roleEl.textContent = u.role; + const editBtn = document.createElement('button'); + editBtn.className = 'admin-btn'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', () => openEditForm(u)); + const delBtn = document.createElement('button'); + delBtn.className = 'admin-btn danger'; + delBtn.textContent = 'Delete'; + delBtn.disabled = u.id === currentUser?.id; + delBtn.addEventListener('click', () => deleteUser(u.id)); + row.append(nameEl, roleEl, editBtn, delBtn); + userListEl.appendChild(row); + } + } + + async function deleteUser(id) { + if (!confirm('Delete this user?')) return; + try { + const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); + if (!res.ok) { const d = await res.json(); alert(d.error); return; } + await loadUsers(); + renderUsers(); + } catch {} + } + + function openAddForm() { + editForm.hidden = true; + addForm.hidden = false; + aaufUser.value = ''; + aaufPass.value = ''; + aaufRole.value = 'user'; + aaufErr.textContent = ''; + renderFolderChecks(aaufFolders, _topDirs, []); + aaufUser.focus(); + } + + function openEditForm(u) { + addForm.hidden = true; + _editUserId = u.id; + aeufLabel.textContent = `Editing: ${u.username}`; + aeufPass.value = ''; + aeufRole.value = u.role; + aeufErr.textContent = ''; + renderFolderChecks(aeufFolders, _topDirs, u.allowedPaths || []); + editForm.hidden = false; + } + + addUserBtn.addEventListener('click', openAddForm); + aaufCancel.addEventListener('click', () => { addForm.hidden = true; }); + + aaufSubmit.addEventListener('click', async () => { + aaufErr.textContent = ''; + const body = { + username: aaufUser.value.trim(), + password: aaufPass.value, + role: aaufRole.value, + allowedPaths: getCheckedFolders(aaufFolders), + }; + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { aaufErr.textContent = data.error || 'Error'; return; } + addForm.hidden = true; + await loadUsers(); + renderUsers(); + } catch { aaufErr.textContent = 'Network error'; } + }); + + aeufCancel.addEventListener('click', () => { editForm.hidden = true; _editUserId = null; }); + + aeufSubmit.addEventListener('click', async () => { + aeufErr.textContent = ''; + const body = { + role: aeufRole.value, + allowedPaths: getCheckedFolders(aeufFolders), + }; + if (aeufPass.value) body.password = aeufPass.value; + try { + const res = await fetch(`/api/admin/users/${_editUserId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { aeufErr.textContent = data.error || 'Error'; return; } + editForm.hidden = true; + _editUserId = null; + await loadUsers(); + renderUsers(); + } catch { aeufErr.textContent = 'Network error'; } + }); + + closeBtn.addEventListener('click', close); + + async function open() { + _topDirs = await fetchTopDirs(); + await loadUsers(); + renderUsers(); + addForm.hidden = true; + editForm.hidden = true; + panel.hidden = false; + } + + function close() { panel.hidden = true; } + + return { open, close }; +})(); + +// ── Wire Player.getState / Player.loadState ─────────────────────────────────── +// These augment the already-initialised Player closure by storing refs on the +// object it returned. + +Player.getState = function() { + return { + queue: Player.getQueue(), + index: Player.getQueueIndex(), + position: (() => { + const el = document.getElementById('audio'); + return isFinite(el.currentTime) ? el.currentTime : 0; + })(), + sortKey: localStorage.getItem('snap_sort_key') || 'name', + sortDir: localStorage.getItem('snap_sort_dir') || 'asc', + }; +}; + +Player.loadState = function({ queue, index, position }) { + if (!Array.isArray(queue) || queue.length === 0) return; + // Use startQueue at the right index and seek to position after metadata loads + Player.startQueue(queue, typeof index === 'number' ? index : 0); + if (position && position > 0) { + const audioEl = document.getElementById('audio'); + const seek = () => { audioEl.currentTime = position; }; + if (audioEl.readyState >= 1) seek(); + else audioEl.addEventListener('loadedmetadata', seek, { once: true }); + } +}; + +// ── Wire sort changes into SyncManager ─────────────────────────────────────── +// Patch the sort buttons rendered by FileBrowser to push state on change. +// We do this by observing clicks on .sort-btn elements via event delegation. +document.getElementById('browser').addEventListener('click', e => { + if (e.target.classList.contains('sort-btn')) { + setTimeout(() => SyncManager.push(), 50); + } +}); + +// ── Wire "add to playlist" buttons ─────────────────────────────────────────── +document.getElementById('select-add-playlist').addEventListener('click', () => { + const selected = Array.from(document.querySelectorAll('.list-item.selected, .grid-item.selected')) + .map(el => { + // Find the path from the item's click data — stored as data-path or via the + // list items rendered by FileBrowser. We'll rely on QueueModal pattern instead. + }); + // Easier: expose selected paths through FileBrowser + Playlists.showAddModal(FileBrowser.getSelectedPaths ? FileBrowser.getSelectedPaths() : []); +}); + +document.getElementById('qm-add-playlist').addEventListener('click', () => { + // get pending paths from QueueModal + const paths = QueueModal.getPendingPaths ? QueueModal.getPendingPaths() : []; + document.getElementById('queue-modal').hidden = true; + Playlists.showAddModal(paths); +}); + +// ── App startup ─────────────────────────────────────────────────────────────── + +let _appReady = false; +function onReady() { + const user = Auth.currentUser(); + if (!user) return; + UserMenu.setup(user); + // Load the file browser (first real load after auth) + FileBrowser.reload(); + // Restore queue from localStorage + Player.restore(); + if (!Player.isActive()) document.body.classList.add('player-hidden'); + // Init cross-device sync + SyncManager.init(); + _appReady = true; +} + +Auth.init(); diff --git a/public/index.html b/public/index.html index f17060f..c2860bb 100644 --- a/public/index.html +++ b/public/index.html @@ -8,16 +8,46 @@ + +
+ +
+
+ +
+ + +
@@ -72,6 +102,18 @@ + + + + + + +
+ +
-

Users

+

Create User

-
+
+ + +
+

Edit User

+ + + +
+

All Users

+
+
+
diff --git a/public/style.css b/public/style.css index ef91338..94dc659 100644 --- a/public/style.css +++ b/public/style.css @@ -1063,6 +1063,29 @@ input[type=range]::-moz-range-thumb { .folder-checks { display:flex; flex-direction:column; gap:6px; max-height:200px; overflow-y:auto; } .folder-check { display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; } .folder-check input[type=checkbox] { accent-color:var(--accent); } + +/* ── Password group (input + eye toggle) ── */ +.pw-group { display:flex; align-items:stretch; gap:0; } +.pw-group input { flex:1; border-radius:6px 0 0 6px !important; } +.pw-eye { background:var(--surface); border:1px solid var(--border); border-left:none; color:var(--text-muted); border-radius:0 6px 6px 0; padding:0 10px; cursor:pointer; display:flex; align-items:center; flex-shrink:0; transition:color .12s; } +.pw-eye:hover { color:var(--text); } + +/* ── Admin select (user dropdown in edit section) ── */ +.admin-select { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:7px 10px; font-size:13px; outline:none; width:100%; margin-top:4px; } +.admin-select:focus { border-color:var(--accent); } + +/* ── Folder tree ── */ +.folder-tree { max-height:260px; overflow-y:auto; border:1px solid var(--border); border-radius:6px; padding:6px 8px; background:var(--bg); } +.folder-tree::-webkit-scrollbar { width:4px; } +.folder-tree::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; } +.ftree-node { } +.ftree-children { padding-left:18px; } +.ftree-row { display:flex; align-items:center; gap:4px; padding:2px 0; } +.ftree-expand { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:9px; width:14px; text-align:center; flex-shrink:0; padding:0; line-height:1; } +.ftree-expand:hover { color:var(--text); } +.ftree-label { display:flex; align-items:center; gap:6px; cursor:pointer; font-size:13px; user-select:none; } +.ftree-label input[type=checkbox] { accent-color:var(--accent); flex-shrink:0; } +.ftree-loading { font-size:12px; color:var(--text-muted); padding:4px 2px; } .admin-form-actions { display:flex; gap:8px; justify-content:flex-end; } .admin-form-actions button { padding:7px 16px; border-radius:6px; border:1px solid var(--border); cursor:pointer; font-size:13px; } .btn-primary { background:var(--accent); color:#000; border-color:var(--accent) !important; font-weight:600; } From b3b93e9259794e8aa3177ffdd688358e38e884ed Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 17:47:30 +0100 Subject: [PATCH 05/11] Fix eye toggle display, folder tree layout, resume prompt, and takeover button position - Add svg[hidden] override inside .pw-eye so only one eye icon shows at a time - Override flex-direction on .ftree-label inside admin forms so checkbox stays inline - Defer media src loading in restore() to prevent browser "Continue playing" prompt; seek to saved position on first play - Move fs-btn-takeover out of icon row into its own .fs-takeover row at the bottom of the fullscreen player Co-Authored-By: Claude Sonnet 4.6 --- public/app.js | 33 +++++++++++++++++++++------------ public/index.html | 8 ++++---- public/style.css | 7 +++++++ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/public/app.js b/public/app.js index f48586c..f01e4a3 100644 --- a/public/app.js +++ b/public/app.js @@ -168,6 +168,7 @@ const Player = (() => { let repeatMode = 'off'; // 'off' | 'queue' | 'one' let isSeeking = false; let currentPath = null; + let _pendingRestorePosition = 0; // position to seek to on first play after restore // ── Persistence ── const STORAGE_KEY = 'snap_state'; @@ -312,7 +313,22 @@ const Player = (() => { // ── Controls ── function playPause() { if (med.paused) { - med.play().then(() => { syncPlayIcon(true); WakeLock.acquire(); }).catch(() => {}); + if (!med.src && currentPath) { + // First play after page restore — load the track then seek to saved position + const pos = _pendingRestorePosition; + _pendingRestorePosition = 0; + loadTrack(currentPath, false).then(() => { + const doPlay = () => med.play().then(() => { syncPlayIcon(true); WakeLock.acquire(); }).catch(() => {}); + if (pos > 0) { + if (med.readyState >= 1) { med.currentTime = pos; syncSeek(); doPlay(); } + else med.addEventListener('loadedmetadata', () => { med.currentTime = pos; syncSeek(); doPlay(); }, { once: true }); + } else { + doPlay(); + } + }); + } else { + med.play().then(() => { syncPlayIcon(true); WakeLock.acquire(); }).catch(() => {}); + } } else { med.pause(); syncPlayIcon(false); @@ -594,18 +610,10 @@ const Player = (() => { syncShuffleIcon(); syncRepeatIcon(); - const savedPos = s.position || 0; + _pendingRestorePosition = s.position || 0; const restoreIsVideo = isVideoPath(currentPath); if (restoreIsVideo !== currentIsVideo) setMediaMode(restoreIsVideo); - med.src = `/api/stream?path=${enc(currentPath)}`; - med.load(); - - if (savedPos > 0) { - med.addEventListener('loadedmetadata', () => { - med.currentTime = savedPos; - syncSeek(); - }, { once: true }); - } + // Don't load media src here — deferred to first play to avoid browser "Continue playing" prompt if (!currentIsVideo) syncArt(currentPath); @@ -1773,6 +1781,7 @@ const SyncManager = (() => { const takeoverBtn = document.getElementById('takeover-btn'); const takeoverDismiss = document.getElementById('takeover-dismiss'); const fsTakeoverBtn = document.getElementById('fs-btn-takeover'); + const fsTakeoverRow = document.getElementById('fs-takeover-row'); let myDeviceId = localStorage.getItem('snap_device_id'); if (!myDeviceId) { @@ -1797,7 +1806,7 @@ const SyncManager = (() => { } function updateTakeoverBtn() { - if (fsTakeoverBtn) fsTakeoverBtn.hidden = isActiveDevice(); + if (fsTakeoverRow) fsTakeoverRow.hidden = isActiveDevice(); } function getPlayerState() { diff --git a/public/index.html b/public/index.html index 5c34d89..ab1d074 100644 --- a/public/index.html +++ b/public/index.html @@ -364,13 +364,13 @@

All Users

- + + - + diff --git a/public/style.css b/public/style.css index 94dc659..8c52237 100644 --- a/public/style.css +++ b/public/style.css @@ -1069,6 +1069,7 @@ input[type=range]::-moz-range-thumb { .pw-group input { flex:1; border-radius:6px 0 0 6px !important; } .pw-eye { background:var(--surface); border:1px solid var(--border); border-left:none; color:var(--text-muted); border-radius:0 6px 6px 0; padding:0 10px; cursor:pointer; display:flex; align-items:center; flex-shrink:0; transition:color .12s; } .pw-eye:hover { color:var(--text); } +.pw-eye svg[hidden] { display:none; } /* ── Admin select (user dropdown in edit section) ── */ .admin-select { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:7px 10px; font-size:13px; outline:none; width:100%; margin-top:4px; } @@ -1084,6 +1085,7 @@ input[type=range]::-moz-range-thumb { .ftree-expand { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:9px; width:14px; text-align:center; flex-shrink:0; padding:0; line-height:1; } .ftree-expand:hover { color:var(--text); } .ftree-label { display:flex; align-items:center; gap:6px; cursor:pointer; font-size:13px; user-select:none; } +.admin-form .ftree-label { flex-direction:row !important; align-items:center !important; } .ftree-label input[type=checkbox] { accent-color:var(--accent); flex-shrink:0; } .ftree-loading { font-size:12px; color:var(--text-muted); padding:4px 2px; } .admin-form-actions { display:flex; gap:8px; justify-content:flex-end; } @@ -1111,6 +1113,11 @@ input[type=range]::-moz-range-thumb { .playlist-track { font-size:12px; color:var(--text-muted); padding:3px 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; } .playlist-track:hover { color:var(--text); } +/* ── Fullscreen takeover row ── */ +.fs-takeover { display:flex; justify-content:center; padding:8px 0 4px; } +.fs-takeover[hidden] { display:none; } +.fs-takeover-btn { background:var(--accent); color:#000; border:none; border-radius:8px; padding:8px 28px; cursor:pointer; font-size:13px; font-weight:600; } + /* ── Sync banners ── */ .sync-banner { position:fixed; left:0; right:0; bottom:var(--player-h); background:var(--surface); border-top:1px solid var(--accent); padding:10px 16px; display:flex; align-items:center; gap:10px; z-index:150; font-size:13px; } .sync-banner[hidden] { display:none; } From 977265877daff4d0fe03cc13b67aff374f152908 Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 19:51:00 +0100 Subject: [PATCH 06/11] Full cross-device sync: live progress, remote control, and remote track selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Active device pushes position on every poll tick while playing so non-active devices see live progress without user interaction - Add syncPositionDisplay() to Player so non-active devices update seek bar and time display from server state on each poll - Add duration to server state (PUT /api/state) so non-active devices can convert seek-bar percentage to seconds without loading audio - Intercept skip ±30s buttons in capture phase to send skip commands when another device is active - Intercept seek bar change events to send seek commands to the active device - Add loadqueue command type (server + executeCommand) so non-active devices can queue tracks on the active device - Wrap Player.startQueue to redirect to active device via loadqueue command when another device is playing; show takeover banner so user can switch if desired - Fix takeoverHere() to update serverState.activeDeviceId before calling loadState so startQueue is not erroneously redirected back during takeover Co-Authored-By: Claude Sonnet 4.6 --- public/app.js | 79 ++++++++++++++++++++++++++++++++++++++++++++++----- server.js | 5 ++-- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/public/app.js b/public/app.js index f01e4a3..4069c8c 100644 --- a/public/app.js +++ b/public/app.js @@ -769,6 +769,20 @@ const Player = (() => { queueIndex = typeof idx === 'number' ? Math.max(0, Math.min(idx, paths.length - 1)) : 0; loadTrack(queue[queueIndex], false); }, + syncPositionDisplay(pos, totalDur) { + if (!isFinite(pos) || pos < 0) return; + const dur = (totalDur > 0 && isFinite(totalDur)) ? totalDur : (isFinite(med.duration) ? med.duration : 0); + const pct = dur > 0 ? (pos / dur) * 100 : 0; + if (!isSeeking) [seekBar, fsSeekBar].forEach(s => { s.value = pct; }); + const curStr = formatTime(pos); + timeCurrent.textContent = curStr; + fsTimeCurrent.textContent = curStr; + if (dur > 0) { + const totStr = formatTime(dur); + timeTotal.textContent = totStr; + fsTimeTotal.textContent = totStr; + } + }, }; })(); @@ -1810,10 +1824,12 @@ const SyncManager = (() => { } function getPlayerState() { + const audio = document.getElementById('audio'); return { queue: Player.getQueue(), index: Player.getQueueIndex(), - position: (() => { const e = document.getElementById('audio'); return isFinite(e.currentTime) ? e.currentTime : 0; })(), + position: isFinite(audio.currentTime) ? audio.currentTime : 0, + duration: isFinite(audio.duration) ? audio.duration : 0, sortKey: localStorage.getItem('snap_sort_key') || 'name', sortDir: localStorage.getItem('snap_sort_dir') || 'asc', deviceId: myDeviceId, @@ -1853,6 +1869,12 @@ const SyncManager = (() => { if (cmd.data?.seekTo != null) e.currentTime = cmd.data.seekTo; break; } + case 'loadqueue': { + if (Array.isArray(cmd.data?.queue) && cmd.data.queue.length) { + Player.startQueue(cmd.data.queue, cmd.data.index ?? 0); + } + break; + } } } @@ -1866,10 +1888,12 @@ const SyncManager = (() => { updateTakeoverBtn(); if (isActiveDevice()) { + // Push current position so non-active devices stay in sync + if (!Player.paused()) push(); // Execute any remote command pushed by another device if (state.pendingCommand) { executeCommand(state.pendingCommand); - setTimeout(() => push(), 300); + if (Player.paused()) setTimeout(() => push(), 300); } } else { // Sync queue/track display from active device (no auto-play) @@ -1879,6 +1903,10 @@ const SyncManager = (() => { Player.syncQueue(state.queue, state.index); } } + // Sync playback position display + if (typeof state.position === 'number') { + Player.syncPositionDisplay(state.position, state.duration); + } // If we just lost active status (another device took over), pause local audio if (prevActiveId === myDeviceId && !Player.paused()) { Player.playPause(); @@ -1898,10 +1926,21 @@ const SyncManager = (() => { // ── Takeover button handlers ────────────────────────────────────────────────── function takeoverHere() { if (!serverState) return; - Player.loadState({ queue: serverState.queue, index: serverState.index, position: serverState.position }); + // Claim active status before calling loadState so startQueue isn't redirected back + const snap = { ...serverState }; serverState = { ...serverState, activeDeviceId: myDeviceId }; updateTakeoverBtn(); - push(); // claim this device + takeoverBanner.hidden = true; + if (fsTakeoverRow) fsTakeoverRow.hidden = true; + Player.loadState({ queue: snap.queue, index: snap.index, position: snap.position }); + push(); // notify server + } + + function showTakeover() { + if (!takeoverShown) { + takeoverShown = true; + takeoverBanner.hidden = false; + } } takeoverBtn.addEventListener('click', () => { takeoverBanner.hidden = true; takeoverHere(); }); @@ -1910,11 +1949,11 @@ const SyncManager = (() => { // ── Transport interception for non-active device ────────────────────────────── // Capturing listeners fire before Player's bubble listeners on the same element. - function makeTransportGuard(type) { + function makeTransportGuard(type, data) { return function(e) { if (hasActiveDevice()) { e.stopImmediatePropagation(); - sendCommand(type); + sendCommand(type, data); } }; } @@ -1927,6 +1966,18 @@ const SyncManager = (() => { ['btn-play', 'fs-btn-play'].forEach(id => { document.getElementById(id)?.addEventListener('click', makeTransportGuard('playpause'), true); }); + document.getElementById('fs-btn-skip-back')?.addEventListener('click', makeTransportGuard('skip', { seconds: -30 }), true); + document.getElementById('fs-btn-skip-fwd')?.addEventListener('click', makeTransportGuard('skip', { seconds: 30 }), true); + + // ── Seek bar interception for non-active device ─────────────────────────────── + ['seek-bar', 'fs-seek-bar'].forEach(id => { + document.getElementById(id)?.addEventListener('change', e => { + if (!hasActiveDevice()) return; + const audio = document.getElementById('audio'); + const dur = isFinite(audio.duration) ? audio.duration : (serverState?.duration || 0); + if (dur > 0) sendCommand('seek', { seekTo: (parseFloat(e.target.value) / 100) * dur }); + }); + }); // ── Wire into audio events ──────────────────────────────────────────────────── document.getElementById('audio').addEventListener('play', () => { startPolling(); push(); }); @@ -1958,7 +2009,7 @@ const SyncManager = (() => { startPolling(); } - return { init, push, sendCommand, isActiveDevice, hasActiveDevice, myDeviceId }; + return { init, push, sendCommand, isActiveDevice, hasActiveDevice, showTakeover, myDeviceId }; })(); // ── Playlists ───────────────────────────────────────────────────────────────── @@ -2450,6 +2501,20 @@ Player.loadState = function({ queue, index, position }) { } }; +// ── Route track selection through active device ─────────────────────────────── +// When another device is playing, selecting a track sends it to that device +// instead of playing locally. The takeover banner appears so the user can +// optionally switch playback to this device. +const _origStartQueue = Player.startQueue; +Player.startQueue = function(paths, idx) { + if (SyncManager.hasActiveDevice()) { + SyncManager.sendCommand('loadqueue', { queue: paths, index: idx || 0 }); + SyncManager.showTakeover(); + return; + } + _origStartQueue.call(this, paths, idx); +}; + // ── Wire sort changes into SyncManager ─────────────────────────────────────── // Patch the sort buttons rendered by FileBrowser to push state on change. // We do this by observing clicks on .sort-btn elements via event delegation. diff --git a/server.js b/server.js index f7714e0..b6bdf34 100644 --- a/server.js +++ b/server.js @@ -205,12 +205,13 @@ app.get('/api/state', requireAuth, (req, res) => { }); app.put('/api/state', requireAuth, (req, res) => { - const { queue, index, position, sortKey, sortDir, deviceId } = req.body || {}; + const { queue, index, position, duration, sortKey, sortDir, deviceId } = req.body || {}; const state = getUserState(); state[req.user.id] = { queue: Array.isArray(queue) ? queue : (state[req.user.id]?.queue || []), index: typeof index === 'number' ? index : (state[req.user.id]?.index ?? -1), position: typeof position === 'number' ? position : (state[req.user.id]?.position || 0), + duration: typeof duration === 'number' ? duration : (state[req.user.id]?.duration || 0), sortKey: sortKey || state[req.user.id]?.sortKey || 'name', sortDir: sortDir || state[req.user.id]?.sortDir || 'asc', activeDeviceId: deviceId || state[req.user.id]?.activeDeviceId || null, @@ -223,7 +224,7 @@ app.put('/api/state', requireAuth, (req, res) => { app.post('/api/command', requireAuth, (req, res) => { const { type, data } = req.body || {}; - const valid = ['playpause', 'next', 'prev', 'seek', 'skip']; + const valid = ['playpause', 'next', 'prev', 'seek', 'skip', 'loadqueue']; if (!valid.includes(type)) return res.status(400).json({ error: 'Invalid type' }); const state = getUserState(); if (!state[req.user.id]) return res.status(404).json({ error: 'No active state' }); From 1790cc7f741377fe0d589ff57a9e95b0e8e969ce Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 20:11:21 +0100 Subject: [PATCH 07/11] Reduce sync poll interval to 1s for near-real-time progress on non-active devices Co-Authored-By: Claude Sonnet 4.6 --- public/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index 4069c8c..a4de2da 100644 --- a/public/app.js +++ b/public/app.js @@ -1916,7 +1916,7 @@ const SyncManager = (() => { function startPolling() { if (pollTimer) return; - pollTimer = setInterval(pollAndSync, 3000); + pollTimer = setInterval(pollAndSync, 1000); } function stopPolling() { From 5a0acddc502fa82712ed822a4fa10645b7bf4175 Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 20:24:49 +0100 Subject: [PATCH 08/11] Fix video sync: correct media element for position/duration/seek - Add getDuration(), getPosition(), seekTo() to Player so SyncManager always uses the active media element (video or audio) rather than hardcoding audio - Fix getPlayerState() to use Player.getDuration/getPosition so video position and duration are correctly pushed to the server - Fix executeCommand('seek') to use Player.seekTo() so seeks land on the video element when a video is playing - Fix seek bar interception to use serverState.duration (already correct for video) instead of document.getElementById('audio').duration - Rewrite syncQueue to be display-only: updates title/art/metadata without loading any media stream, and exits video mode so the non-active device shows a static artwork image instead of attempting to play the video Co-Authored-By: Claude Sonnet 4.6 --- public/app.js | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/public/app.js b/public/app.js index a4de2da..781f345 100644 --- a/public/app.js +++ b/public/app.js @@ -761,13 +761,45 @@ const Player = (() => { startQueue, addToEnd, addAfterCurrent, jumpTo, playPause, playNext, playPrev, restore, removeFromQueue, reorderQueue, isCurrentVideo: () => currentIsVideo, + getDuration: () => isFinite(med.duration) ? med.duration : 0, + getPosition: () => isFinite(med.currentTime) ? med.currentTime : 0, + seekTo: (pos) => { + if (isFinite(med.duration)) med.currentTime = Math.max(0, Math.min(med.duration, pos)); + else med.currentTime = Math.max(0, pos); + }, skip: (secs) => { med.currentTime = Math.max(0, Math.min(isFinite(med.duration) ? med.duration : Infinity, med.currentTime + secs)); }, syncQueue(paths, idx) { if (!Array.isArray(paths) || !paths.length) return; queue = [...paths]; originalQueue = null; queueIndex = typeof idx === 'number' ? Math.max(0, Math.min(idx, paths.length - 1)) : 0; - loadTrack(queue[queueIndex], false); + const path = queue[queueIndex]; + if (path === currentPath) return; + currentPath = path; + lastGoodPosition = 0; + + // Non-active device: show artwork display only — never load video or audio stream + if (currentIsVideo) setMediaMode(false); // switch away from video mode to show art + syncArt(path); + + const name = path.split('/').pop().replace(/\.[^.]+$/, ''); + fsTitle.textContent = name; + fsArtist.textContent = ''; + fetch(`/api/metadata?path=${enc(path)}`) + .then(r => r.json()) + .then(meta => { + fsTitle.textContent = meta.title || name; + fsArtist.textContent = meta.artist || ''; + MediaSessionManager.update({ title: meta.title || name, artist: meta.artist || '', artworkPath: path }); + }) + .catch(() => { + MediaSessionManager.update({ title: name, artist: '', artworkPath: path }); + }); + + FileBrowser.setPlaying(path); + QueuePanel.refresh(); + playerBar.hidden = false; + document.body.classList.remove('player-hidden'); }, syncPositionDisplay(pos, totalDur) { if (!isFinite(pos) || pos < 0) return; @@ -1824,12 +1856,11 @@ const SyncManager = (() => { } function getPlayerState() { - const audio = document.getElementById('audio'); return { queue: Player.getQueue(), index: Player.getQueueIndex(), - position: isFinite(audio.currentTime) ? audio.currentTime : 0, - duration: isFinite(audio.duration) ? audio.duration : 0, + position: Player.getPosition(), + duration: Player.getDuration(), sortKey: localStorage.getItem('snap_sort_key') || 'name', sortDir: localStorage.getItem('snap_sort_dir') || 'asc', deviceId: myDeviceId, @@ -1865,8 +1896,7 @@ const SyncManager = (() => { case 'prev': Player.playPrev(); break; case 'skip': Player.skip(cmd.data?.seconds || 0); break; case 'seek': { - const e = document.getElementById('audio'); - if (cmd.data?.seekTo != null) e.currentTime = cmd.data.seekTo; + if (cmd.data?.seekTo != null) Player.seekTo(cmd.data.seekTo); break; } case 'loadqueue': { @@ -1973,8 +2003,7 @@ const SyncManager = (() => { ['seek-bar', 'fs-seek-bar'].forEach(id => { document.getElementById(id)?.addEventListener('change', e => { if (!hasActiveDevice()) return; - const audio = document.getElementById('audio'); - const dur = isFinite(audio.duration) ? audio.duration : (serverState?.duration || 0); + const dur = serverState?.duration || 0; if (dur > 0) sendCommand('seek', { seekTo: (parseFloat(e.target.value) / 100) * dur }); }); }); From a2efff9faa65722454bd7e6a7a6bfced04ef79f5 Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 20:35:18 +0100 Subject: [PATCH 09/11] Fix command double-execution and missing play/pause sync on non-active devices - Fix video unpausing then immediately re-pausing: active device was not pushing after executing a play command (only pushed when result was paused), so the next poll still saw the same pendingCommand and executed it again. Now always push 200ms after any command execution to clear it from the server. - Separate the position-push path (no pending command + playing) from the command-execution path so they can't interfere with each other. - Add playing boolean to server state and getPlayerState() so non-active devices know whether the active device is playing or paused. - Call Player.syncPlayState(state.playing) on each poll so the play/pause button icon stays in sync on non-active devices. Co-Authored-By: Claude Sonnet 4.6 --- public/app.js | 22 ++++++++++++++-------- server.js | 3 ++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/public/app.js b/public/app.js index 781f345..7a84e7c 100644 --- a/public/app.js +++ b/public/app.js @@ -760,9 +760,10 @@ const Player = (() => { paused, isActive, getCurrentPath, getQueue, getQueueIndex, startQueue, addToEnd, addAfterCurrent, jumpTo, playPause, playNext, playPrev, restore, removeFromQueue, reorderQueue, - isCurrentVideo: () => currentIsVideo, - getDuration: () => isFinite(med.duration) ? med.duration : 0, - getPosition: () => isFinite(med.currentTime) ? med.currentTime : 0, + isCurrentVideo: () => currentIsVideo, + getDuration: () => isFinite(med.duration) ? med.duration : 0, + getPosition: () => isFinite(med.currentTime) ? med.currentTime : 0, + syncPlayState: (playing) => syncPlayIcon(playing), seekTo: (pos) => { if (isFinite(med.duration)) med.currentTime = Math.max(0, Math.min(med.duration, pos)); else med.currentTime = Math.max(0, pos); @@ -1861,6 +1862,7 @@ const SyncManager = (() => { index: Player.getQueueIndex(), position: Player.getPosition(), duration: Player.getDuration(), + playing: !Player.paused(), sortKey: localStorage.getItem('snap_sort_key') || 'name', sortDir: localStorage.getItem('snap_sort_dir') || 'asc', deviceId: myDeviceId, @@ -1918,12 +1920,13 @@ const SyncManager = (() => { updateTakeoverBtn(); if (isActiveDevice()) { - // Push current position so non-active devices stay in sync - if (!Player.paused()) push(); - // Execute any remote command pushed by another device if (state.pendingCommand) { + // Execute command then always push to clear pendingCommand from server executeCommand(state.pendingCommand); - if (Player.paused()) setTimeout(() => push(), 300); + setTimeout(() => push(), 200); + } else if (!Player.paused()) { + // Periodic position push so non-active devices stay in sync + push(); } } else { // Sync queue/track display from active device (no auto-play) @@ -1933,10 +1936,13 @@ const SyncManager = (() => { Player.syncQueue(state.queue, state.index); } } - // Sync playback position display + // Sync playback position and play/pause icon if (typeof state.position === 'number') { Player.syncPositionDisplay(state.position, state.duration); } + if (typeof state.playing === 'boolean') { + Player.syncPlayState(state.playing); + } // If we just lost active status (another device took over), pause local audio if (prevActiveId === myDeviceId && !Player.paused()) { Player.playPause(); diff --git a/server.js b/server.js index b6bdf34..f7d8d03 100644 --- a/server.js +++ b/server.js @@ -205,13 +205,14 @@ app.get('/api/state', requireAuth, (req, res) => { }); app.put('/api/state', requireAuth, (req, res) => { - const { queue, index, position, duration, sortKey, sortDir, deviceId } = req.body || {}; + const { queue, index, position, duration, playing, sortKey, sortDir, deviceId } = req.body || {}; const state = getUserState(); state[req.user.id] = { queue: Array.isArray(queue) ? queue : (state[req.user.id]?.queue || []), index: typeof index === 'number' ? index : (state[req.user.id]?.index ?? -1), position: typeof position === 'number' ? position : (state[req.user.id]?.position || 0), duration: typeof duration === 'number' ? duration : (state[req.user.id]?.duration || 0), + playing: typeof playing === 'boolean' ? playing : (state[req.user.id]?.playing || false), sortKey: sortKey || state[req.user.id]?.sortKey || 'name', sortDir: sortDir || state[req.user.id]?.sortDir || 'asc', activeDeviceId: deviceId || state[req.user.id]?.activeDeviceId || null, From e3e0b62720b94bd8ad6c8e0e3f00a9ef0d4ea08c Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 20:44:39 +0100 Subject: [PATCH 10/11] Center video mode transport controls horizontally Co-Authored-By: Claude Sonnet 4.6 --- public/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/style.css b/public/style.css index 8c52237..a85a5fe 100644 --- a/public/style.css +++ b/public/style.css @@ -977,8 +977,12 @@ input[type=range]::-moz-range-thumb { transition: opacity 0.25s; } +.fullscreen-player.video-mode .fs-controls, .fullscreen-player.video-mode .fs-extra { justify-content: center; +} + +.fullscreen-player.video-mode .fs-extra { padding-bottom: calc(16px + env(safe-area-inset-bottom)); } From 043596aefa53087c121d889b43b8b818741b348e Mon Sep 17 00:00:00 2001 From: Luke Hallinan Date: Sun, 31 May 2026 20:56:21 +0100 Subject: [PATCH 11/11] =?UTF-8?q?Hide=20password=20confirm=20field=20on=20?= =?UTF-8?q?login=20=E2=80=94=20display:flex=20was=20overriding=20hidden=20?= =?UTF-8?q?attribute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- public/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/public/style.css b/public/style.css index a85a5fe..c4c7f3c 100644 --- a/public/style.css +++ b/public/style.css @@ -1028,6 +1028,7 @@ input[type=range]::-moz-range-thumb { .login-box { background:var(--surface); border:1px solid var(--border); border-radius:16px; padding:32px; width:min(360px,90vw); display:flex; flex-direction:column; gap:16px; } .login-box h1 { color:var(--accent); font-size:24px; text-align:center; } .login-field { display:flex; flex-direction:column; gap:6px; } +.login-field[hidden] { display:none; } .login-field label { font-size:13px; color:var(--text-muted); } .login-field input { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:8px; padding:10px 12px; font-size:14px; outline:none; } .login-field input:focus { border-color:var(--accent); }