diff --git a/Dockerfile b/Dockerfile
index e89eceb..17b8110 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,6 +3,9 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --omit=dev
COPY server.js .
+COPY lib/ lib/
+COPY middleware/ middleware/
+COPY routes/ routes/
COPY public/ public/
EXPOSE 3000
CMD ["node", "server.js"]
diff --git a/lib/data.js b/lib/data.js
new file mode 100644
index 0000000..9f097c6
--- /dev/null
+++ b/lib/data.js
@@ -0,0 +1,34 @@
+const path = require('path');
+const fs = require('fs');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const SESSIONS_DIR = path.join(DATA_DIR, 'sessions');
+const USERS_FILE = path.join(DATA_DIR, 'users.json');
+const USERSTATE_FILE = path.join(DATA_DIR, 'userstate.json');
+const PLAYLISTS_FILE = path.join(DATA_DIR, 'playlists.json');
+
+for (const dir of [DATA_DIR, SESSIONS_DIR]) {
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+}
+for (const file of [USERS_FILE, USERSTATE_FILE, PLAYLISTS_FILE]) {
+ if (!fs.existsSync(file)) {
+ fs.writeFileSync(file, file === USERS_FILE || file === PLAYLISTS_FILE ? '[]' : '{}');
+ }
+}
+
+function readJSON(file) {
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return file.endsWith('userstate.json') ? {} : []; }
+}
+function writeJSON(file, data) {
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
+}
+
+module.exports = {
+ DATA_DIR, SESSIONS_DIR, USERS_FILE, USERSTATE_FILE, PLAYLISTS_FILE,
+ getUsers: () => readJSON(USERS_FILE),
+ saveUsers: u => writeJSON(USERS_FILE, u),
+ getUserState: () => readJSON(USERSTATE_FILE),
+ saveUserState: s => writeJSON(USERSTATE_FILE, s),
+ getPlaylists: () => readJSON(PLAYLISTS_FILE),
+ savePlaylists: p => writeJSON(PLAYLISTS_FILE, p),
+};
diff --git a/lib/media.js b/lib/media.js
new file mode 100644
index 0000000..bb3d9ac
--- /dev/null
+++ b/lib/media.js
@@ -0,0 +1,19 @@
+const path = require('path');
+const MEDIA_ROOT = path.resolve(process.env.MEDIA_ROOT || '/media');
+const AUDIO_EXTS = new Set(['.mp3', '.flac', '.ogg', '.m4a', '.mp4', '.aac', '.wav', '.opus', '.wma', '.m4b', '.mp4a']);
+const VIDEO_EXTS = new Set(['.mkv', '.webm', '.avi', '.mov', '.m4v', '.mpg', '.mpeg', '.wmv']);
+const ARTWORK_NAMES = ['folder.jpg', 'folder.jpeg', 'folder.png', 'cover.jpg', 'cover.jpeg', 'cover.png', 'album.jpg', 'album.png'];
+
+function safePath(relPath) {
+ const normalized = path.normalize(relPath || '');
+ const full = path.join(MEDIA_ROOT, normalized);
+ if (!full.startsWith(MEDIA_ROOT)) throw new Error('Path traversal denied');
+ return full;
+}
+function canAccess(user, relPath) {
+ if (user.role === 'admin') return true;
+ if (!relPath) return true;
+ return user.allowedPaths.some(p => relPath === p || relPath.startsWith(p + '/'));
+}
+
+module.exports = { MEDIA_ROOT, AUDIO_EXTS, VIDEO_EXTS, ARTWORK_NAMES, safePath, canAccess };
diff --git a/middleware/auth.js b/middleware/auth.js
new file mode 100644
index 0000000..35f294a
--- /dev/null
+++ b/middleware/auth.js
@@ -0,0 +1,22 @@
+const { getUsers } = require('../lib/data.js');
+
+function validUsername(u) { return typeof u === 'string' && /^[a-zA-Z0-9_-]{3,32}$/.test(u); }
+function validPassword(p) { return typeof p === 'string' && p.length >= 8; }
+
+function requireAuth(req, res, next) {
+ if (!req.session.userId) return res.status(401).json({ error: 'Not authenticated' });
+ const users = getUsers();
+ const user = users.find(u => u.id === req.session.userId);
+ if (!user) { req.session.destroy(() => {}); return res.status(401).json({ error: 'Not authenticated' }); }
+ req.user = user;
+ next();
+}
+
+function requireAdmin(req, res, next) {
+ requireAuth(req, res, () => {
+ if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
+ next();
+ });
+}
+
+module.exports = { validUsername, validPassword, requireAuth, requireAdmin };
diff --git a/public/app.js b/public/app.js
deleted file mode 100644
index 14fc5a8..0000000
--- a/public/app.js
+++ /dev/null
@@ -1,2661 +0,0 @@
-'use strict';
-
-// ── Remote logging (shows up in docker logs) ──────────────────────────────────
-function clog(event, data) {
- try {
- const body = JSON.stringify({ event, data });
- navigator.sendBeacon('/api/clientlog', new Blob([body], { type: 'application/json' }));
- } catch (_) {}
-}
-
-// ── Utilities ────────────────────────────────────────────────────────────────
-
-function formatTime(sec) {
- if (!isFinite(sec) || sec < 0) return '0:00';
- const h = Math.floor(sec / 3600);
- const m = Math.floor((sec % 3600) / 60);
- const s = Math.floor(sec % 60);
- if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
- return `${m}:${String(s).padStart(2, '0')}`;
-}
-
-function formatSize(bytes) {
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
-}
-
-function enc(p) { return encodeURIComponent(p); }
-
-// ── Icon SVGs ─────────────────────────────────────────────────────────────────
-const S = 'stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"';
-const icons = {
- play: ``,
- pause: ``,
- prev: ``,
- next: ``,
- shuffle: ``,
- repeat: ``,
- repeatOne: ``,
- close: ``,
- note: ``,
- folder: ``,
- check: ``,
- drag: ``,
- menu: ``,
- grid: ``,
- volume: ``,
- playSmall: ``,
-};
-
-// ── Media Session (lock screen controls + OS background audio permission) ─────
-
-const MediaSessionManager = (() => {
- const ms = navigator.mediaSession;
- if (!ms) return { update: () => {}, setPlaying: () => {}, setPosition: () => {} };
-
- // Wire OS lock-screen buttons → Player (forward refs resolved at call time)
- const actions = {
- play: () => Player.playPause(),
- pause: () => Player.playPause(),
- previoustrack: () => Player.playPrev(),
- nexttrack: () => Player.playNext(),
- seekto: d => { const el = document.getElementById('video').hidden === false ? document.getElementById('video') : document.getElementById('audio'); el.currentTime = d.seekTime; },
- seekbackward: d => { Player.skip(-(d.seekOffset || 10)); },
- seekforward: d => { Player.skip(d.seekOffset || 10); },
- };
- for (const [action, handler] of Object.entries(actions)) {
- try { ms.setActionHandler(action, handler); } catch (_) {}
- }
-
- function update({ title, artist, artworkPath }) {
- const artwork = artworkPath
- ? [{ src: `/api/artwork?path=${enc(artworkPath)}`, sizes: '512x512', type: 'image/jpeg' }]
- : [];
- ms.metadata = new MediaMetadata({ title: title || 'Unknown', artist: artist || '', artwork });
- }
-
- function setPlaying(playing) {
- ms.playbackState = playing ? 'playing' : 'paused';
- }
-
- function setPosition(current, duration, rate) {
- if (!isFinite(duration) || duration <= 0) return;
- try {
- ms.setPositionState({ duration, playbackRate: rate || 1, position: current });
- } catch (_) {}
- }
-
- return { update, setPlaying, setPosition };
-})();
-
-// ── Wake Lock ─────────────────────────────────────────────────────────────────
-
-const WakeLock = (() => {
- let lock = null;
-
- async function acquire() {
- if (!('wakeLock' in navigator)) return;
- try { lock = await navigator.wakeLock.request('screen'); }
- catch (e) { console.warn('Wake lock failed:', e); }
- }
-
- async function release() {
- if (lock) { await lock.release().catch(() => {}); lock = null; }
- }
-
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible' && !Player.paused()) acquire();
- });
-
- return { acquire, release };
-})();
-
-// ── Player ────────────────────────────────────────────────────────────────────
-
-const Player = (() => {
- const audioEl = document.getElementById('audio');
- const videoEl = document.getElementById('video');
- let med = audioEl; // currently active media element
- let currentIsVideo = false;
-
- const VIDEO_EXTS = new Set(['.mp4', '.m4v', '.mkv', '.webm', '.avi', '.mov', '.mpg', '.mpeg', '.wmv']);
- function isVideoPath(p) {
- const i = p.lastIndexOf('.');
- return i !== -1 && VIDEO_EXTS.has(p.slice(i).toLowerCase());
- }
-
- // Attach a handler to both elements; fires only when that element is the active one
- function onBoth(event, fn) {
- [audioEl, videoEl].forEach(el => el.addEventListener(event, function(e) {
- if (this !== med) return;
- fn.call(this, e);
- }));
- }
-
- // Player bar
- const playerBar = document.getElementById('player-bar');
- const artImg = document.getElementById('player-art-img');
- const btnPlay = document.getElementById('btn-play');
- const btnPrev = document.getElementById('btn-prev');
- const btnNext = document.getElementById('btn-next');
- const btnShuffle = document.getElementById('btn-shuffle');
- const btnRepeat = document.getElementById('btn-repeat');
- const seekBar = document.getElementById('seek-bar');
- const timeCurrent = document.getElementById('time-current');
- const timeTotal = document.getElementById('time-total');
- const volumeBar = document.getElementById('volume-bar');
-
- // Fullscreen
- const fsArtImg = document.getElementById('fs-art-img');
- const fsArtFallback = document.querySelector('.fs-art-fallback');
- const playerArtBtn = document.getElementById('player-art-btn');
- const fsBtnRotate = document.getElementById('fs-btn-rotate');
- const fsTitle = document.getElementById('fs-title');
- const fsArtist = document.getElementById('fs-artist');
- const fsBtnPlay = document.getElementById('fs-btn-play');
- const fsBtnPrev = document.getElementById('fs-btn-prev');
- const fsBtnNext = document.getElementById('fs-btn-next');
- const fsBtnShuffle = document.getElementById('fs-btn-shuffle');
- const fsBtnRepeat = document.getElementById('fs-btn-repeat');
- const fsSeekBar = document.getElementById('fs-seek-bar');
- const fsTimeCurrent = document.getElementById('fs-time-current');
- const fsTimeTotal = document.getElementById('fs-time-total');
-
- let queue = [];
- let originalQueue = null; // saved copy when shuffle is on
- let queueIndex = -1;
- let shuffleMode = false;
- 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';
- let lastSaveAt = 0;
- let lastGoodPosition = 0; // last currentTime > 1s, survives connection resets
-
- function saveState() {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify({
- queue, originalQueue, queueIndex,
- shuffleMode, repeatMode, currentPath,
- position: isFinite(med.currentTime) ? med.currentTime : 0,
- }));
- } catch (_) {}
- }
-
- // ── Queries ──
- function paused() { return med.paused; }
- function isActive() { return queue.length > 0; }
- function getQueue() { return [...queue]; }
- function getQueueIndex() { return queueIndex; }
- function getCurrentPath() { return currentPath; }
-
- // ── UI sync ──
- function syncPlayIcon(playing) {
- btnPlay.innerHTML = playing ? icons.pause : icons.play;
- fsBtnPlay.innerHTML = playing ? icons.pause : icons.play;
- }
-
- function syncShuffleIcon() {
- [btnShuffle, fsBtnShuffle].forEach(b => {
- b.classList.toggle('active', shuffleMode);
- b.blur();
- });
- }
-
- function syncRepeatIcon() {
- const active = repeatMode !== 'off';
- [btnRepeat, fsBtnRepeat].forEach(b => {
- b.classList.toggle('active', active);
- b.innerHTML = repeatMode === 'one' ? icons.repeatOne : icons.repeat;
- b.blur();
- });
- }
-
- function syncSeek() {
- if (isSeeking) return;
- const pct = isFinite(med.duration) ? (med.currentTime / med.duration) * 100 : 0;
- [seekBar, fsSeekBar].forEach(s => { s.value = pct; });
- const cur = formatTime(med.currentTime);
- timeCurrent.textContent = cur;
- fsTimeCurrent.textContent = cur;
- if (isFinite(med.duration)) {
- const tot = formatTime(med.duration);
- timeTotal.textContent = tot;
- fsTimeTotal.textContent = tot;
- }
- }
-
- function syncArt(path) {
- const url = `/api/artwork?path=${enc(path)}`;
- [artImg, fsArtImg].forEach(img => {
- img.src = url;
- img.onerror = () => { img.src = ''; };
- });
- }
-
- // ── setMediaMode ──
- function setMediaMode(isVideo) {
- currentIsVideo = isVideo;
- med = isVideo ? videoEl : audioEl;
- if (isVideo) {
- audioEl.pause(); audioEl.removeAttribute('src'); audioEl.load();
- fsArtImg.style.display = 'none';
- fsArtFallback.style.display = 'none';
- videoEl.style.display = 'block';
- playerArtBtn.classList.add('video-mode');
- fsBtnRotate.hidden = false;
- document.getElementById('fullscreen-player').classList.add('video-mode');
- } else {
- videoEl.pause(); videoEl.removeAttribute('src'); videoEl.load();
- videoEl.style.display = 'none';
- fsArtImg.style.display = '';
- fsArtFallback.style.display = '';
- playerArtBtn.classList.remove('video-mode');
- fsBtnRotate.hidden = true;
- document.getElementById('fullscreen-player').classList.remove('video-mode');
- }
- }
-
- // ── Load & play ──
- async function loadTrack(path, play = true) {
- const isVideo = isVideoPath(path);
- if (isVideo !== currentIsVideo) setMediaMode(isVideo);
-
- playerBar.hidden = false;
- document.body.classList.remove('player-hidden');
- currentPath = path;
- lastGoodPosition = 0; // new track - don't restore old position on play events
- clearTimeout(loadTimeoutTimer); loadTimeoutTimer = null;
- saveState();
- med.src = `/api/stream?path=${enc(path)}`;
- med.load();
-
- [seekBar, fsSeekBar].forEach(s => { s.value = 0; });
- [timeCurrent, fsTimeCurrent, timeTotal, fsTimeTotal].forEach(t => { t.textContent = '0:00'; });
-
- 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 || '';
- if (meta.duration) {
- const tot = formatTime(meta.duration);
- timeTotal.textContent = tot;
- fsTimeTotal.textContent = tot;
- }
- MediaSessionManager.update({ title: meta.title || name, artist: meta.artist || '', artworkPath: path });
- })
- .catch(() => {
- MediaSessionManager.update({ title: name, artist: '', artworkPath: path });
- });
-
- if (!currentIsVideo) syncArt(path);
- FileBrowser.setPlaying(path);
- QueuePanel.refresh();
-
- if (play) {
- try {
- await med.play();
- syncPlayIcon(true);
- WakeLock.acquire();
- if (isVideo) FullscreenPlayer.open();
- } catch (e) { console.warn('Playback failed:', e); }
- }
- }
-
- // ── Controls ──
- function playPause() {
- if (med.paused) {
- 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);
- WakeLock.release();
- }
- }
-
- function playNext() {
- if (repeatMode === 'one') { med.currentTime = 0; med.play(); return; }
- if (queueIndex < queue.length - 1) {
- queueIndex++;
- } else if (repeatMode === 'queue') {
- queueIndex = 0;
- } else {
- return;
- }
- loadTrack(queue[queueIndex]);
- }
-
- function playPrev() {
- if (med.currentTime > 3) { med.currentTime = 0; return; }
- if (queueIndex > 0) {
- queueIndex--;
- loadTrack(queue[queueIndex]);
- } else if (repeatMode === 'queue') {
- queueIndex = queue.length - 1;
- loadTrack(queue[queueIndex]);
- } else {
- med.currentTime = 0;
- }
- }
-
- // ── Queue management ──
- function startQueue(paths, startIndex) {
- queue = [...paths];
- originalQueue = null;
- if (shuffleMode) {
- originalQueue = [...queue];
- doShuffle(startIndex);
- } else {
- queueIndex = startIndex;
- }
- loadTrack(queue[queueIndex]);
- }
-
- function addToEnd(path) {
- queue.push(path);
- if (originalQueue) originalQueue.push(path);
- QueuePanel.refresh();
- saveState();
- }
-
- function addAfterCurrent(path) {
- queue.splice(queueIndex + 1, 0, path);
- if (originalQueue) originalQueue.splice(originalQueue.length, 0, path);
- QueuePanel.refresh();
- saveState();
- }
-
- function jumpTo(index) {
- queueIndex = index;
- loadTrack(queue[queueIndex]);
- }
-
- // ── Shuffle ──
- function doShuffle(currentIdx) {
- const current = queue[currentIdx];
- const rest = queue.filter((_, i) => i !== currentIdx);
- for (let i = rest.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [rest[i], rest[j]] = [rest[j], rest[i]];
- }
- queue = [current, ...rest];
- queueIndex = 0;
- }
-
- function toggleShuffle() {
- shuffleMode = !shuffleMode;
- if (shuffleMode) {
- originalQueue = [...queue];
- if (queueIndex >= 0) doShuffle(queueIndex);
- } else if (originalQueue) {
- const cur = queue[queueIndex];
- queue = originalQueue;
- originalQueue = null;
- queueIndex = Math.max(0, queue.indexOf(cur));
- }
- syncShuffleIcon();
- QueuePanel.refresh();
- saveState();
- }
-
- // ── Repeat ──
- function cycleRepeat() {
- if (repeatMode === 'off') repeatMode = 'queue';
- else if (repeatMode === 'queue') repeatMode = 'one';
- else repeatMode = 'off';
- syncRepeatIcon();
- saveState();
- }
-
- // ── Event wiring ──
- btnPlay.addEventListener('click', playPause);
- fsBtnPlay.addEventListener('click', playPause);
- btnPrev.addEventListener('click', playPrev);
- fsBtnPrev.addEventListener('click', playPrev);
- btnNext.addEventListener('click', playNext);
- fsBtnNext.addEventListener('click', playNext);
- btnShuffle.addEventListener('click', toggleShuffle);
- fsBtnShuffle.addEventListener('click', toggleShuffle);
- btnRepeat.addEventListener('click', cycleRepeat);
- fsBtnRepeat.addEventListener('click', cycleRepeat);
-
- onBoth('timeupdate', function() {
- if (med.currentTime > 1) lastGoodPosition = med.currentTime;
- if (med.currentTime > 1 && loadTimeoutTimer) { clearTimeout(loadTimeoutTimer); loadTimeoutTimer = null; }
- syncSeek();
- const now = Date.now();
- if (now - lastSaveAt > 5000) { saveState(); lastSaveAt = now; }
- });
- // Keep lastGoodPosition honest after explicit seeks (including seek-to-0)
- onBoth('seeked', function() { lastGoodPosition = med.currentTime; });
- onBoth('ended', function() {
- syncPlayIcon(false);
- playNext();
- if (med.paused) WakeLock.release();
- });
- onBoth('pause', function() { syncPlayIcon(false); MediaSessionManager.setPlaying(false); clog('audio:pause', { t: Math.round(med.currentTime), lgp: Math.round(lastGoodPosition), hidden: document.hidden }); });
- onBoth('play', function() { syncPlayIcon(true); MediaSessionManager.setPlaying(true); clog('audio:play', { t: Math.round(med.currentTime), lgp: Math.round(lastGoodPosition), hidden: document.hidden }); });
- onBoth('stalled', function() { clog('audio:stalled', { t: Math.round(med.currentTime), lgp: Math.round(lastGoodPosition) }); });
- onBoth('waiting', function() { clog('audio:waiting', { t: Math.round(med.currentTime), lgp: Math.round(lastGoodPosition) }); });
- audioEl.addEventListener('error', () => clog('audio:error', { code: audioEl.error?.code, msg: audioEl.error?.message, t: Math.round(audioEl.currentTime) }));
-
- // Reconnect if the stream drops (e.g. phone locked, NAS timeout, Android throttling)
- let stallTimer = null;
- let lastStallTime = -1;
- let loadTimeoutTimer = null;
-
- function doReconnect(pos) {
- if (med !== audioEl) return;
- clearInterval(stallTimer);
- clearTimeout(loadTimeoutTimer);
- stallTimer = null;
- loadTimeoutTimer = null;
- clog('reconnect', { pos: Math.round(pos), lgp: Math.round(lastGoodPosition), hidden: document.hidden });
- if (pos > 0) audioEl.addEventListener('loadedmetadata', () => { audioEl.currentTime = pos; }, { once: true });
- // audioEl.load() aborts the existing HTTP request and frees the browser's
- // connection slot before we open a fresh one — without it, rapid reconnects
- // exhaust all 6 HTTP/1.1 slots and every subsequent request stalls immediately.
- audioEl.load();
- audioEl.play().catch(() => {
- // play() rejected while screen is off — retry when screen wakes
- if (document.hidden) {
- document.addEventListener('visibilitychange', function retryPlay() {
- if (!document.hidden) {
- document.removeEventListener('visibilitychange', retryPlay);
- audioEl.play().catch(() => {});
- }
- });
- }
- });
- // If audio doesn't advance past 1 s within 20 s, the reconnect itself stalled
- loadTimeoutTimer = setTimeout(() => {
- loadTimeoutTimer = null;
- if (currentPath && !audioEl.paused && audioEl.currentTime < 1) {
- clog('reconnect:timeout', { lgp: Math.round(lastGoodPosition) });
- doReconnect(lastGoodPosition);
- }
- }, 20000);
- }
-
- function startStallWatch() {
- if (med !== audioEl) return;
- clearInterval(stallTimer);
- lastStallTime = audioEl.currentTime;
- stallTimer = setInterval(() => {
- if (audioEl.paused || !currentPath || isSeeking) { lastStallTime = audioEl.currentTime; return; }
- if (isFinite(audioEl.duration) && audioEl.currentTime >= audioEl.duration - 0.5) return;
- // Require currentTime > 0 to avoid triggering during the loading phase after a
- // fresh reconnect (the element sits at t=0 while buffering the new response).
- if (audioEl.currentTime > 0 && audioEl.currentTime === lastStallTime) {
- if (document.hidden) {
- // The stream is still TCP-alive but Firefox throttles background media
- // downloads while the screen is locked. Reconnecting just aborts a good
- // stream and opens a new one Firefox won't buffer either. Skip it — the
- // visibilitychange handler will reconnect if the stream doesn't resume
- // naturally when the screen turns on.
- clog('stall:hidden-skip', { t: Math.round(audioEl.currentTime), lgp: Math.round(lastGoodPosition) });
- return;
- }
- const pos = audioEl.currentTime > 1 ? audioEl.currentTime : lastGoodPosition;
- clog('stall:reconnect', { t: Math.round(audioEl.currentTime), pos: Math.round(pos), lgp: Math.round(lastGoodPosition), hidden: document.hidden });
- doReconnect(pos);
- } else {
- lastStallTime = audioEl.currentTime;
- }
- }, 4000);
- }
- audioEl.addEventListener('play', startStallWatch);
- audioEl.addEventListener('pause', () => {
- clearInterval(stallTimer);
- clearTimeout(loadTimeoutTimer);
- loadTimeoutTimer = null;
- });
-
- // Reload from saved position on network errors (connection dropped by router)
- audioEl.addEventListener('error', () => {
- if (!currentPath) return;
- const code = audioEl.error && audioEl.error.code;
- clog('error:handler', { code, t: Math.round(audioEl.currentTime), lgp: Math.round(lastGoodPosition) });
- if (code !== 2 && code !== 3) return; // MEDIA_ERR_NETWORK or MEDIA_ERR_DECODE only
- doReconnect(audioEl.currentTime > 1 ? audioEl.currentTime : lastGoodPosition);
- });
-
- // Tick MediaSession position so the OS lock screen scrubber stays accurate
- setInterval(() => {
- if (!med.paused) MediaSessionManager.setPosition(med.currentTime, med.duration, med.playbackRate);
- }, 1000);
-
- function setupSeekBar(bar, localTimeEl) {
- bar.addEventListener('mousedown', () => { isSeeking = true; });
- bar.addEventListener('touchstart', () => { isSeeking = true; }, { passive: true });
- bar.addEventListener('input', () => {
- if (!isFinite(med.duration)) return;
- const t = (bar.value / 100) * med.duration;
- const str = formatTime(t);
- timeCurrent.textContent = str;
- fsTimeCurrent.textContent = str;
- [seekBar, fsSeekBar].forEach(s => { s.value = bar.value; });
- });
- bar.addEventListener('change', () => {
- if (isFinite(med.duration)) med.currentTime = (bar.value / 100) * med.duration;
- isSeeking = false;
- });
- }
- setupSeekBar(seekBar, timeCurrent);
- setupSeekBar(fsSeekBar, fsTimeCurrent);
-
- volumeBar.addEventListener('input', () => { med.volume = volumeBar.value; });
-
- // Art thumbnail → fullscreen
- document.getElementById('player-art-btn').addEventListener('click', () => {
- if (currentPath) FullscreenPlayer.open();
- });
-
- // Queue view button
- document.getElementById('btn-queue-view').addEventListener('click', () => QueuePanel.open());
-
- // Fullscreen → queue
- document.getElementById('fs-btn-queue').addEventListener('click', () => {
- FullscreenPlayer.close();
- QueuePanel.open();
- });
-
- // Skip ±30s
- document.getElementById('fs-btn-skip-back').addEventListener('click', () => {
- med.currentTime = Math.max(0, med.currentTime - 30);
- });
- document.getElementById('fs-btn-skip-fwd').addEventListener('click', () => {
- if (isFinite(med.duration)) med.currentTime = Math.min(med.duration, med.currentTime + 30);
- else med.currentTime += 30;
- });
-
- // ── Restore persisted state on page load ──
- function restore() {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return;
- const s = JSON.parse(raw);
- if (!s.currentPath || !Array.isArray(s.queue) || s.queue.length === 0) return;
-
- queue = s.queue;
- originalQueue = s.originalQueue || null;
- queueIndex = s.queueIndex ?? -1;
- shuffleMode = s.shuffleMode || false;
- repeatMode = s.repeatMode || 'off';
- currentPath = s.currentPath;
-
- syncShuffleIcon();
- syncRepeatIcon();
-
- _pendingRestorePosition = s.position || 0;
- const restoreIsVideo = isVideoPath(currentPath);
- if (restoreIsVideo !== currentIsVideo) setMediaMode(restoreIsVideo);
- // Don't load media src here — deferred to first play to avoid browser "Continue playing" prompt
-
- if (!currentIsVideo) syncArt(currentPath);
-
- const name = currentPath.split('/').pop().replace(/\.[^.]+$/, '');
- fsTitle.textContent = name;
- fsArtist.textContent = '';
-
- fetch(`/api/metadata?path=${enc(currentPath)}`)
- .then(r => r.json())
- .then(meta => {
- fsTitle.textContent = meta.title || name;
- fsArtist.textContent = meta.artist || '';
- if (meta.duration) {
- const tot = formatTime(meta.duration);
- timeTotal.textContent = tot;
- fsTimeTotal.textContent = tot;
- }
- MediaSessionManager.update({ title: meta.title || name, artist: meta.artist || '', artworkPath: currentPath });
- })
- .catch(() => {
- MediaSessionManager.update({ title: name, artist: '', artworkPath: currentPath });
- });
-
- FileBrowser.setPlaying(currentPath);
- QueuePanel.refresh();
- playerBar.hidden = false;
- document.body.classList.remove('player-hidden');
- } catch (_) {}
- }
-
- function clear() {
- audioEl.pause(); audioEl.removeAttribute('src'); audioEl.load();
- videoEl.pause(); videoEl.removeAttribute('src'); videoEl.load();
- if (currentIsVideo) setMediaMode(false);
- queue = [];
- originalQueue = null;
- queueIndex = -1;
- currentPath = null;
-
- syncPlayIcon(false);
- artImg.src = '';
- fsArtImg.src = '';
- [seekBar, fsSeekBar].forEach(s => { s.value = 0; });
- [timeCurrent, fsTimeCurrent, timeTotal, fsTimeTotal].forEach(t => { t.textContent = '0:00'; });
- fsTitle.textContent = '';
- fsArtist.textContent = '';
-
- try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
-
- FileBrowser.setPlaying(null);
- QueuePanel.refresh();
- WakeLock.release();
- FullscreenPlayer.close();
-
- playerBar.hidden = true;
- document.body.classList.add('player-hidden');
- }
-
- document.getElementById('btn-clear').addEventListener('click', clear);
-
- function removeFromQueue(idx) {
- if (idx === queueIndex) return;
- const path = queue[idx];
- queue.splice(idx, 1);
- if (originalQueue) {
- const oi = originalQueue.indexOf(path);
- if (oi !== -1) originalQueue.splice(oi, 1);
- }
- if (idx < queueIndex) queueIndex--;
- saveState();
- QueuePanel.refresh();
- }
-
- function reorderQueue(fromIdx, toIdx) {
- if (fromIdx === toIdx || fromIdx === queueIndex) return;
- const [item] = queue.splice(fromIdx, 1);
- queue.splice(toIdx > fromIdx ? toIdx - 1 : toIdx, 0, item);
- queueIndex = currentPath ? queue.indexOf(currentPath) : queueIndex;
- originalQueue = queue.slice();
- saveState();
- QueuePanel.refresh();
- }
-
- let wifiKeepAlive = null;
- let bufferMonitor = null;
- document.addEventListener('visibilitychange', () => {
- clog('visibility', { hidden: document.hidden, paused: med.paused, t: Math.round(med.currentTime), lgp: Math.round(lastGoodPosition) });
- if (document.hidden) {
- if (!med.paused) {
- // Log fetch success/fail to prove network access works while locked
- wifiKeepAlive = setInterval(() => {
- fetch('/api/browse?path=', { signal: AbortSignal.timeout(5000) })
- .then(() => clog('keepalive:ok'))
- .catch(() => clog('keepalive:fail'));
- }, 15000);
- // Log Firefox's audio buffer state every 10s to see how much it buffered
- bufferMonitor = setInterval(() => {
- if (med.paused || !currentPath) return;
- const buf = med.buffered;
- const end = buf.length > 0 ? buf.end(buf.length - 1) : med.currentTime;
- clog('buffer', { t: Math.round(med.currentTime), end: Math.round(end), ahead: Math.round(end - med.currentTime) });
- }, 10000);
- }
- } else {
- clearInterval(wifiKeepAlive);
- clearInterval(bufferMonitor);
- wifiKeepAlive = null;
- bufferMonitor = null;
-
- // When the screen turns on, Firefox resumes buffering the paused stream.
- // Give it 2 seconds to advance on its own before deciding it needs a reconnect.
- if (!med.paused && currentPath) {
- const posAtWake = med.currentTime;
- setTimeout(() => {
- if (!med.paused && med.currentTime === posAtWake) {
- // Still frozen — stream must have actually died while locked
- clog('wake:reconnect', { pos: Math.round(posAtWake), lgp: Math.round(lastGoodPosition) });
- doReconnect(posAtWake > 1 ? posAtWake : lastGoodPosition);
- } else {
- clog('wake:ok', { t: Math.round(med.currentTime) });
- }
- }, 2000);
- }
- }
- });
- audioEl.addEventListener('pause', () => { clearInterval(wifiKeepAlive); clearInterval(bufferMonitor); wifiKeepAlive = null; bufferMonitor = null; });
-
- // When the browser resets the audio element to position 0 after a dropped
- // connection and then auto-resumes (the "started from the beginning" bug),
- // lastGoodPosition holds the last real position and we jump back to it.
- // This fires only when currentTime < 1 AND we had been > 1s into the track.
- audioEl.addEventListener('play', () => {
- if (audioEl.currentTime < 1 && lastGoodPosition > 1) {
- clog('play:restore', { lgp: Math.round(lastGoodPosition) });
- if (audioEl.readyState >= 1) {
- audioEl.currentTime = lastGoodPosition;
- } else {
- audioEl.addEventListener('loadedmetadata', () => { audioEl.currentTime = lastGoodPosition; }, { once: true });
- }
- }
- });
-
- return {
- 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,
- 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);
- },
- 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;
- 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;
- 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;
- }
- },
- };
-})();
-
-// ── Queue Action Modal ────────────────────────────────────────────────────────
-
-const QueueModal = (() => {
- const overlay = document.getElementById('queue-modal');
- const trackName = document.getElementById('modal-track-name');
- const btnPlayNow = document.getElementById('qm-play-now');
- const btnNext = document.getElementById('qm-play-next');
- const btnAddEnd = document.getElementById('qm-add-end');
- const btnCancel = document.getElementById('qm-cancel');
-
- // paths: array of paths to queue. startIdx: which to start at.
- // For single file: paths=[file], startIdx=0.
- // For play-all: paths=allFiles, startIdx=0.
- let pendingPaths = [];
- let pendingIdx = 0;
- // The "add next/add end" target is always pendingPaths[pendingIdx]
- function pendingPath() { return pendingPaths[pendingIdx]; }
-
- function show(title, paths, startIdx) {
- pendingPaths = paths;
- pendingIdx = startIdx;
- trackName.textContent = title;
- overlay.hidden = false;
- }
-
- function hide() { overlay.hidden = true; }
-
- btnPlayNow.addEventListener('click', () => {
- Player.startQueue(pendingPaths, pendingIdx);
- hide();
- });
-
- btnNext.addEventListener('click', () => {
- // For multi-file (play all) add all after current; for single just add one
- if (pendingPaths.length > 1) {
- pendingPaths.forEach(p => Player.addToEnd(p));
- } else {
- Player.addAfterCurrent(pendingPath());
- }
- hide();
- });
-
- btnAddEnd.addEventListener('click', () => {
- pendingPaths.forEach(p => Player.addToEnd(p));
- hide();
- });
-
- btnCancel.addEventListener('click', hide);
- overlay.addEventListener('click', e => { if (e.target === overlay) hide(); });
-
- function getPendingPaths() { return [...pendingPaths]; }
-
- return { show, getPendingPaths };
-})();
-
-// ── Queue Panel ───────────────────────────────────────────────────────────────
-
-const QueuePanel = (() => {
- const panel = document.getElementById('queue-panel');
- const listEl = document.getElementById('queue-list');
- const closeBtn = document.getElementById('queue-close');
-
- let dragFromIdx = null;
- let insertBeforeIdx = null;
-
- function clearDropIndicators() {
- listEl.querySelectorAll('.drop-before, .drop-after').forEach(el => {
- el.classList.remove('drop-before', 'drop-after');
- });
- }
-
- function setupDragHandle(handle, fromIdx) {
- handle.addEventListener('pointerdown', e => {
- if (e.button > 1) return;
- e.preventDefault();
- e.stopPropagation();
- dragFromIdx = fromIdx;
- insertBeforeIdx = null;
- handle.setPointerCapture(e.pointerId);
- handle.closest('.queue-item').classList.add('queue-dragging');
- handle.addEventListener('pointermove', onMove);
- handle.addEventListener('pointerup', onUp);
- handle.addEventListener('pointercancel', onCancel);
- });
- handle.addEventListener('click', e => e.stopPropagation());
-
- function onMove(e) {
- const qIdx = Player.getQueueIndex();
- const minInsert = qIdx + 1;
- clearDropIndicators();
- const items = Array.from(listEl.querySelectorAll('.queue-item'));
- let placed = false;
- for (let i = 0; i < items.length; i++) {
- const rect = items[i].getBoundingClientRect();
- if (e.clientY < rect.top + rect.height / 2) {
- const clamped = Math.max(i, minInsert);
- if (items[clamped]) items[clamped].classList.add('drop-before');
- insertBeforeIdx = clamped;
- placed = true;
- break;
- }
- }
- if (!placed) {
- if (items.length) items[items.length - 1].classList.add('drop-after');
- insertBeforeIdx = items.length;
- }
- }
-
- function onUp() {
- if (dragFromIdx !== null && insertBeforeIdx !== null)
- Player.reorderQueue(dragFromIdx, insertBeforeIdx);
- cleanup();
- }
-
- function onCancel() { cleanup(); }
-
- function cleanup() {
- const el = listEl.querySelector('.queue-dragging');
- if (el) el.classList.remove('queue-dragging');
- clearDropIndicators();
- dragFromIdx = null;
- insertBeforeIdx = null;
- handle.removeEventListener('pointermove', onMove);
- handle.removeEventListener('pointerup', onUp);
- handle.removeEventListener('pointercancel', onCancel);
- }
- }
-
- function open() { panel.hidden = false; refresh(); }
- function close() { panel.hidden = true; }
-
- function refresh() {
- if (panel.hidden) return;
- const q = Player.getQueue();
- const idx = Player.getQueueIndex();
- listEl.innerHTML = '';
-
- if (q.length === 0) {
- listEl.innerHTML = '
Queue is empty
';
- return;
- }
-
- q.forEach((path, i) => {
- const isCurrent = i === idx;
- const item = document.createElement('div');
- item.className = 'queue-item' + (isCurrent ? ' current' : '');
-
- const canDrag = i > idx;
- const handle = document.createElement('span');
- handle.className = 'queue-drag-handle' + (!canDrag ? ' queue-drag-handle--disabled' : '');
- handle.innerHTML = icons.drag;
- if (canDrag) setupDragHandle(handle, i);
-
- const num = document.createElement('span');
- num.className = 'queue-item-num';
- if (isCurrent) num.innerHTML = icons.playSmall;
- else num.textContent = i + 1;
-
- const thumb = document.createElement('div');
- thumb.className = 'queue-item-thumb';
- thumb.innerHTML = icons.note;
- const tImg = document.createElement('img');
- tImg.src = `/api/artwork?path=${enc(path)}`;
- tImg.onload = () => { thumb.textContent = ''; thumb.appendChild(tImg); };
- tImg.onerror = () => {};
-
- const name = document.createElement('span');
- name.className = 'queue-item-name';
- name.textContent = path.split('/').pop().replace(/\.[^.]+$/, '');
-
- const removeBtn = document.createElement('button');
- removeBtn.className = 'queue-item-remove';
- removeBtn.innerHTML = icons.close;
- removeBtn.title = 'Remove from queue';
- if (isCurrent) {
- removeBtn.disabled = true;
- } else {
- removeBtn.addEventListener('click', e => { e.stopPropagation(); Player.removeFromQueue(i); });
- }
-
- item.append(handle, num, thumb, name, removeBtn);
- item.addEventListener('click', () => { Player.jumpTo(i); close(); });
- listEl.appendChild(item);
- });
-
- const cur = listEl.querySelector('.current');
- if (cur) cur.scrollIntoView({ block: 'nearest' });
- }
-
- closeBtn.addEventListener('click', close);
- return { open, close, refresh };
-})();
-
-// ── Fullscreen Player ─────────────────────────────────────────────────────────
-
-const FullscreenPlayer = (() => {
- const el = document.getElementById('fullscreen-player');
- const fsArtEl = document.querySelector('.fs-art');
-
- let controlsHideTimer = null;
- let tapTimer = null;
-
- function showControls() {
- el.classList.remove('controls-hidden');
- clearTimeout(controlsHideTimer);
- if (!Player.paused()) {
- controlsHideTimer = setTimeout(() => {
- if (Player.isCurrentVideo() && !Player.paused()) el.classList.add('controls-hidden');
- }, 3000);
- }
- }
-
- function hideControls() {
- clearTimeout(controlsHideTimer);
- el.classList.add('controls-hidden');
- }
-
- function open() {
- history.pushState({ type: 'fullscreen' }, '');
- el.hidden = false;
- if (Player.isCurrentVideo()) showControls();
- }
-
- function close() {
- el.hidden = true;
- el.classList.remove('controls-hidden');
- clearTimeout(controlsHideTimer);
- clearTimeout(tapTimer);
- controlsHideTimer = null;
- tapTimer = null;
- }
-
- function isOpen() { return !el.hidden; }
-
- document.getElementById('fs-close').addEventListener('click', () => history.back());
-
- // Rotate button
- document.getElementById('fs-btn-rotate').addEventListener('click', async () => {
- try {
- const type = screen.orientation.type;
- if (type.startsWith('landscape')) {
- await screen.orientation.lock('portrait-primary');
- } else {
- await screen.orientation.lock('landscape-primary');
- }
- } catch (_) {}
- });
-
- // Fullscreen button — toggles browser native fullscreen
- const fsFullscreenBtn = document.getElementById('fs-btn-fullscreen');
- const fsFullscreenIcon = document.getElementById('fs-fullscreen-icon');
- const ICON_EXPAND = ``;
- const ICON_COLLAPSE = ``;
- fsFullscreenBtn.addEventListener('click', () => {
- if (!document.fullscreenElement) {
- document.documentElement.requestFullscreen().catch(() => {});
- } else {
- document.exitFullscreen().catch(() => {});
- }
- });
- document.addEventListener('fullscreenchange', () => {
- fsFullscreenIcon.innerHTML = document.fullscreenElement ? ICON_COLLAPSE : ICON_EXPAND;
- });
-
- // ── Video tap zone — only fires on .fs-art (the video background layer) ──
- // Tap zones split into thirds:
- // Single tap (any zone): toggle show/hide controls
- // Double tap left third: seek -30 s
- // Double tap middle third: play/pause
- // Double tap right third: seek +30 s
- function handleTap(x) {
- const third = el.clientWidth / 3;
- const zone = x < third ? 'left' : x < third * 2 ? 'mid' : 'right';
-
- if (tapTimer) {
- clearTimeout(tapTimer);
- tapTimer = null;
- if (zone === 'left') Player.skip(-30);
- else if (zone === 'right') Player.skip(30);
- else Player.playPause();
- } else {
- tapTimer = setTimeout(() => {
- tapTimer = null;
- if (el.classList.contains('controls-hidden')) showControls();
- else hideControls();
- }, 220);
- }
- }
-
- // touchend on .fs-art — fast, no 300 ms delay, no button interference
- fsArtEl.addEventListener('touchend', e => {
- if (!Player.isCurrentVideo()) return;
- e.preventDefault();
- handleTap(e.changedTouches[0].clientX);
- }, { passive: false });
-
- // click on .fs-art — desktop fallback
- fsArtEl.addEventListener('click', e => {
- if (!Player.isCurrentVideo()) return;
- handleTap(e.clientX);
- });
-
- // Re-show controls when the seek bar is touched
- el.addEventListener('input', () => { if (Player.isCurrentVideo()) showControls(); });
-
- return { open, close, isOpen };
-})();
-
-// ── View Toggle ───────────────────────────────────────────────────────────────
-
-const ViewToggle = (() => {
- const PREF_KEY = 'snap_view';
- let current = localStorage.getItem(PREF_KEY) || 'list';
-
- const btnList = document.getElementById('btn-list');
- const btnGrid = document.getElementById('btn-grid');
-
- function setView(v) {
- current = v;
- localStorage.setItem(PREF_KEY, v);
- btnList.classList.toggle('active', v === 'list');
- btnGrid.classList.toggle('active', v === 'grid');
- FileBrowser.refresh();
- }
-
- btnList.addEventListener('click', () => setView('list'));
- btnGrid.addEventListener('click', () => setView('grid'));
-
- btnList.classList.toggle('active', current === 'list');
- btnGrid.classList.toggle('active', current === 'grid');
-
- function get() { return current; }
- return { get };
-})();
-
-// ── File Browser ──────────────────────────────────────────────────────────────
-
-const FileBrowser = (() => {
- const browserEl = document.getElementById('browser');
- const breadcrumbEl = document.getElementById('breadcrumb');
- const searchEl = document.getElementById('search');
- const selectBar = document.getElementById('select-bar');
- const selectCount = document.getElementById('select-count');
- const selectPlayNow = document.getElementById('select-play-now');
- const selectAddQueue = document.getElementById('select-add-queue');
- const selectCancel = document.getElementById('select-cancel');
-
- let currentPath = '';
- let currentItems = [];
- let playingPath = '';
- let searchQuery = '';
- let searchResults = null; // non-null when a full-library search is active
- let searchDebounce = null;
- let sortKey = localStorage.getItem('snap_sort_key') || 'name';
- let sortDir = localStorage.getItem('snap_sort_dir') || 'asc';
-
- const durationObserver = new IntersectionObserver((entries) => {
- for (const entry of entries) {
- if (!entry.isIntersecting) continue;
- const el = entry.target;
- durationObserver.unobserve(el);
- fetchDuration(el.dataset.durPath).then(d => { if (d) el.textContent = formatTime(d); });
- }
- }, { rootMargin: '200px' });
-
- const artworkObserver = new IntersectionObserver((entries) => {
- for (const entry of entries) {
- if (!entry.isIntersecting) continue;
- const thumb = entry.target;
- artworkObserver.unobserve(thumb);
- const img = document.createElement('img');
- img.src = `/api/artwork?path=${enc(thumb.dataset.artPath)}`;
- img.onload = () => { thumb.textContent = ''; thumb.appendChild(img); };
- img.onerror = () => {};
- }
- }, { rootMargin: '400px' });
-
- // Multi-select state
- let selectMode = false;
- let selectedPaths = new Set();
-
- // ── Multi-select bar ──
- function enterSelectMode(path) {
- selectMode = true;
- selectedPaths = new Set([path]);
- selectBar.hidden = false;
- updateSelectCount();
- render();
- }
-
- function exitSelectMode() {
- selectMode = false;
- selectedPaths.clear();
- selectBar.hidden = true;
- render();
- }
-
- function updateSelectCount() {
- selectCount.textContent = `${selectedPaths.size} selected`;
- }
-
- selectPlayNow.addEventListener('click', () => {
- const paths = [...selectedPaths];
- if (paths.length === 0) return;
- Player.startQueue(paths, 0);
- exitSelectMode();
- });
-
- selectAddQueue.addEventListener('click', () => {
- [...selectedPaths].forEach(p => Player.addToEnd(p));
- exitSelectMode();
- });
-
- selectCancel.addEventListener('click', exitSelectMode);
-
- // ── Long press detection ──
- function addLongPress(el, onLong, onClick) {
- let timer = null;
- let didLong = false;
- let moved = false;
-
- const start = () => {
- didLong = false;
- moved = false;
- timer = setTimeout(() => {
- didLong = true;
- onLong();
- }, 500);
- };
-
- const cancel = () => {
- if (timer) { clearTimeout(timer); timer = null; }
- };
-
- let wasTouch = false;
-
- el.addEventListener('touchstart', () => { wasTouch = true; start(); }, { passive: true });
- el.addEventListener('touchmove', () => { moved = true; cancel(); }, { passive: true });
- el.addEventListener('touchend', e => {
- cancel();
- if (!didLong && !moved) {
- e.preventDefault(); // suppress synthetic mousedown/click that would double-fire
- onClick(e);
- }
- // reset wasTouch after a frame so the click guard below doesn't persist
- setTimeout(() => { wasTouch = false; }, 600);
- });
-
- // Mouse fallback for desktop (skip if touch already handled it)
- el.addEventListener('mousedown', () => { if (!wasTouch) start(); });
- el.addEventListener('mouseup', () => { if (!wasTouch) cancel(); });
- el.addEventListener('mouseleave',() => { if (!wasTouch) cancel(); });
- el.addEventListener('click', e => {
- if (wasTouch) return; // already handled by touchend
- if (!didLong) onClick(e);
- didLong = false;
- });
- }
-
- // ── Sorting ──
- function applySort(items) {
- const dirs = items.filter(i => i.type === 'dir');
- const files = items.filter(i => i.type === 'file');
- const cmp = (a, b) => {
- if (sortKey === 'name') {
- const r = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
- return sortDir === 'asc' ? r : -r;
- }
- if (sortKey === 'date') {
- const r = (a.mtime || 0) - (b.mtime || 0);
- return sortDir === 'asc' ? r : -r;
- }
- // size — dirs fall back to name
- const r = ((a.size || 0) - (b.size || 0)) || a.name.toLowerCase().localeCompare(b.name.toLowerCase());
- return sortDir === 'asc' ? r : -r;
- };
- return [...dirs.sort(cmp), ...files.sort(cmp)];
- }
-
- // ── Navigation ──
- function navigate(p, push = true) {
- if (selectMode) exitSelectMode();
- if (push) history.pushState({ type: 'browse', path: p }, '');
- currentPath = p;
- searchEl.value = '';
- searchQuery = '';
- searchResults = null;
- load();
- }
-
- async function load() {
- browserEl.innerHTML = 'Loading…
';
- renderBreadcrumb();
-
- 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;
- }
-
- currentItems = data.items;
- render();
- }
-
- function refresh() { render(); }
-
- function renderSearchResults() {
- if (searchResults.length === 0) {
- browserEl.innerHTML = 'No results found
';
- return;
- }
- const isGrid = ViewToggle.get() === 'grid';
- const container = document.createElement('div');
- container.className = isGrid ? 'grid-view' : 'list-view';
- for (const item of searchResults) {
- const dir = item.path.includes('/') ? item.path.split('/').slice(0, -1).join('/') : '';
- container.appendChild(isGrid ? makeGridItem(item, dir) : makeListItem(item, dir));
- }
- browserEl.innerHTML = '';
- browserEl.appendChild(container);
- }
-
- function render() {
- if (searchResults !== null) {
- renderSearchResults();
- return;
- }
- let items = currentItems;
- if (items.length === 0) {
- browserEl.innerHTML = 'No files found
';
- return;
- }
-
- items = applySort(items);
-
- const frag = document.createDocumentFragment();
-
- // Sort bar
- const sortBar = document.createElement('div');
- sortBar.className = 'sort-bar';
- for (const { key, label } of [{ key: 'name', label: 'Name' }, { key: 'date', label: 'Date' }, { key: 'size', label: 'Size' }]) {
- const btn = document.createElement('button');
- const isActive = sortKey === key;
- btn.className = 'sort-btn' + (isActive ? ' active' : '');
- btn.textContent = label + (isActive ? (sortDir === 'asc' ? ' ↑' : ' ↓') : '');
- btn.addEventListener('click', () => {
- if (sortKey === key) {
- sortDir = sortDir === 'asc' ? 'desc' : 'asc';
- } else {
- sortKey = key;
- sortDir = key === 'date' || key === 'size' ? 'desc' : 'asc';
- }
- localStorage.setItem('snap_sort_key', sortKey);
- localStorage.setItem('snap_sort_dir', sortDir);
- render();
- });
- sortBar.appendChild(btn);
- }
- frag.appendChild(sortBar);
-
- // Play all / folder actions header (only when there are audio files)
- const audioFiles = items.filter(i => i.type === 'file');
- if (audioFiles.length > 0 && !selectMode) {
- const header = document.createElement('div');
- header.className = 'folder-actions';
-
- const label = document.createElement('span');
- label.className = 'folder-actions-label';
- label.textContent = `${audioFiles.length} song${audioFiles.length !== 1 ? 's' : ''}`;
-
- const playAll = document.createElement('button');
- playAll.className = 'play-all-btn';
- playAll.innerHTML = icons.play + ' Play all';
- playAll.addEventListener('click', () => {
- const paths = audioFiles.map(f => f.path);
- if (Player.isActive()) {
- QueueModal.show(`${audioFiles.length} songs in folder`, paths, 0);
- } else {
- Player.startQueue(paths, 0);
- }
- });
-
- header.append(label, playAll);
- frag.appendChild(header);
- }
-
- const container = document.createElement('div');
- if (ViewToggle.get() === 'grid') {
- container.className = 'grid-view';
- for (const item of items) container.appendChild(makeGridItem(item));
- } else {
- container.className = 'list-view';
- for (const item of items) container.appendChild(makeListItem(item));
- }
- frag.appendChild(container);
-
- browserEl.innerHTML = '';
- browserEl.appendChild(frag);
- }
-
- function onFileClick(item) {
- if (selectMode) {
- // Toggle selection
- if (selectedPaths.has(item.path)) {
- selectedPaths.delete(item.path);
- if (selectedPaths.size === 0) { exitSelectMode(); return; }
- } else {
- selectedPaths.add(item.path);
- }
- updateSelectCount();
- render();
- return;
- }
-
- // Normal click: single file only
- if (Player.isActive()) {
- const displayName = item.name.replace(/\.[^.]+$/, '');
- QueueModal.show(displayName, [item.path], 0);
- } else {
- Player.startQueue([item.path], 0);
- }
- }
-
- function makeListItem(item, pathSubtitle) {
- const el = document.createElement('div');
- el.className = 'list-item' +
- (item.name.startsWith('.') ? ' hidden-entry' : '') +
- (item.path === playingPath ? ' playing' : '') +
- (selectMode && selectedPaths.has(item.path) ? ' selected' : '');
-
- if (selectMode && item.type === 'file') {
- const chk = document.createElement('span');
- chk.className = 'select-check';
- chk.innerHTML = selectedPaths.has(item.path) ? icons.check : '';
- el.appendChild(chk);
- } else {
- const icon = document.createElement('span');
- icon.className = 'list-icon';
- icon.innerHTML = item.type === 'dir' ? icons.folder : icons.note;
- el.appendChild(icon);
- }
-
- const name = document.createElement('span');
- name.className = 'list-name';
- name.textContent = item.name;
-
- const meta = document.createElement('span');
- meta.className = 'list-meta';
-
- if (item.type === 'file') {
- const dur = document.createElement('span');
- dur.textContent = '—';
- dur.dataset.durPath = item.path;
- durationObserver.observe(dur);
- const sz = document.createElement('span');
- sz.textContent = item.size ? formatSize(item.size) : '';
- meta.append(dur, sz);
- }
-
- if (pathSubtitle !== undefined) {
- const sub = document.createElement('span');
- sub.className = 'list-path';
- sub.textContent = pathSubtitle || '/';
- el.append(name, sub, meta);
- } else {
- el.append(name, meta);
- }
-
- if (item.type === 'dir') {
- el.addEventListener('click', () => navigate(item.path));
- } else {
- addLongPress(
- el,
- () => { // long press
- if (!selectMode) enterSelectMode(item.path);
- else { selectedPaths.add(item.path); updateSelectCount(); render(); }
- },
- () => onFileClick(item) // normal click
- );
- }
-
- return el;
- }
-
- function makeGridItem(item, pathSubtitle) {
- const el = document.createElement('div');
- el.className = 'grid-item' +
- (item.name.startsWith('.') ? ' hidden-entry' : '') +
- (item.path === playingPath ? ' playing' : '') +
- (selectMode && selectedPaths.has(item.path) ? ' selected' : '');
-
- const thumb = document.createElement('div');
- thumb.className = 'grid-thumb';
- thumb.innerHTML = item.type === 'dir' ? icons.folder : icons.note;
-
- if (selectMode && item.type === 'file') {
- const overlay = document.createElement('div');
- overlay.className = 'grid-select-overlay';
- overlay.innerHTML = selectedPaths.has(item.path) ? icons.check : '';
- thumb.appendChild(overlay);
- } else if (item.type === 'file') {
- thumb.dataset.artPath = item.path;
- artworkObserver.observe(thumb);
- }
-
- const name = document.createElement('span');
- name.className = 'grid-name';
- name.textContent = item.name;
-
- if (pathSubtitle !== undefined) {
- const sub = document.createElement('span');
- sub.className = 'grid-path';
- sub.textContent = pathSubtitle || '/';
- el.append(thumb, name, sub);
- } else {
- el.append(thumb, name);
- }
-
- if (item.type === 'dir') {
- el.addEventListener('click', () => navigate(item.path));
- } else {
- addLongPress(
- el,
- () => {
- if (!selectMode) enterSelectMode(item.path);
- else { selectedPaths.add(item.path); updateSelectCount(); render(); }
- },
- () => onFileClick(item)
- );
- }
-
- return el;
- }
-
- function renderBreadcrumb() {
- breadcrumbEl.innerHTML = '';
-
- const home = document.createElement('button');
- home.className = 'crumb' + (currentPath === '' ? ' active' : '');
- home.textContent = 'Home';
- home.addEventListener('click', () => navigate(''));
- breadcrumbEl.appendChild(home);
-
- if (currentPath) {
- const parts = currentPath.split('/').filter(Boolean);
- parts.forEach((part, i) => {
- const sep = document.createElement('span');
- sep.className = 'crumb-sep';
- sep.textContent = ' / ';
- breadcrumbEl.appendChild(sep);
-
- const crumb = document.createElement('button');
- const isLast = i === parts.length - 1;
- crumb.className = 'crumb' + (isLast ? ' active' : '');
- crumb.textContent = part;
- if (!isLast) {
- const crumbPath = parts.slice(0, i + 1).join('/');
- crumb.addEventListener('click', () => navigate(crumbPath));
- }
- breadcrumbEl.appendChild(crumb);
- });
- }
- }
-
- function setPlaying(filePath) {
- playingPath = filePath;
- render();
- }
-
- const durCache = new Map();
- async function fetchDuration(filePath) {
- if (durCache.has(filePath)) return durCache.get(filePath);
- try {
- const res = await fetch(`/api/metadata?path=${enc(filePath)}`);
- const meta = await res.json();
- durCache.set(filePath, meta.duration);
- return meta.duration;
- } catch { return null; }
- }
-
- searchEl.addEventListener('input', () => {
- const q = searchEl.value.trim();
- searchQuery = q;
- clearTimeout(searchDebounce);
- if (q.length < 2) {
- searchResults = null;
- render();
- return;
- }
- browserEl.innerHTML = 'Searching…
';
- searchDebounce = setTimeout(async () => {
- try {
- const res = await fetch(`/api/search?q=${enc(q)}`);
- if (!res.ok) return;
- const data = await res.json();
- if (searchEl.value.trim() === q) { // ignore stale responses
- searchResults = data;
- render();
- }
- } catch {}
- }, 300);
- });
-
- history.replaceState({ type: 'browse', path: '' }, '');
- load();
-
- function reload() { load(); }
-
- function getSelectedPaths() { return [...selectedPaths]; }
-
- return { navigate, setPlaying, refresh, reload, getSelectedPaths };
-})();
-
-// Keep --player-h in sync with the actual rendered player bar height
-new ResizeObserver(entries => {
- const h = entries[0].target.offsetHeight;
- if (h > 0) document.documentElement.style.setProperty('--player-h', h + 'px');
-}).observe(document.getElementById('player-bar'));
-
-// 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.
-// Android puts the WiFi adapter into deep power-save when no inbound traffic
-// arrives; the server pings every 8 s prevents that — same mechanism as
-// WireGuard PersistentKeepalive. Only active while screen is locked + playing.
-(() => {
- const _audio = document.getElementById('audio');
- const _video = document.getElementById('video');
- let _sock = null;
-
- function _isPlaying() { return !_audio.paused || !_video.paused; }
-
- function _open() {
- if (_sock && _sock.readyState < 2) return;
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
- _sock = new WebSocket(`${proto}//${location.host}/_ws`);
- _sock.addEventListener('open', () => clog('ws:open'));
- _sock.addEventListener('close', () => {
- clog('ws:close');
- _sock = null;
- if (document.hidden && _isPlaying()) setTimeout(_open, 3000);
- });
- _sock.addEventListener('error', () => {});
- }
-
- function _close() { if (_sock) { _sock.close(); _sock = null; } }
-
- document.addEventListener('visibilitychange', () => {
- if (document.hidden && _isPlaying()) _open(); else _close();
- });
- _audio.addEventListener('pause', () => { if (!_isPlaying()) _close(); });
- _video.addEventListener('pause', () => { if (!_isPlaying()) _close(); });
- _audio.addEventListener('play', () => { if (document.hidden) _open(); });
- _video.addEventListener('play', () => { if (document.hidden) _open(); });
-})();
-
-// Pull to refresh
-(() => {
- const browserEl = document.getElementById('browser');
- const indicator = document.getElementById('ptr-indicator');
- const IND_H = 52;
- const THRESHOLD = 72;
- let startY = 0, startX = 0, active = false, pull = 0;
-
- browserEl.addEventListener('touchstart', e => {
- if (browserEl.scrollTop === 0) {
- startY = e.touches[0].clientY;
- startX = e.touches[0].clientX;
- active = true;
- pull = 0;
- browserEl.style.transition = '';
- indicator.style.transition = '';
- }
- }, { passive: true });
-
- browserEl.addEventListener('touchmove', e => {
- if (!active) return;
- const dy = e.touches[0].clientY - startY;
- const dx = Math.abs(e.touches[0].clientX - startX);
- if (dy <= 0 || dx > dy) { active = false; return; }
- e.preventDefault();
- pull = Math.min(IND_H + THRESHOLD * 0.6, dy * 0.45);
- browserEl.style.transform = `translateY(${pull}px)`;
- indicator.style.transform = `translateY(${pull - IND_H}px)`;
- indicator.style.opacity = String(Math.min(1, pull / IND_H * 1.5));
- indicator.classList.toggle('ptr-ready', pull >= THRESHOLD);
- }, { passive: false });
-
- function release() {
- if (!active) return;
- active = false;
- const doRefresh = pull >= THRESHOLD;
- pull = 0;
- browserEl.style.transition = 'transform 0.25s ease';
- indicator.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
- browserEl.style.transform = '';
- indicator.style.transform = `translateY(-${IND_H}px)`;
- indicator.style.opacity = '0';
- indicator.classList.remove('ptr-ready');
- if (doRefresh) FileBrowser.reload();
- }
-
- browserEl.addEventListener('touchend', release, { passive: true });
- browserEl.addEventListener('touchcancel', release, { passive: true });
-})();
-
-// Back button: close fullscreen or navigate up the file tree
-window.addEventListener('popstate', e => {
- const state = e.state;
- if (!state) return;
- if (FullscreenPlayer.isOpen()) {
- FullscreenPlayer.close();
- } else if (state.type === 'browse') {
- 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 passConfirmField = document.getElementById('login-confirm-field');
- const passConfirm = document.getElementById('login-password2');
- 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';
- passConfirmField.hidden = !isSetup;
- passConfirm.value = '';
- 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; }
- if (_isSetup && password !== passConfirm.value) { errorEl.textContent = 'Passwords do not match.'; 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, passConfirm].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 takeoverBanner = document.getElementById('takeover-banner');
- 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) {
- 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 serverState = null;
- let takeoverShown = false;
-
- function isActiveDevice() {
- return !serverState?.activeDeviceId || serverState.activeDeviceId === myDeviceId;
- }
-
- function hasActiveDevice() {
- if (!serverState?.activeDeviceId || serverState.activeDeviceId === myDeviceId) return false;
- return (Date.now() - (serverState.activeDeviceAt || 0)) < 5 * 60 * 1000;
- }
-
- function updateTakeoverBtn() {
- if (fsTakeoverRow) fsTakeoverRow.hidden = isActiveDevice();
- }
-
- function getPlayerState() {
- return {
- queue: Player.getQueue(),
- 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,
- };
- }
-
- async function push(state) {
- try {
- await fetch('/api/state', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(state || getPlayerState()),
- });
- } catch {}
- }
-
- async function sendCommand(type, data) {
- try {
- await fetch('/api/command', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ type, data }),
- });
- } catch {}
- }
-
- function executeCommand(cmd) {
- if (!cmd?.type) return;
- if (Date.now() - (cmd.sentAt || 0) > 10000) return; // stale, ignore
- switch (cmd.type) {
- case 'playpause': Player.playPause(); break;
- case 'next': Player.playNext(); break;
- case 'prev': Player.playPrev(); break;
- case 'skip': Player.skip(cmd.data?.seconds || 0); break;
- case 'seek': {
- if (cmd.data?.seekTo != null) Player.seekTo(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;
- }
- }
- }
-
- async function pollAndSync() {
- let res;
- try { res = await fetch('/api/state'); } catch { return; }
- if (!res?.ok) return;
- const state = await res.json();
- const prevActiveId = serverState?.activeDeviceId;
- serverState = state;
- updateTakeoverBtn();
-
- if (isActiveDevice()) {
- if (state.pendingCommand) {
- // Execute command then always push to clear pendingCommand from server
- executeCommand(state.pendingCommand);
- 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)
- if (state.queue?.length > 0) {
- const newPath = state.queue[state.index ?? 0];
- if (newPath && newPath !== Player.getCurrentPath()) {
- Player.syncQueue(state.queue, state.index);
- }
- }
- // 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();
- }
- }
- }
-
- function startPolling() {
- if (pollTimer) return;
- pollTimer = setInterval(pollAndSync, 1000);
- }
-
- function stopPolling() {
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
- }
-
- // ── Takeover button handlers ──────────────────────────────────────────────────
- function takeoverHere() {
- if (!serverState) return;
- // Claim active status before calling loadState so startQueue isn't redirected back
- const snap = { ...serverState };
- serverState = { ...serverState, activeDeviceId: myDeviceId };
- updateTakeoverBtn();
- 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(); });
- takeoverDismiss.addEventListener('click', () => { takeoverBanner.hidden = true; });
- if (fsTakeoverBtn) fsTakeoverBtn.addEventListener('click', takeoverHere);
-
- // ── Transport interception for non-active device ──────────────────────────────
- // Capturing listeners fire before Player's bubble listeners on the same element.
- function makeTransportGuard(type, data) {
- return function(e) {
- if (hasActiveDevice()) {
- e.stopImmediatePropagation();
- sendCommand(type, data);
- }
- };
- }
- ['btn-prev', 'fs-btn-prev'].forEach(id => {
- document.getElementById(id)?.addEventListener('click', makeTransportGuard('prev'), true);
- });
- ['btn-next', 'fs-btn-next'].forEach(id => {
- document.getElementById(id)?.addEventListener('click', makeTransportGuard('next'), true);
- });
- ['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 dur = 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(); });
- document.getElementById('audio').addEventListener('pause', () => { push(); });
-
- // ── Init ─────────────────────────────────────────────────────────────────────
- async function init() {
- let res;
- try { res = await fetch('/api/state'); } catch { return; }
- if (!res?.ok) return;
- serverState = await res.json();
- updateTakeoverBtn();
-
- if (!serverState?.queue?.length || !serverState.activeDeviceAt) { startPolling(); return; }
-
- const age = Date.now() - serverState.activeDeviceAt;
- if (age > 30 * 60 * 1000) { startPolling(); return; }
-
- // Silently load queue/track on all devices
- Player.syncQueue(serverState.queue, serverState.index);
-
- if (serverState.activeDeviceId !== myDeviceId) {
- // Another device is active — show takeover banner
- if (!takeoverShown) {
- takeoverShown = true;
- takeoverBanner.hidden = false;
- }
- }
- startPolling();
- }
-
- return { init, push, sendCommand, isActiveDevice, hasActiveDevice, showTakeover, 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');
- const aeufSelect = document.getElementById('aeuf-user-select');
-
- // Add form fields
- const aaufUser = document.getElementById('aauf-username');
- const aaufPass = document.getElementById('aauf-password');
- const aaufPass2 = document.getElementById('aauf-password2');
- 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 aeufPass = document.getElementById('aeuf-password');
- const aeufPass2 = document.getElementById('aeuf-password2');
- 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 _editUserId = null;
-
- // ── Folder tree ──────────────────────────────────────────────────────────────
-
- function buildTreeNode(name, dirPath, checked) {
- const node = document.createElement('div');
- node.className = 'ftree-node';
-
- const row = document.createElement('div');
- row.className = 'ftree-row';
-
- const expander = document.createElement('button');
- expander.type = 'button';
- expander.className = 'ftree-expand';
- expander.textContent = '▶';
-
- const cb = document.createElement('input');
- cb.type = 'checkbox';
- cb.value = dirPath;
- cb.checked = checked.some(c => c === dirPath || dirPath.startsWith(c + '/'));
-
- const lbl = document.createElement('label');
- lbl.className = 'ftree-label';
- lbl.append(cb, document.createTextNode(' ' + name));
-
- row.append(expander, lbl);
- node.appendChild(row);
-
- const childWrap = document.createElement('div');
- childWrap.className = 'ftree-children';
- childWrap.hidden = true;
- node.appendChild(childWrap);
-
- let loaded = false;
- expander.addEventListener('click', async () => {
- if (!loaded) {
- childWrap.innerHTML = 'Loading…
';
- childWrap.hidden = false;
- expander.textContent = '▼';
- try {
- const res = await fetch(`/api/browse?path=${encodeURIComponent(dirPath)}`);
- const data = await res.json();
- const subdirs = (data.items || []).filter(i => i.type === 'dir');
- childWrap.innerHTML = '';
- if (subdirs.length === 0) {
- expander.style.visibility = 'hidden';
- } else {
- for (const sub of subdirs) {
- childWrap.appendChild(buildTreeNode(sub.name, sub.path, checked));
- }
- }
- } catch {
- childWrap.innerHTML = 'Error loading
';
- }
- loaded = true;
- } else {
- childWrap.hidden = !childWrap.hidden;
- expander.textContent = childWrap.hidden ? '▶' : '▼';
- }
- });
-
- return node;
- }
-
- async function renderFolderTree(container, checked) {
- container.innerHTML = 'Loading folders…
';
- try {
- const res = await fetch('/api/browse?path=');
- if (!res.ok) throw new Error();
- const data = await res.json();
- const dirs = (data.items || []).filter(i => i.type === 'dir');
- container.innerHTML = '';
- if (dirs.length === 0) {
- container.innerHTML = 'No folders found in media root';
- return;
- }
- for (const dir of dirs) {
- container.appendChild(buildTreeNode(dir.name, dir.path, checked));
- }
- } catch {
- container.innerHTML = 'Could not load folders';
- }
- }
-
- function getCheckedPaths(container) {
- return Array.from(container.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value);
- }
-
- // ── Users ────────────────────────────────────────────────────────────────────
-
- async function loadUsers() {
- try {
- const res = await fetch('/api/admin/users');
- if (!res.ok) return;
- _users = await res.json();
- } catch {}
- }
-
- function populateEditSelect() {
- const currentId = aeufSelect.value;
- aeufSelect.innerHTML = '';
- for (const u of _users) {
- const opt = document.createElement('option');
- opt.value = u.id;
- opt.textContent = u.username + (u.role === 'admin' ? ' (admin)' : '');
- aeufSelect.appendChild(opt);
- }
- aeufSelect.value = currentId;
- }
-
- function renderUsers() {
- userListEl.innerHTML = '';
- const currentUser = Auth.currentUser();
- if (_users.length === 0) {
- userListEl.innerHTML = 'No users yet
';
- return;
- }
- 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 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, delBtn);
- userListEl.appendChild(row);
- }
- populateEditSelect();
- }
-
- 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();
- if (_editUserId === id) { editForm.hidden = true; _editUserId = null; }
- } catch {}
- }
-
- // ── Add form ─────────────────────────────────────────────────────────────────
-
- addUserBtn.addEventListener('click', () => {
- addForm.hidden = !addForm.hidden;
- if (!addForm.hidden) {
- aaufUser.value = '';
- aaufPass.value = '';
- aaufPass2.value = '';
- aaufRole.value = 'user';
- aaufErr.textContent = '';
- renderFolderTree(aaufFolders, []);
- aaufUser.focus();
- }
- });
-
- aaufCancel.addEventListener('click', () => { addForm.hidden = true; });
-
- aaufSubmit.addEventListener('click', async () => {
- aaufErr.textContent = '';
- if (aaufPass.value !== aaufPass2.value) { aaufErr.textContent = 'Passwords do not match.'; return; }
- const body = {
- username: aaufUser.value.trim(),
- password: aaufPass.value,
- role: aaufRole.value,
- allowedPaths: getCheckedPaths(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'; }
- });
-
- // ── Edit form (driven by user select dropdown) ────────────────────────────────
-
- aeufSelect.addEventListener('change', async () => {
- const uid = aeufSelect.value;
- if (!uid) { editForm.hidden = true; _editUserId = null; return; }
- const u = _users.find(u => u.id === uid);
- if (!u) return;
- _editUserId = uid;
- aeufPass.value = '';
- aeufPass2.value = '';
- aeufRole.value = u.role;
- aeufErr.textContent = '';
- editForm.hidden = false;
- await renderFolderTree(aeufFolders, u.allowedPaths || []);
- });
-
- aeufCancel.addEventListener('click', () => {
- editForm.hidden = true;
- _editUserId = null;
- aeufSelect.value = '';
- });
-
- aeufSubmit.addEventListener('click', async () => {
- aeufErr.textContent = '';
- if (aeufPass.value && aeufPass.value !== aeufPass2.value) {
- aeufErr.textContent = 'Passwords do not match.'; return;
- }
- const body = {
- role: aeufRole.value,
- allowedPaths: getCheckedPaths(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;
- aeufSelect.value = '';
- await loadUsers();
- renderUsers();
- } catch { aeufErr.textContent = 'Network error'; }
- });
-
- closeBtn.addEventListener('click', close);
-
- async function open() {
- 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 });
- }
-};
-
-// ── 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.
-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);
-});
-
-// ── Password eye-toggle (global, works on any .pw-eye button) ────────────────
-
-document.addEventListener('click', e => {
- const btn = e.target.closest('.pw-eye');
- if (!btn) return;
- const input = document.getElementById(btn.dataset.target);
- if (!input) return;
- const show = input.type === 'password';
- input.type = show ? 'text' : 'password';
- btn.querySelector('.eye-show').hidden = show;
- btn.querySelector('.eye-hide').hidden = !show;
-});
-
-// ── 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/css/admin.css b/public/css/admin.css
new file mode 100644
index 0000000..2f844fc
--- /dev/null
+++ b/public/css/admin.css
@@ -0,0 +1,53 @@
+/* ── Admin panel ── */
+#admin-panel { position:fixed; inset:0; background:var(--bg); z-index:350; display:flex; flex-direction:column; overflow:hidden; }
+#admin-panel[hidden] { display:none; }
+.admin-header { display:flex; align-items:center; gap:12px; padding:16px; border-bottom:1px solid var(--border); flex-shrink:0; }
+.admin-header h2 { font-size:18px; font-weight:700; flex:1; }
+.admin-body { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:16px; }
+.admin-section { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:16px; display:flex; flex-direction:column; gap:10px; }
+.admin-section h3 { font-size:14px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:.5px; }
+.user-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--border); }
+.user-row:last-child { border-bottom:none; }
+.user-row-name { flex:1; font-size:14px; }
+.user-row-role { font-size:12px; color:var(--text-muted); background:var(--surface2); border-radius:10px; padding:2px 8px; }
+.admin-btn { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 10px; cursor:pointer; font-size:12px; }
+.admin-btn:hover { border-color:var(--accent); }
+.admin-btn.danger { color:#e55; }
+.admin-btn.danger:hover { border-color:#e55; }
+.admin-form { display:flex; flex-direction:column; gap:10px; padding:12px; background:var(--surface2); border-radius:8px; }
+.admin-form[hidden] { display:none; }
+.admin-form label { font-size:12px; color:var(--text-muted); display:flex; flex-direction:column; gap:4px; }
+.admin-form input, .admin-form select { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:7px 10px; font-size:13px; outline:none; }
+.admin-form input:focus, .admin-form select:focus { border-color:var(--accent); }
+.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); }
+.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; }
+.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; }
+.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; }
+.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; }
+.btn-cancel { background:none; color:var(--text-muted); }
diff --git a/public/css/auth.css b/public/css/auth.css
new file mode 100644
index 0000000..589e479
--- /dev/null
+++ b/public/css/auth.css
@@ -0,0 +1,21 @@
+/* ── Login overlay ── */
+#login-overlay { position:fixed; inset:0; background:#111; z-index:400; display:flex; align-items:center; justify-content:center; }
+#login-overlay[hidden] { display:none; }
+.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); }
+.login-submit { background:var(--accent); color:#000; border:none; border-radius:8px; padding:12px; font-size:15px; font-weight:600; cursor:pointer; }
+.login-error { color:#e55; font-size:13px; text-align:center; min-height:18px; }
+
+/* ── User menu ── */
+.user-menu-wrap { position:relative; }
+#user-menu-btn { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 10px; cursor:pointer; font-size:13px; max-width:110px; overflow:hidden; }
+#user-menu-label { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+#user-menu-dropdown { position:absolute; top:calc(100% + 6px); right:0; background:var(--surface); border:1px solid var(--border); border-radius:8px; min-width:140px; z-index:200; display:flex; flex-direction:column; overflow:hidden; }
+#user-menu-dropdown[hidden] { display:none; }
+.user-menu-item { background:none; border:none; color:var(--text); padding:10px 14px; text-align:left; cursor:pointer; font-size:13px; }
+.user-menu-item:hover { background:var(--surface2); }
diff --git a/public/css/base.css b/public/css/base.css
new file mode 100644
index 0000000..03647bc
--- /dev/null
+++ b/public/css/base.css
@@ -0,0 +1,23 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg: #111;
+ --surface: #1a1a1a;
+ --surface2: #242424;
+ --border: #2e2e2e;
+ --text: #e8e8e8;
+ --text-muted: #888;
+ --accent: #1db954;
+ --player-h: 72px;
+ --topbar-h: 52px;
+ --breadcrumb-h: 36px;
+}
+
+html, body {
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ font-size: 14px;
+ overflow: hidden;
+}
diff --git a/public/css/browser.css b/public/css/browser.css
new file mode 100644
index 0000000..53f70cb
--- /dev/null
+++ b/public/css/browser.css
@@ -0,0 +1,288 @@
+/* ── Breadcrumb ── */
+.breadcrumb {
+ position: fixed;
+ top: var(--topbar-h);
+ left: 0; right: 0;
+ height: var(--breadcrumb-h);
+ background: var(--surface2);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 4px;
+ font-size: 13px;
+ color: var(--text-muted);
+ z-index: 90;
+ overflow-x: auto;
+ white-space: nowrap;
+}
+.breadcrumb::-webkit-scrollbar { display: none; }
+
+.crumb {
+ cursor: pointer;
+ color: var(--text-muted);
+ background: none;
+ border: none;
+ font-size: 13px;
+ padding: 2px 4px;
+ border-radius: 4px;
+ transition: color 0.15s;
+}
+.crumb:hover { color: var(--accent); }
+.crumb.active { color: var(--text); cursor: default; border: 1px solid var(--border); padding: 2px 8px; }
+.crumb-sep { color: var(--text-muted); user-select: none; font-weight: 600; }
+
+/* ── Pull to refresh ── */
+#ptr-indicator {
+ position: fixed;
+ top: calc(var(--topbar-h) + var(--breadcrumb-h));
+ left: 0; right: 0;
+ height: 52px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ opacity: 0;
+ color: var(--text-muted);
+ z-index: 89;
+ transform: translateY(-52px);
+}
+.ptr-arrow { transition: transform 0.2s ease; }
+#ptr-indicator.ptr-ready .ptr-arrow { transform: rotate(180deg); }
+
+/* ── File browser ── */
+.browser {
+ position: fixed;
+ top: calc(var(--topbar-h) + var(--breadcrumb-h));
+ bottom: var(--player-h);
+ left: 0; right: 0;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ padding: 12px;
+}
+.browser::-webkit-scrollbar { width: 6px; }
+.browser::-webkit-scrollbar-track { background: transparent; }
+.browser::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+/* List view */
+.list-view {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.list-item {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.12s;
+ user-select: none;
+}
+.list-item:hover { background: var(--surface2); }
+.list-item.playing { background: var(--surface2); color: var(--accent); }
+.list-item.hidden-entry { opacity: 0.55; }
+
+.list-icon { font-size: 18px; flex-shrink: 0; width: 24px; text-align: center; }
+.list-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.list-meta { color: var(--text-muted); font-size: 12px; flex-shrink: 0; display: flex; gap: 12px; }
+.list-path { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-basis: 100%; order: 3; padding-left: 28px; margin-top: -2px; }
+
+/* Grid view */
+.grid-view {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 12px;
+}
+
+.grid-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 10px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.12s;
+ user-select: none;
+}
+.grid-item:hover { background: var(--surface2); }
+.grid-item.playing { background: var(--surface2); color: var(--accent); }
+.grid-item.hidden-entry { opacity: 0.55; }
+
+.grid-thumb {
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--surface2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 40px;
+}
+.grid-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+.grid-thumb img[data-loaded="false"] { display: none; }
+
+.grid-name {
+ font-size: 12px;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+ max-width: 130px;
+}
+.grid-path {
+ font-size: 10px;
+ color: var(--text-muted);
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+ max-width: 130px;
+}
+
+/* Empty / loading states */
+.browser-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-muted);
+ font-size: 15px;
+}
+
+/* ── Sort bar ── */
+.sort-bar {
+ display: flex;
+ gap: 6px;
+ padding: 8px 10px 4px;
+}
+.sort-btn {
+ background: none;
+ border: 1px solid transparent;
+ color: var(--text-muted);
+ border-radius: 14px;
+ padding: 3px 10px;
+ cursor: pointer;
+ font-size: 12px;
+ transition: color 0.12s, border-color 0.12s;
+ -webkit-tap-highlight-color: transparent;
+}
+.sort-btn.active {
+ color: var(--text);
+ border-color: var(--border);
+}
+@media (hover: hover) {
+ .sort-btn:hover { color: var(--text); }
+}
+
+/* ── Folder actions header ── */
+.folder-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px 10px;
+ gap: 10px;
+}
+.folder-actions-label {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.play-all-btn {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: 6px;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 13px;
+ transition: border-color 0.12s;
+ -webkit-tap-highlight-color: transparent;
+}
+@media (hover: hover) {
+ .play-all-btn:hover { border-color: var(--accent); color: var(--accent); }
+}
+
+/* ── Multi-select ── */
+.list-item.selected { background: rgba(29,185,84,0.12); }
+.grid-item.selected .grid-thumb { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+.select-check {
+ flex-shrink: 0;
+ width: 24px;
+ text-align: center;
+ font-size: 16px;
+ color: var(--accent);
+}
+
+.grid-select-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0,0,0,0.45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ color: var(--accent);
+ border-radius: 6px;
+}
+.grid-thumb { position: relative; }
+
+/* ── Selection action bar ── */
+.select-bar {
+ position: fixed;
+ bottom: var(--player-h);
+ left: 0; right: 0;
+ background: var(--surface);
+ border-top: 1px solid var(--accent);
+ display: flex;
+ align-items: center;
+ padding: 10px 16px;
+ gap: 10px;
+ z-index: 99;
+}
+.select-bar[hidden] { display: none; }
+
+#select-count {
+ font-size: 13px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+.select-actions {
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+ align-items: center;
+}
+.select-actions button {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: 6px;
+ padding: 7px 12px;
+ cursor: pointer;
+ font-size: 13px;
+ -webkit-tap-highlight-color: transparent;
+}
+#select-cancel {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 18px;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+@media (hover: hover) {
+ .select-actions button:hover { border-color: var(--accent); }
+}
diff --git a/public/css/fullscreen.css b/public/css/fullscreen.css
new file mode 100644
index 0000000..9d1dd4f
--- /dev/null
+++ b/public/css/fullscreen.css
@@ -0,0 +1,230 @@
+/* ── Fullscreen player ── */
+.fullscreen-player {
+ position: fixed;
+ inset: 0;
+ background: #0d0d0d;
+ z-index: 250;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px 24px calc(24px + env(safe-area-inset-bottom));
+ gap: 20px;
+}
+.fullscreen-player[hidden] { display: none; }
+
+.fs-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 20px;
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 6px;
+}
+.fs-close:hover { color: var(--text); }
+
+.fs-art {
+ width: min(280px, 70vw);
+ aspect-ratio: 1;
+ border-radius: 12px;
+ overflow: hidden;
+ background: var(--surface2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 72px;
+ flex-shrink: 0;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.6);
+}
+.fs-art img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+.fs-art img:not([src]),
+.fs-art img[src=""] { display: none; }
+
+/* Hide fallback when artwork is loaded; show it only when img src is empty */
+.fs-art-fallback { display: none; }
+.fs-art img:not([src]) ~ .fs-art-fallback,
+.fs-art img[src=""] ~ .fs-art-fallback { display: block; }
+
+.fs-info {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ width: 100%;
+ max-width: 400px;
+ text-align: center;
+}
+.fs-title {
+ font-size: 18px;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+}
+.fs-artist {
+ font-size: 14px;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+}
+
+.fs-seek {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ max-width: 400px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.fs-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.fs-controls button {
+ background: none;
+ border: none;
+ color: var(--text);
+ cursor: pointer;
+ font-size: 22px;
+ padding: 10px 14px;
+ border-radius: 8px;
+ line-height: 1;
+ transition: color 0.12s;
+ font-variant-emoji: text;
+ -webkit-tap-highlight-color: transparent;
+}
+.fs-controls button:focus { outline: none; color: var(--text); }
+@media (hover: hover) {
+ .fs-controls button:hover { color: var(--accent); }
+}
+.fs-controls .play-btn { font-size: 34px !important; padding: 10px 18px; }
+
+.fs-skip-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px;
+}
+
+.fs-extra {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.fs-extra button {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 22px;
+ padding: 8px 12px;
+ border-radius: 6px;
+ transition: color 0.12s;
+ font-variant-emoji: text;
+ -webkit-tap-highlight-color: transparent;
+}
+.fs-extra button:focus { outline: none; color: var(--text-muted); }
+@media (hover: hover) {
+ .fs-extra button:hover { color: var(--text); }
+}
+.fs-extra button.active { color: var(--accent); }
+
+/* ── Video mode fullscreen ── */
+.fullscreen-player.video-mode {
+ padding: 0;
+ gap: 0;
+ justify-content: flex-end;
+ background: #000;
+}
+
+/* Video fills full background */
+.fullscreen-player.video-mode .fs-art {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ aspect-ratio: unset;
+ border-radius: 0;
+ background: #000;
+ box-shadow: none;
+ z-index: 0;
+}
+
+#video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ background: #000;
+}
+
+/* Controls overlay at bottom in video mode */
+.fullscreen-player.video-mode .fs-info,
+.fullscreen-player.video-mode .fs-seek,
+.fullscreen-player.video-mode .fs-controls,
+.fullscreen-player.video-mode .fs-extra {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 100%;
+ padding: 0 20px;
+ 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));
+}
+
+.fullscreen-player.video-mode .fs-close {
+ z-index: 2;
+ transition: opacity 0.25s;
+}
+
+/* Gradient scrim behind controls */
+.fullscreen-player.video-mode::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 55%;
+ background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%);
+ z-index: 0;
+ pointer-events: none;
+ transition: opacity 0.25s;
+}
+
+/* Auto-hide controls in video mode */
+.fullscreen-player.video-mode.controls-hidden .fs-close,
+.fullscreen-player.video-mode.controls-hidden .fs-info,
+.fullscreen-player.video-mode.controls-hidden .fs-seek,
+.fullscreen-player.video-mode.controls-hidden .fs-controls,
+.fullscreen-player.video-mode.controls-hidden .fs-extra {
+ opacity: 0;
+ pointer-events: none;
+}
+.fullscreen-player.video-mode.controls-hidden::after {
+ opacity: 0;
+}
+
+/* Mini player art area in video mode */
+.player-art-btn.video-mode .art-fallback { display: flex !important; }
+.player-art-btn.video-mode img { display: none; }
diff --git a/public/css/player.css b/public/css/player.css
new file mode 100644
index 0000000..caf0359
--- /dev/null
+++ b/public/css/player.css
@@ -0,0 +1,251 @@
+/* ── Player bar ── */
+.player-bar[hidden] { display: none; }
+
+body.player-hidden .browser,
+body.player-hidden .select-bar { bottom: 0 !important; }
+
+.player-clear-btn { font-size: 14px !important; }
+
+.player-bar {
+ position: fixed;
+ bottom: 0; left: 0; right: 0;
+ height: var(--player-h);
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ gap: 10px;
+ z-index: 100;
+}
+
+/* Thumbnail button */
+.player-art-btn {
+ flex-shrink: 0;
+ width: 48px;
+ height: 48px;
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--surface2);
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ color: var(--text-muted);
+ padding: 0;
+ position: relative;
+ transition: opacity 0.15s;
+}
+.player-art-btn:hover { opacity: 0.85; }
+.player-art-btn img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ position: absolute;
+ top: 0; left: 0;
+}
+.player-art-btn img:not([src]),
+.player-art-btn img[src=""] { display: none; }
+.art-fallback { pointer-events: none; }
+
+/* Controls row */
+.player-controls {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.player-controls button {
+ background: none;
+ border: none;
+ color: var(--text);
+ cursor: pointer;
+ font-size: 15px;
+ padding: 6px 7px;
+ border-radius: 4px;
+ line-height: 1;
+ transition: color 0.12s;
+ font-variant-emoji: text;
+ -webkit-tap-highlight-color: transparent;
+}
+.player-controls button:focus { outline: none; color: var(--text); }
+.player-controls button:focus-visible { outline: 1px solid var(--accent); }
+@media (hover: hover) {
+ .player-controls button:hover { color: var(--accent); }
+}
+.play-btn { font-size: 20px !important; }
+
+.extra-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 6px 8px;
+ border-radius: 4px;
+ line-height: 1;
+ font-variant-emoji: text;
+ -webkit-tap-highlight-color: transparent;
+ color: var(--text-muted);
+ font-size: 20px;
+ transition: color 0.12s;
+}
+.extra-btn:focus { outline: none; color: var(--text-muted); }
+.extra-btn.active { color: var(--accent); }
+@media (hover: hover) {
+ .extra-btn:hover { color: var(--accent); }
+}
+
+/* Shuffle / repeat inline group (desktop) */
+.player-mid-row {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+/* Seek */
+.player-seek {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.player-volume {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ font-size: 14px;
+ color: var(--text-muted);
+}
+
+/* Range inputs */
+input[type=range] {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--border);
+ outline: none;
+ cursor: pointer;
+}
+#seek-bar, #fs-seek-bar { flex: 1; }
+#volume-bar { width: 80px; }
+
+input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: pointer;
+}
+input[type=range]::-moz-range-thumb {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--accent);
+ border: none;
+ cursor: pointer;
+}
+
+/* ── Mobile adjustments ── */
+@media (max-width: 600px) {
+ :root { --player-h: 106px; }
+
+ .player-bar {
+ display: grid;
+ grid-template-columns: 68px 1fr 68px; /* equal outer cols → play btn dead-centre */
+ grid-template-rows: auto auto auto;
+ column-gap: 4px;
+ row-gap: 2px;
+ padding: 6px 8px 4px;
+ height: auto;
+ align-items: center;
+ }
+
+ .browser {
+ bottom: var(--player-h);
+ }
+
+ /* Thumbnail — square, spans rows 1+2 */
+ .player-art-btn {
+ grid-column: 1;
+ grid-row: 1 / 3;
+ width: 68px;
+ height: 68px;
+ align-self: center;
+ border-radius: 8px;
+ }
+
+ /* Prev / play / next — row 1, dead-centre */
+ .player-controls {
+ grid-column: 2;
+ grid-row: 1;
+ width: 100%;
+ justify-content: center;
+ }
+
+ /* Shuffle / repeat — row 2, centred */
+ .player-mid-row {
+ grid-column: 2;
+ grid-row: 2;
+ width: 100%;
+ justify-content: center;
+ order: unset;
+ }
+
+ /* Queue button — same width as thumbnail, spans rows 1+2 */
+ .queue-view-btn {
+ grid-column: 3;
+ grid-row: 1 / 3;
+ width: 68px;
+ height: 68px;
+ align-self: center;
+ justify-self: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ padding: 0;
+ }
+
+ /* Seek bar — row 3, full width */
+ .player-seek {
+ grid-column: 1 / 4;
+ grid-row: 3;
+ order: unset;
+ width: auto;
+ }
+
+ /* Tighter button padding on mobile */
+ .player-controls button { padding: 4px 8px; }
+ .player-mid-row .extra-btn { padding: 2px 10px; }
+
+ .player-volume { display: none; }
+
+ .select-bar {
+ bottom: var(--player-h);
+ }
+
+ .queue-panel {
+ width: 100%;
+ top: 20%;
+ border-radius: 16px 16px 0 0;
+ border: none;
+ border-top: 1px solid var(--border);
+ }
+
+ .grid-view {
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ }
+
+ #search { width: 120px; }
+}
diff --git a/public/css/playlists.css b/public/css/playlists.css
new file mode 100644
index 0000000..c070756
--- /dev/null
+++ b/public/css/playlists.css
@@ -0,0 +1,19 @@
+/* ── Playlists panel ── */
+#playlists-panel { position:fixed; top:0; right:0; bottom:0; width:320px; background:var(--surface); border-left:1px solid var(--border); z-index:200; display:flex; flex-direction:column; }
+#playlists-panel[hidden] { display:none; }
+.playlist-header { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid var(--border); font-weight:600; font-size:15px; flex-shrink:0; }
+.playlist-list { flex:1; overflow-y:auto; padding:8px 0; }
+.playlist-item { display:flex; align-items:center; gap:8px; padding:10px 12px; cursor:pointer; transition:background .12s; }
+.playlist-item:hover { background:var(--surface2); }
+.playlist-item-name { flex:1; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.playlist-item-count { font-size:12px; color:var(--text-muted); }
+.playlist-item-actions { display:flex; gap:4px; opacity:0; transition:opacity .12s; }
+.playlist-item:hover .playlist-item-actions { opacity:1; }
+.playlist-footer { padding:10px 12px; border-top:1px solid var(--border); flex-shrink:0; }
+.new-playlist-btn { width:100%; background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:8px; cursor:pointer; font-size:13px; }
+.new-playlist-btn:hover { border-color:var(--accent); }
+
+/* Playlist track list (expanded) */
+.playlist-tracks { padding:0 12px 8px; display:flex; flex-direction:column; gap:2px; }
+.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); }
diff --git a/public/css/queue.css b/public/css/queue.css
new file mode 100644
index 0000000..2a3f694
--- /dev/null
+++ b/public/css/queue.css
@@ -0,0 +1,174 @@
+/* ── Queue action modal ── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.7);
+ z-index: 300;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.modal-overlay[hidden] { display: none; }
+
+.modal {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+ width: min(320px, 90vw);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.modal-track-name {
+ font-size: 13px;
+ color: var(--text-muted);
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding-bottom: 4px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-btn {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: 8px;
+ padding: 12px 16px;
+ cursor: pointer;
+ font-size: 14px;
+ text-align: left;
+ transition: background 0.12s, border-color 0.12s;
+}
+.modal-btn:hover { background: var(--border); border-color: var(--accent); }
+.modal-cancel { color: var(--text-muted); }
+
+/* ── Queue panel ── */
+.queue-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 320px;
+ background: var(--surface);
+ border-left: 1px solid var(--border);
+ z-index: 200;
+ display: flex;
+ flex-direction: column;
+ transform: translateX(0);
+ transition: transform 0.25s ease;
+}
+.queue-panel[hidden] { display: none; }
+
+.queue-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 600;
+ font-size: 15px;
+ flex-shrink: 0;
+}
+.queue-header button {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 4px;
+ border-radius: 4px;
+}
+.queue-header button:hover { color: var(--text); }
+
+.queue-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px 0;
+}
+.queue-list::-webkit-scrollbar { width: 4px; }
+.queue-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+
+.queue-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px 6px 6px;
+ cursor: pointer;
+ transition: background 0.12s;
+ font-size: 13px;
+ border-top: 2px solid transparent;
+ border-bottom: 2px solid transparent;
+}
+.queue-item:hover { background: var(--surface2); }
+.queue-item.current { color: var(--accent); }
+.queue-item.queue-dragging { opacity: 0.35; }
+.queue-item.drop-before { border-top-color: var(--accent); }
+.queue-item.drop-after { border-bottom-color: var(--accent); }
+
+.queue-drag-handle {
+ flex-shrink: 0;
+ cursor: grab;
+ color: var(--text-muted);
+ font-size: 13px;
+ padding: 4px 3px;
+ user-select: none;
+ touch-action: none;
+ line-height: 1;
+}
+.queue-drag-handle:active { cursor: grabbing; }
+.queue-drag-handle--disabled { opacity: 0; pointer-events: none; }
+
+.queue-item-remove {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 11px;
+ padding: 4px 5px;
+ border-radius: 4px;
+ line-height: 1;
+ transition: color 0.12s;
+}
+.queue-item-remove:hover { color: var(--text); }
+.queue-item-remove:disabled { opacity: 0; pointer-events: none; }
+.queue-item-num {
+ color: var(--text-muted);
+ font-size: 11px;
+ width: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.queue-item.current .queue-item-num { color: var(--accent); }
+
+.queue-item-thumb {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ border-radius: 4px;
+ background: var(--surface2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ overflow: hidden;
+}
+.queue-item-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.queue-item-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/public/css/sync.css b/public/css/sync.css
new file mode 100644
index 0000000..b8bb1da
--- /dev/null
+++ b/public/css/sync.css
@@ -0,0 +1,18 @@
+/* ── 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; }
+.sync-banner-text { flex:1; }
+.sync-banner-btn { background:var(--accent); color:#000; border:none; border-radius:6px; padding:5px 12px; cursor:pointer; font-size:12px; font-weight:600; }
+.sync-banner-dismiss { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:18px; padding:2px 6px; }
+
+/* ── Mobile: hide playlists button in topbar ── */
+@media (max-width: 600px) {
+ #btn-playlists { display: none; }
+ .sync-banner { bottom: var(--player-h); }
+ #playlists-panel { width:100%; top:20%; border-radius:16px 16px 0 0; border:none; border-top:1px solid var(--border); }
+}
diff --git a/public/css/topbar.css b/public/css/topbar.css
new file mode 100644
index 0000000..3620834
--- /dev/null
+++ b/public/css/topbar.css
@@ -0,0 +1,64 @@
+/* ── Top bar ── */
+.topbar {
+ position: fixed;
+ top: 0; left: 0; right: 0;
+ height: var(--topbar-h);
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 12px;
+ z-index: 100;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--accent);
+ letter-spacing: -0.5px;
+ flex-shrink: 0;
+}
+.logo-img {
+ width: 32px;
+ height: 32px;
+ display: block;
+}
+
+.topbar-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: auto;
+}
+
+#search {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: 6px;
+ padding: 5px 10px;
+ font-size: 13px;
+ width: 180px;
+ outline: none;
+}
+#search:focus { border-color: var(--accent); }
+
+.view-btn {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ border-radius: 6px;
+ padding: 5px 10px;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ transition: color 0.15s, border-color 0.15s;
+}
+.view-btn.active, .view-btn:hover {
+ color: var(--text);
+ border-color: var(--accent);
+}
diff --git a/public/index.html b/public/index.html
index ab1d074..087c1c8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -371,6 +371,6 @@ All Users
-
+