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

- + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..c350c69 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,287 @@ +'use strict'; + +import { Auth } from './auth.js'; + +// ── 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 }; +})(); + +export { AdminPanel }; diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..a1d2059 --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,106 @@ +'use strict'; + +// ── 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; + let _onReadyCallback = null; + + 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(); + if (_onReadyCallback) _onReadyCallback(); + } 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(onReadyCallback) { + _onReadyCallback = onReadyCallback || null; + 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(); + if (_onReadyCallback) _onReadyCallback(); + } 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 }; +})(); + +export { Auth }; diff --git a/public/js/file-browser.js b/public/js/file-browser.js new file mode 100644 index 0000000..d6becea --- /dev/null +++ b/public/js/file-browser.js @@ -0,0 +1,491 @@ +'use strict'; + +import { Player } from './player.js'; +import { clog, formatTime, formatSize, enc, icons } from './utils.js'; + +// ── 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 = window._ViewToggle ? window._ViewToggle.get() === 'grid' : false; + 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()) { + window._QueueModal && window._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'); + const isGrid = window._ViewToggle ? window._ViewToggle.get() === 'grid' : false; + if (isGrid) { + 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(/\.[^.]+$/, ''); + window._QueueModal && window._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 }; +})(); + +export { FileBrowser }; diff --git a/public/js/fullscreen.js b/public/js/fullscreen.js new file mode 100644 index 0000000..1e88236 --- /dev/null +++ b/public/js/fullscreen.js @@ -0,0 +1,120 @@ +'use strict'; + +import { Player } from './player.js'; + +// ── 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 }; +})(); + +export { FullscreenPlayer }; diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..03a075d --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,194 @@ +'use strict'; + +import { clog } from './utils.js'; +import { Player } from './player.js'; +import { MediaSessionManager, WakeLock } from './media-session.js'; +import { QueueModal, QueuePanel } from './queue.js'; +import { FullscreenPlayer } from './fullscreen.js'; +import { ViewToggle } from './view-toggle.js'; +import { FileBrowser } from './file-browser.js'; +import { Auth } from './auth.js'; +import { UserMenu } from './user-menu.js'; +import { AdminPanel } from './admin.js'; +import { SyncManager } from './sync-manager.js'; +import { Playlists } from './playlists.js'; + +// Expose modules on window so that lazily-resolved cross-module references work. +// (Player, FileBrowser, QueuePanel, etc. reference each other via window._X at call time +// to avoid circular import issues.) +window._MediaSessionManager = MediaSessionManager; +window._WakeLock = WakeLock; +window._QueueModal = QueueModal; +window._QueuePanel = QueuePanel; +window._FullscreenPlayer = FullscreenPlayer; +window._ViewToggle = ViewToggle; +window._FileBrowser = FileBrowser; + +// 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')); + +// 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); + } +}); + +// ── 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', () => { + 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(onReady); diff --git a/public/js/media-session.js b/public/js/media-session.js new file mode 100644 index 0000000..850e6a6 --- /dev/null +++ b/public/js/media-session.js @@ -0,0 +1,69 @@ +'use strict'; + +import { enc } from './utils.js'; +import { Player } from './player.js'; + +// ── 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 }; +})(); + +export { MediaSessionManager, WakeLock }; diff --git a/public/js/player.js b/public/js/player.js new file mode 100644 index 0000000..e32d118 --- /dev/null +++ b/public/js/player.js @@ -0,0 +1,745 @@ +'use strict'; + +import { clog, formatTime, enc, icons } from './utils.js'; + +// Forward references resolved at call time (these modules are loaded in main.js before Player runs) +// MediaSessionManager, WakeLock, FileBrowser, QueuePanel, FullscreenPlayer are globals set by main.js +// To avoid circular imports they are accessed via window or a registry pattern. +// We use a simple deferred-reference approach: functions that call them look them up lazily. + +// ── 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; + } + window._MediaSessionManager && window._MediaSessionManager.update({ title: meta.title || name, artist: meta.artist || '', artworkPath: path }); + }) + .catch(() => { + window._MediaSessionManager && window._MediaSessionManager.update({ title: name, artist: '', artworkPath: path }); + }); + + if (!currentIsVideo) syncArt(path); + window._FileBrowser && window._FileBrowser.setPlaying(path); + window._QueuePanel && window._QueuePanel.refresh(); + + if (play) { + try { + await med.play(); + syncPlayIcon(true); + window._WakeLock && window._WakeLock.acquire(); + if (isVideo) window._FullscreenPlayer && window._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); window._WakeLock && window._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); window._WakeLock && window._WakeLock.acquire(); }).catch(() => {}); + } + } else { + med.pause(); + syncPlayIcon(false); + window._WakeLock && window._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); + window._QueuePanel && window._QueuePanel.refresh(); + saveState(); + } + + function addAfterCurrent(path) { + queue.splice(queueIndex + 1, 0, path); + if (originalQueue) originalQueue.splice(originalQueue.length, 0, path); + window._QueuePanel && window._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(); + window._QueuePanel && window._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) window._WakeLock && window._WakeLock.release(); + }); + onBoth('pause', function() { syncPlayIcon(false); window._MediaSessionManager && window._MediaSessionManager.setPlaying(false); clog('audio:pause', { t: Math.round(med.currentTime), lgp: Math.round(lastGoodPosition), hidden: document.hidden }); }); + onBoth('play', function() { syncPlayIcon(true); window._MediaSessionManager && window._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) window._MediaSessionManager && window._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) window._FullscreenPlayer && window._FullscreenPlayer.open(); + }); + + // Queue view button + document.getElementById('btn-queue-view').addEventListener('click', () => window._QueuePanel && window._QueuePanel.open()); + + // Fullscreen → queue + document.getElementById('fs-btn-queue').addEventListener('click', () => { + window._FullscreenPlayer && window._FullscreenPlayer.close(); + window._QueuePanel && window._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; + } + window._MediaSessionManager && window._MediaSessionManager.update({ title: meta.title || name, artist: meta.artist || '', artworkPath: currentPath }); + }) + .catch(() => { + window._MediaSessionManager && window._MediaSessionManager.update({ title: name, artist: '', artworkPath: currentPath }); + }); + + window._FileBrowser && window._FileBrowser.setPlaying(currentPath); + window._QueuePanel && window._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 (_) {} + + window._FileBrowser && window._FileBrowser.setPlaying(null); + window._QueuePanel && window._QueuePanel.refresh(); + window._WakeLock && window._WakeLock.release(); + window._FullscreenPlayer && window._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(); + window._QueuePanel && window._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(); + window._QueuePanel && window._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 || ''; + window._MediaSessionManager && window._MediaSessionManager.update({ title: meta.title || name, artist: meta.artist || '', artworkPath: path }); + }) + .catch(() => { + window._MediaSessionManager && window._MediaSessionManager.update({ title: name, artist: '', artworkPath: path }); + }); + + window._FileBrowser && window._FileBrowser.setPlaying(path); + window._QueuePanel && window._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; + } + }, + }; +})(); + +// ── Wire Player.getState / Player.loadState ─────────────────────────────────── +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 }); + } +}; + +export { Player }; diff --git a/public/js/playlists.js b/public/js/playlists.js new file mode 100644 index 0000000..f41869d --- /dev/null +++ b/public/js/playlists.js @@ -0,0 +1,184 @@ +'use strict'; + +import { Player } from './player.js'; +import { FullscreenPlayer } from './fullscreen.js'; + +// ── 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 }; +})(); + +export { Playlists }; diff --git a/public/js/queue.js b/public/js/queue.js new file mode 100644 index 0000000..bc30188 --- /dev/null +++ b/public/js/queue.js @@ -0,0 +1,199 @@ +'use strict'; + +import { Player } from './player.js'; +import { enc, icons } from './utils.js'; + +// ── 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 }; +})(); + +export { QueueModal, QueuePanel }; diff --git a/public/js/sync-manager.js b/public/js/sync-manager.js new file mode 100644 index 0000000..7ab12d7 --- /dev/null +++ b/public/js/sync-manager.js @@ -0,0 +1,245 @@ +'use strict'; + +import { Player } from './player.js'; + +// ── 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 { + // Active device cleared its queue — hide takeover and reset local display + if (!state.queue?.length && !state.activeDeviceId) { + takeoverBanner.hidden = true; + takeoverShown = false; + if (fsTakeoverRow) fsTakeoverRow.hidden = true; + return; + } + // 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(); }); + + // ── Push cleared state after Player.clear() runs ───────────────────────────── + // Player.clear() pauses audio first (which pushes old queue), then empties queue. + // We push after a tick so getPlayerState() sees the already-cleared queue. + document.getElementById('btn-clear')?.addEventListener('click', () => { + setTimeout(() => push(), 0); + }); + + // ── 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 }; +})(); + +export { SyncManager }; diff --git a/public/js/user-menu.js b/public/js/user-menu.js new file mode 100644 index 0000000..a3b3dfa --- /dev/null +++ b/public/js/user-menu.js @@ -0,0 +1,34 @@ +'use strict'; + +import { Auth } from './auth.js'; +import { AdminPanel } from './admin.js'; + +// ── 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 }; +})(); + +export { UserMenu }; diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100644 index 0000000..adfc4e4 --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,48 @@ +'use strict'; + +// ── Remote logging (shows up in docker logs) ────────────────────────────────── +export function clog(event, data) { + try { + const body = JSON.stringify({ event, data }); + navigator.sendBeacon('/api/clientlog', new Blob([body], { type: 'application/json' })); + } catch (_) {} +} + +// ── Utilities ──────────────────────────────────────────────────────────────── + +export 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')}`; +} + +export function formatSize(bytes) { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export function enc(p) { return encodeURIComponent(p); } + +// ── Icon SVGs ───────────────────────────────────────────────────────────────── +const S = 'stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"'; +export const icons = { + play: ``, + pause: ``, + prev: ``, + next: ``, + shuffle: ``, + repeat: ``, + repeatOne: ``, + close: ``, + note: ``, + folder: ``, + check: ``, + drag: ``, + menu: ``, + grid: ``, + volume: ``, + playSmall: ``, +}; diff --git a/public/js/view-toggle.js b/public/js/view-toggle.js new file mode 100644 index 0000000..e31fafc --- /dev/null +++ b/public/js/view-toggle.js @@ -0,0 +1,30 @@ +'use strict'; + +// ── 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'); + window._FileBrowser && window._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 }; +})(); + +export { ViewToggle }; diff --git a/public/style.css b/public/style.css index f94954a..3656660 100644 --- a/public/style.css +++ b/public/style.css @@ -1,1150 +1,10 @@ -*, *::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; -} - -/* ── 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); -} - -/* ── 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; -} - -/* ── 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; -} - -/* ── 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; -} - -/* ── 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); } - -/* ── 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); } -} - -/* ── 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; } -} - -/* ── 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; } - -/* ── 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); } - -/* ── 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); } - -/* ── 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); } - -/* ── 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); } -} +@import url('css/base.css'); +@import url('css/topbar.css'); +@import url('css/browser.css'); +@import url('css/player.css'); +@import url('css/queue.css'); +@import url('css/fullscreen.css'); +@import url('css/auth.css'); +@import url('css/admin.css'); +@import url('css/playlists.css'); +@import url('css/sync.css'); diff --git a/routes/admin.js b/routes/admin.js new file mode 100644 index 0000000..85801e8 --- /dev/null +++ b/routes/admin.js @@ -0,0 +1,56 @@ +const express = require('express'); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const { getUsers, saveUsers } = require('../lib/data.js'); +const { validUsername, validPassword, requireAdmin } = require('../middleware/auth.js'); +const router = express.Router(); + +router.get('/users', requireAdmin, (req, res) => { + res.json(getUsers().map(({ passwordHash, ...u }) => u)); +}); + +router.post('/users', requireAdmin, async (req, res) => { + const { username, password, role, allowedPaths } = req.body || {}; + if (!validUsername(username)) return res.status(400).json({ error: 'Invalid username' }); + if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + const users = getUsers(); + if (users.find(u => u.username === username)) return res.status(400).json({ error: 'Username taken' }); + const passwordHash = await bcrypt.hash(password, 12); + const user = { id: crypto.randomUUID(), username, passwordHash, role, allowedPaths: Array.isArray(allowedPaths) ? allowedPaths : [] }; + users.push(user); + saveUsers(users); + const { passwordHash: _, ...out } = user; + res.json(out); +}); + +router.patch('/users/:id', requireAdmin, async (req, res) => { + const users = getUsers(); + const idx = users.findIndex(u => u.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'User not found' }); + const { password, role, allowedPaths } = req.body || {}; + if (password !== undefined) { + if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + users[idx].passwordHash = await bcrypt.hash(password, 12); + } + if (role !== undefined) { + if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + users[idx].role = role; + } + if (allowedPaths !== undefined) users[idx].allowedPaths = Array.isArray(allowedPaths) ? allowedPaths : []; + saveUsers(users); + const { passwordHash, ...out } = users[idx]; + res.json(out); +}); + +router.delete('/users/:id', requireAdmin, (req, res) => { + if (req.user.id === req.params.id) return res.status(400).json({ error: 'Cannot delete yourself' }); + const users = getUsers(); + const idx = users.findIndex(u => u.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'User not found' }); + users.splice(idx, 1); + saveUsers(users); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..58ca078 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,47 @@ +const express = require('express'); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const { getUsers, saveUsers } = require('../lib/data.js'); +const { validUsername, validPassword, requireAuth } = require('../middleware/auth.js'); +const router = express.Router(); + +router.get('/setup-check', (req, res) => { + res.json({ needsSetup: getUsers().length === 0 }); +}); + +router.post('/setup', async (req, res) => { + const users = getUsers(); + if (users.length > 0) return res.status(400).json({ error: 'Setup already done' }); + const { username, password } = req.body || {}; + if (!validUsername(username)) return res.status(400).json({ error: 'Invalid username (3-32 chars, alphanumeric/underscore/dash)' }); + if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + const passwordHash = await bcrypt.hash(password, 12); + const user = { id: crypto.randomUUID(), username, passwordHash, role: 'admin', allowedPaths: [] }; + users.push(user); + saveUsers(users); + req.session.userId = user.id; + res.json({ id: user.id, username: user.username, role: user.role }); +}); + +router.post('/login', async (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + const users = getUsers(); + const user = users.find(u => u.username === username); + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) return res.status(401).json({ error: 'Invalid credentials' }); + req.session.userId = user.id; + res.json({ id: user.id, username: user.username, role: user.role }); +}); + +router.post('/logout', (req, res) => { + req.session.destroy(() => {}); + res.json({ ok: true }); +}); + +router.get('/me', requireAuth, (req, res) => { + res.json({ id: req.user.id, username: req.user.username, role: req.user.role }); +}); + +module.exports = router; diff --git a/routes/media.js b/routes/media.js new file mode 100644 index 0000000..ec36811 --- /dev/null +++ b/routes/media.js @@ -0,0 +1,240 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const mime = require('mime-types'); +const { requireAuth } = require('../middleware/auth.js'); +const { MEDIA_ROOT, AUDIO_EXTS, VIDEO_EXTS, ARTWORK_NAMES, safePath, canAccess } = require('../lib/media.js'); +const router = express.Router(); + +// ── Search ──────────────────────────────────────────────────────────────────── +router.get('/search', requireAuth, (req, res) => { + const q = (req.query.q || '').trim().toLowerCase(); + if (q.length < 2) return res.json([]); + + const results = []; + const LIMIT = 200; + + function walk(relPath) { + if (results.length >= LIMIT) return; + let entries; + try { entries = fs.readdirSync(path.join(MEDIA_ROOT, relPath), { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (results.length >= LIMIT) return; + const rel = relPath ? path.join(relPath, entry.name) : entry.name; + if (entry.isDirectory()) { + if (!canAccess(req.user, rel)) continue; + walk(rel); + } else { + const ext = path.extname(entry.name).toLowerCase(); + if (!AUDIO_EXTS.has(ext) && !VIDEO_EXTS.has(ext)) continue; + if (!canAccess(req.user, relPath || '')) continue; + if (!entry.name.toLowerCase().includes(q)) continue; + let size = 0, mtime = 0; + try { const s = fs.statSync(path.join(MEDIA_ROOT, rel)); size = s.size; mtime = s.mtimeMs; } catch {} + results.push({ name: entry.name, type: 'file', path: rel, size, ext, mtime }); + } + } + } + + walk(''); + res.json(results); +}); + +// ── Browse directory ────────────────────────────────────────────────────────── +router.get('/browse', requireAuth, (req, res) => { + const relPath = req.query.path || ''; + + if (!canAccess(req.user, relPath)) { + return res.status(403).json({ error: 'Access denied' }); + } + + let full; + try { + full = safePath(relPath); + } catch { + return res.status(400).json({ error: 'Invalid path' }); + } + + let entries; + try { + entries = fs.readdirSync(full, { withFileTypes: true }); + } catch (e) { + return res.status(404).json({ error: 'Directory not found' }); + } + + const dirs = []; + const files = []; + + for (const entry of entries) { + const ext = path.extname(entry.name).toLowerCase(); + const rel = path.join(relPath, entry.name); + + if (entry.isDirectory()) { + // At root level, filter by allowedPaths for non-admins + if (!relPath && req.user.role !== 'admin') { + if (!req.user.allowedPaths.includes(entry.name)) continue; + } + let mtime = 0; + try { mtime = fs.statSync(path.join(full, entry.name)).mtimeMs; } catch {} + dirs.push({ name: entry.name, type: 'dir', path: rel, mtime }); + } else if (AUDIO_EXTS.has(ext) || VIDEO_EXTS.has(ext)) { + let size = 0, mtime = 0; + try { + const s = fs.statSync(path.join(full, entry.name)); + size = s.size; + mtime = s.mtimeMs; + } catch {} + files.push({ name: entry.name, type: 'file', path: rel, size, ext, mtime }); + } + } + + res.json({ path: relPath, items: [...dirs, ...files] }); +}); + +// ── Stream audio with range support ────────────────────────────────────────── +router.get('/stream', requireAuth, (req, res) => { + const relPath = req.query.path || ''; + if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); + + let full; + try { + full = safePath(relPath); + } catch { + return res.status(400).json({ error: 'Invalid path' }); + } + + let stat; + try { + stat = fs.statSync(full); + } catch { + return res.status(404).json({ error: 'File not found' }); + } + + const mimeType = mime.lookup(full) || 'application/octet-stream'; + const fileSize = stat.size; + const range = req.headers.range; + const name = path.basename(full); + + if (range) { + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunkSize = end - start + 1; + + console.log(`[stream] "${name}" bytes=${start}-${end}/${fileSize}`); + const stream = fs.createReadStream(full, { start, end }); + stream.on('close', () => console.log(`[stream] "${name}" closed at ${start}-${end}`)); + stream.on('error', e => console.log(`[stream] "${name}" error: ${e.message}`)); + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunkSize, + 'Content-Type': mimeType, + }); + stream.pipe(res); + } else { + console.log(`[stream] "${name}" full (no range), size=${fileSize}`); + const stream = fs.createReadStream(full); + stream.on('close', () => console.log(`[stream] "${name}" closed (full)`)); + stream.on('error', e => console.log(`[stream] "${name}" error: ${e.message}`)); + + res.writeHead(200, { + 'Content-Length': fileSize, + 'Content-Type': mimeType, + 'Accept-Ranges': 'bytes', + }); + stream.pipe(res); + } +}); + +// ── Artwork: embedded → folder image → 404 ─────────────────────────────────── +router.get('/artwork', requireAuth, async (req, res) => { + const relPath = req.query.path || ''; + if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); + + let full; + try { + full = safePath(relPath); + } catch { + return res.status(400).json({ error: 'Invalid path' }); + } + + // Try embedded art + const ext = path.extname(full).toLowerCase(); + if (AUDIO_EXTS.has(ext)) { + try { + const { parseFile } = await import('music-metadata'); + const meta = await parseFile(full, { skipCovers: false, duration: false }); + const pic = meta.common.picture && meta.common.picture[0]; + if (pic) { + res.set('Content-Type', pic.format); + res.set('Cache-Control', 'public, max-age=86400'); + return res.send(Buffer.from(pic.data)); + } + } catch {} + } + + // Try folder images + const dir = path.extname(full) ? path.dirname(full) : full; + for (const name of ARTWORK_NAMES) { + const imgPath = path.join(dir, name); + if (fs.existsSync(imgPath)) { + res.set('Cache-Control', 'public, max-age=86400'); + return res.sendFile(imgPath); + } + } + + res.status(404).json({ error: 'No artwork' }); +}); + +// ── Metadata ────────────────────────────────────────────────────────────────── +router.get('/metadata', requireAuth, async (req, res) => { + const relPath = req.query.path || ''; + if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); + + let full; + try { + full = safePath(relPath); + } catch { + return res.status(400).json({ error: 'Invalid path' }); + } + + let stat; + try { + stat = fs.statSync(full); + } catch { + return res.status(404).json({ error: 'File not found' }); + } + + try { + const { parseFile } = await import('music-metadata'); + const meta = await parseFile(full, { skipCovers: true, duration: true }); + res.json({ + title: meta.common.title || path.basename(full, path.extname(full)), + artist: meta.common.artist || meta.common.albumartist || '', + album: meta.common.album || '', + duration: meta.format.duration || 0, + size: stat.size, + }); + } catch { + res.json({ + title: path.basename(full, path.extname(full)), + artist: '', + album: '', + duration: 0, + size: stat.size, + }); + } +}); + +// ── Client-side event log ───────────────────────────────────────────────────── +router.post('/clientlog', (req, res) => { + const { event, data } = req.body || {}; + const ts = new Date().toISOString().replace('T', ' ').slice(0, 23); + const extra = data ? ' ' + JSON.stringify(data) : ''; + console.log(`[client ${ts}] ${event}${extra}`); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/playlists.js b/routes/playlists.js new file mode 100644 index 0000000..925fabf --- /dev/null +++ b/routes/playlists.js @@ -0,0 +1,47 @@ +const express = require('express'); +const crypto = require('crypto'); +const { getPlaylists, savePlaylists } = require('../lib/data.js'); +const { requireAuth } = require('../middleware/auth.js'); +const router = express.Router(); + +router.get('/', requireAuth, (req, res) => { + res.json(getPlaylists().filter(p => p.userId === req.user.id)); +}); + +router.post('/', requireAuth, (req, res) => { + const { name } = req.body || {}; + if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Name required' }); + const playlist = { id: crypto.randomUUID(), userId: req.user.id, name: name.trim().slice(0, 100), tracks: [], createdAt: new Date().toISOString() }; + const playlists = getPlaylists(); + playlists.push(playlist); + savePlaylists(playlists); + res.json(playlist); +}); + +router.get('/:id', requireAuth, (req, res) => { + const playlist = getPlaylists().find(p => p.id === req.params.id && p.userId === req.user.id); + if (!playlist) return res.status(404).json({ error: 'Playlist not found' }); + res.json(playlist); +}); + +router.put('/:id', requireAuth, (req, res) => { + const playlists = getPlaylists(); + const idx = playlists.findIndex(p => p.id === req.params.id && p.userId === req.user.id); + if (idx === -1) return res.status(404).json({ error: 'Playlist not found' }); + const { name, tracks } = req.body || {}; + if (name !== undefined) playlists[idx].name = String(name).trim().slice(0, 100); + if (tracks !== undefined) playlists[idx].tracks = Array.isArray(tracks) ? tracks : []; + savePlaylists(playlists); + res.json(playlists[idx]); +}); + +router.delete('/:id', requireAuth, (req, res) => { + const playlists = getPlaylists(); + const idx = playlists.findIndex(p => p.id === req.params.id && p.userId === req.user.id); + if (idx === -1) return res.status(404).json({ error: 'Playlist not found' }); + playlists.splice(idx, 1); + savePlaylists(playlists); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/state.js b/routes/state.js new file mode 100644 index 0000000..518fde1 --- /dev/null +++ b/routes/state.js @@ -0,0 +1,41 @@ +const express = require('express'); +const { getUserState, saveUserState } = require('../lib/data.js'); +const { requireAuth } = require('../middleware/auth.js'); +const router = express.Router(); + +router.get('/state', requireAuth, (req, res) => { + const state = getUserState(); + res.json(state[req.user.id] || {}); +}); + +router.put('/state', requireAuth, (req, res) => { + const { queue, index, position, duration, playing, sortKey, sortDir, deviceId } = req.body || {}; + const state = getUserState(); + state[req.user.id] = { + queue: Array.isArray(queue) ? queue : (state[req.user.id]?.queue || []), + index: typeof index === 'number' ? index : (state[req.user.id]?.index ?? -1), + position: typeof position === 'number' ? position : (state[req.user.id]?.position || 0), + duration: typeof duration === 'number' ? duration : (state[req.user.id]?.duration || 0), + playing: typeof playing === 'boolean' ? playing : (state[req.user.id]?.playing || false), + sortKey: sortKey || state[req.user.id]?.sortKey || 'name', + sortDir: sortDir || state[req.user.id]?.sortDir || 'asc', + activeDeviceId: Array.isArray(queue) && queue.length === 0 ? null : (deviceId || state[req.user.id]?.activeDeviceId || null), + activeDeviceAt: Array.isArray(queue) && queue.length === 0 ? 0 : Date.now(), + pendingCommand: null, + }; + saveUserState(state); + res.json({ ok: true }); +}); + +router.post('/command', requireAuth, (req, res) => { + const { type, data } = req.body || {}; + const valid = ['playpause', 'next', 'prev', 'seek', 'skip', 'loadqueue']; + if (!valid.includes(type)) return res.status(400).json({ error: 'Invalid type' }); + const state = getUserState(); + if (!state[req.user.id]) return res.status(404).json({ error: 'No active state' }); + state[req.user.id].pendingCommand = { type, data, sentAt: Date.now() }; + saveUserState(state); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/server.js b/server.js index 1613c98..0d35b5c 100644 --- a/server.js +++ b/server.js @@ -1,47 +1,15 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); -const mime = require('mime-types'); const crypto = require('crypto'); -const bcrypt = require('bcryptjs'); const session = require('express-session'); const FileStore = require('session-file-store')(session); +const { DATA_DIR, SESSIONS_DIR } = require('./lib/data.js'); + const app = express(); app.use(express.json({ limit: '2mb' })); const PORT = process.env.PORT || 3000; -const MEDIA_ROOT = path.resolve(process.env.MEDIA_ROOT || '/media'); - -// ── Data directory setup ────────────────────────────────────────────────────── -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 ? '[]' : '{}'); - } -} - -// ── Data helpers ────────────────────────────────────────────────────────────── -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)); -} - -function getUsers() { return readJSON(USERS_FILE); } -function saveUsers(u) { writeJSON(USERS_FILE, u); } -function getUserState() { return readJSON(USERSTATE_FILE); } -function saveUserState(s) { writeJSON(USERSTATE_FILE, s); } -function getPlaylists() { return readJSON(PLAYLISTS_FILE); } -function savePlaylists(p) { writeJSON(PLAYLISTS_FILE, p); } // ── Session setup ───────────────────────────────────────────────────────────── app.use(session({ @@ -58,469 +26,23 @@ app.use(session({ cookie: { httpOnly: true, maxAge: 30 * 24 * 3600 * 1000, sameSite: 'lax' }, })); -// ── Input validation ────────────────────────────────────────────────────────── -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; } - -// ── Auth middleware ─────────────────────────────────────────────────────────── -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(); - }); -} - -// ── Folder access check ─────────────────────────────────────────────────────── -function canAccess(user, relPath) { - if (user.role === 'admin') return true; - if (!relPath) return true; // root is ok, we filter entries - return user.allowedPaths.some(p => relPath === p || relPath.startsWith(p + '/')); -} - -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; -} - -// ── Auth routes (no auth required for login/setup-check) ───────────────────── -app.get('/api/auth/setup-check', (req, res) => { - const users = getUsers(); - res.json({ needsSetup: users.length === 0 }); -}); - -app.post('/api/auth/setup', async (req, res) => { - const users = getUsers(); - if (users.length > 0) return res.status(400).json({ error: 'Setup already done' }); - const { username, password } = req.body || {}; - if (!validUsername(username)) return res.status(400).json({ error: 'Invalid username (3-32 chars, alphanumeric/underscore/dash)' }); - if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); - const passwordHash = await bcrypt.hash(password, 12); - const user = { id: crypto.randomUUID(), username, passwordHash, role: 'admin', allowedPaths: [] }; - users.push(user); - saveUsers(users); - req.session.userId = user.id; - res.json({ id: user.id, username: user.username, role: user.role }); -}); - -app.post('/api/auth/login', async (req, res) => { - const { username, password } = req.body || {}; - if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); - const users = getUsers(); - const user = users.find(u => u.username === username); - if (!user) return res.status(401).json({ error: 'Invalid credentials' }); - const ok = await bcrypt.compare(password, user.passwordHash); - if (!ok) return res.status(401).json({ error: 'Invalid credentials' }); - req.session.userId = user.id; - res.json({ id: user.id, username: user.username, role: user.role }); -}); - -app.post('/api/auth/logout', (req, res) => { - req.session.destroy(() => {}); - res.json({ ok: true }); -}); - -app.get('/api/auth/me', requireAuth, (req, res) => { - res.json({ id: req.user.id, username: req.user.username, role: req.user.role }); -}); - -// ── Admin user management ───────────────────────────────────────────────────── -app.get('/api/admin/users', requireAdmin, (req, res) => { - const users = getUsers().map(({ passwordHash, ...u }) => u); - res.json(users); -}); - -app.post('/api/admin/users', requireAdmin, async (req, res) => { - const { username, password, role, allowedPaths } = req.body || {}; - if (!validUsername(username)) return res.status(400).json({ error: 'Invalid username' }); - if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); - if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); - const users = getUsers(); - if (users.find(u => u.username === username)) return res.status(400).json({ error: 'Username taken' }); - const passwordHash = await bcrypt.hash(password, 12); - const user = { - id: crypto.randomUUID(), - username, - passwordHash, - role, - allowedPaths: Array.isArray(allowedPaths) ? allowedPaths : [], - }; - users.push(user); - saveUsers(users); - const { passwordHash: _, ...out } = user; - res.json(out); -}); - -app.patch('/api/admin/users/:id', requireAdmin, async (req, res) => { - const users = getUsers(); - const idx = users.findIndex(u => u.id === req.params.id); - if (idx === -1) return res.status(404).json({ error: 'User not found' }); - const { password, role, allowedPaths } = req.body || {}; - if (password !== undefined) { - if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); - users[idx].passwordHash = await bcrypt.hash(password, 12); - } - if (role !== undefined) { - if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); - users[idx].role = role; - } - if (allowedPaths !== undefined) { - users[idx].allowedPaths = Array.isArray(allowedPaths) ? allowedPaths : []; - } - saveUsers(users); - const { passwordHash, ...out } = users[idx]; - res.json(out); -}); - -app.delete('/api/admin/users/:id', requireAdmin, (req, res) => { - if (req.user.id === req.params.id) return res.status(400).json({ error: 'Cannot delete yourself' }); - const users = getUsers(); - const idx = users.findIndex(u => u.id === req.params.id); - if (idx === -1) return res.status(404).json({ error: 'User not found' }); - users.splice(idx, 1); - saveUsers(users); - res.json({ ok: true }); -}); - -// ── User state sync ─────────────────────────────────────────────────────────── -app.get('/api/state', requireAuth, (req, res) => { - const state = getUserState(); - res.json(state[req.user.id] || {}); -}); - -app.put('/api/state', requireAuth, (req, res) => { - const { queue, index, position, duration, playing, sortKey, sortDir, deviceId } = req.body || {}; - const state = getUserState(); - state[req.user.id] = { - queue: Array.isArray(queue) ? queue : (state[req.user.id]?.queue || []), - index: typeof index === 'number' ? index : (state[req.user.id]?.index ?? -1), - position: typeof position === 'number' ? position : (state[req.user.id]?.position || 0), - duration: typeof duration === 'number' ? duration : (state[req.user.id]?.duration || 0), - playing: typeof playing === 'boolean' ? playing : (state[req.user.id]?.playing || false), - sortKey: sortKey || state[req.user.id]?.sortKey || 'name', - sortDir: sortDir || state[req.user.id]?.sortDir || 'asc', - activeDeviceId: deviceId || state[req.user.id]?.activeDeviceId || null, - activeDeviceAt: Date.now(), - pendingCommand: null, - }; - saveUserState(state); - res.json({ ok: true }); -}); - -app.post('/api/command', requireAuth, (req, res) => { - const { type, data } = req.body || {}; - const valid = ['playpause', 'next', 'prev', 'seek', 'skip', 'loadqueue']; - if (!valid.includes(type)) return res.status(400).json({ error: 'Invalid type' }); - const state = getUserState(); - if (!state[req.user.id]) return res.status(404).json({ error: 'No active state' }); - state[req.user.id].pendingCommand = { type, data, sentAt: Date.now() }; - saveUserState(state); - res.json({ ok: true }); -}); - -// ── Playlists ───────────────────────────────────────────────────────────────── -app.get('/api/playlists', requireAuth, (req, res) => { - const playlists = getPlaylists().filter(p => p.userId === req.user.id); - res.json(playlists); -}); - -app.post('/api/playlists', requireAuth, (req, res) => { - const { name } = req.body || {}; - if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Name required' }); - const playlist = { - id: crypto.randomUUID(), - userId: req.user.id, - name: name.trim().slice(0, 100), - tracks: [], - createdAt: new Date().toISOString(), - }; - const playlists = getPlaylists(); - playlists.push(playlist); - savePlaylists(playlists); - res.json(playlist); -}); - -app.get('/api/playlists/:id', requireAuth, (req, res) => { - const playlist = getPlaylists().find(p => p.id === req.params.id && p.userId === req.user.id); - if (!playlist) return res.status(404).json({ error: 'Playlist not found' }); - res.json(playlist); -}); - -app.put('/api/playlists/:id', requireAuth, (req, res) => { - const playlists = getPlaylists(); - const idx = playlists.findIndex(p => p.id === req.params.id && p.userId === req.user.id); - if (idx === -1) return res.status(404).json({ error: 'Playlist not found' }); - const { name, tracks } = req.body || {}; - if (name !== undefined) playlists[idx].name = String(name).trim().slice(0, 100); - if (tracks !== undefined) playlists[idx].tracks = Array.isArray(tracks) ? tracks : []; - savePlaylists(playlists); - res.json(playlists[idx]); -}); - -app.delete('/api/playlists/:id', requireAuth, (req, res) => { - const playlists = getPlaylists(); - const idx = playlists.findIndex(p => p.id === req.params.id && p.userId === req.user.id); - if (idx === -1) return res.status(404).json({ error: 'Playlist not found' }); - playlists.splice(idx, 1); - savePlaylists(playlists); - res.json({ ok: true }); -}); - -// ── Search ──────────────────────────────────────────────────────────────────── -app.get('/api/search', requireAuth, (req, res) => { - const q = (req.query.q || '').trim().toLowerCase(); - if (q.length < 2) return res.json([]); - - const results = []; - const LIMIT = 200; - - function walk(relPath) { - if (results.length >= LIMIT) return; - let entries; - try { entries = fs.readdirSync(path.join(MEDIA_ROOT, relPath), { withFileTypes: true }); } catch { return; } - for (const entry of entries) { - if (results.length >= LIMIT) return; - const rel = relPath ? path.join(relPath, entry.name) : entry.name; - if (entry.isDirectory()) { - if (!canAccess(req.user, rel)) continue; - walk(rel); - } else { - const ext = path.extname(entry.name).toLowerCase(); - if (!AUDIO_EXTS.has(ext) && !VIDEO_EXTS.has(ext)) continue; - if (!canAccess(req.user, relPath || '')) continue; - if (!entry.name.toLowerCase().includes(q)) continue; - let size = 0, mtime = 0; - try { const s = fs.statSync(path.join(MEDIA_ROOT, rel)); size = s.size; mtime = s.mtimeMs; } catch {} - results.push({ name: entry.name, type: 'file', path: rel, size, ext, mtime }); - } - } - } - - walk(''); - res.json(results); -}); - -// ── Browse directory ────────────────────────────────────────────────────────── -app.get('/api/browse', requireAuth, (req, res) => { - const relPath = req.query.path || ''; - - if (!canAccess(req.user, relPath)) { - return res.status(403).json({ error: 'Access denied' }); - } - - let full; - try { - full = safePath(relPath); - } catch { - return res.status(400).json({ error: 'Invalid path' }); - } - - let entries; - try { - entries = fs.readdirSync(full, { withFileTypes: true }); - } catch (e) { - return res.status(404).json({ error: 'Directory not found' }); - } - - const dirs = []; - const files = []; - - for (const entry of entries) { - const ext = path.extname(entry.name).toLowerCase(); - const rel = path.join(relPath, entry.name); - - if (entry.isDirectory()) { - // At root level, filter by allowedPaths for non-admins - if (!relPath && req.user.role !== 'admin') { - if (!req.user.allowedPaths.includes(entry.name)) continue; - } - let mtime = 0; - try { mtime = fs.statSync(path.join(full, entry.name)).mtimeMs; } catch {} - dirs.push({ name: entry.name, type: 'dir', path: rel, mtime }); - } else if (AUDIO_EXTS.has(ext) || VIDEO_EXTS.has(ext)) { - let size = 0, mtime = 0; - try { - const s = fs.statSync(path.join(full, entry.name)); - size = s.size; - mtime = s.mtimeMs; - } catch {} - files.push({ name: entry.name, type: 'file', path: rel, size, ext, mtime }); - } - } - - res.json({ path: relPath, items: [...dirs, ...files] }); -}); - -// Stream audio with range support -app.get('/api/stream', requireAuth, (req, res) => { - const relPath = req.query.path || ''; - if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); - - let full; - try { - full = safePath(relPath); - } catch { - return res.status(400).json({ error: 'Invalid path' }); - } - - let stat; - try { - stat = fs.statSync(full); - } catch { - return res.status(404).json({ error: 'File not found' }); - } +// ── Routes ──────────────────────────────────────────────────────────────────── +app.use('/api/auth', require('./routes/auth.js')); +app.use('/api/admin', require('./routes/admin.js')); +app.use('/api', require('./routes/state.js')); +app.use('/api/playlists', require('./routes/playlists.js')); +app.use('/api', require('./routes/media.js')); - const mimeType = mime.lookup(full) || 'application/octet-stream'; - const fileSize = stat.size; - const range = req.headers.range; - const name = path.basename(full); - - if (range) { - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; - const chunkSize = end - start + 1; - - console.log(`[stream] "${name}" bytes=${start}-${end}/${fileSize}`); - const stream = fs.createReadStream(full, { start, end }); - stream.on('close', () => console.log(`[stream] "${name}" closed at ${start}-${end}`)); - stream.on('error', e => console.log(`[stream] "${name}" error: ${e.message}`)); - - res.writeHead(206, { - 'Content-Range': `bytes ${start}-${end}/${fileSize}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': chunkSize, - 'Content-Type': mimeType, - }); - stream.pipe(res); - } else { - console.log(`[stream] "${name}" full (no range), size=${fileSize}`); - const stream = fs.createReadStream(full); - stream.on('close', () => console.log(`[stream] "${name}" closed (full)`)); - stream.on('error', e => console.log(`[stream] "${name}" error: ${e.message}`)); - - res.writeHead(200, { - 'Content-Length': fileSize, - 'Content-Type': mimeType, - 'Accept-Ranges': 'bytes', - }); - stream.pipe(res); - } -}); - -// Artwork: embedded → folder image → 404 -app.get('/api/artwork', requireAuth, async (req, res) => { - const relPath = req.query.path || ''; - if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); - - let full; - try { - full = safePath(relPath); - } catch { - return res.status(400).json({ error: 'Invalid path' }); - } - - // Try embedded art - const ext = path.extname(full).toLowerCase(); - if (AUDIO_EXTS.has(ext)) { - try { - const { parseFile } = await import('music-metadata'); - const meta = await parseFile(full, { skipCovers: false, duration: false }); - const pic = meta.common.picture && meta.common.picture[0]; - if (pic) { - res.set('Content-Type', pic.format); - res.set('Cache-Control', 'public, max-age=86400'); - return res.send(Buffer.from(pic.data)); - } - } catch {} - } - - // Try folder images - const dir = path.extname(full) ? path.dirname(full) : full; - for (const name of ARTWORK_NAMES) { - const imgPath = path.join(dir, name); - if (fs.existsSync(imgPath)) { - res.set('Cache-Control', 'public, max-age=86400'); - return res.sendFile(imgPath); - } - } - - res.status(404).json({ error: 'No artwork' }); -}); - -// Metadata -app.get('/api/metadata', requireAuth, async (req, res) => { - const relPath = req.query.path || ''; - if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); - - let full; - try { - full = safePath(relPath); - } catch { - return res.status(400).json({ error: 'Invalid path' }); - } - - let stat; - try { - stat = fs.statSync(full); - } catch { - return res.status(404).json({ error: 'File not found' }); - } - - try { - const { parseFile } = await import('music-metadata'); - const meta = await parseFile(full, { skipCovers: true, duration: true }); - res.json({ - title: meta.common.title || path.basename(full, path.extname(full)), - artist: meta.common.artist || meta.common.albumartist || '', - album: meta.common.album || '', - duration: meta.format.duration || 0, - size: stat.size, - }); - } catch { - res.json({ - title: path.basename(full, path.extname(full)), - artist: '', - album: '', - duration: 0, - size: stat.size, - }); - } -}); - -// Client-side event log (audio lifecycle, visibility changes, reconnects) -app.post('/api/clientlog', (req, res) => { - const { event, data } = req.body || {}; - const ts = new Date().toISOString().replace('T', ' ').slice(0, 23); - const extra = data ? ' ' + JSON.stringify(data) : ''; - console.log(`[client ${ts}] ${event}${extra}`); - res.json({ ok: true }); -}); - -// Serve frontend +// ── Serve frontend ──────────────────────────────────────────────────────────── app.use(express.static(path.join(__dirname, 'public'))); const server = app.listen(PORT, () => { + const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media'; console.log(`SNAP listening on http://0.0.0.0:${PORT}`); console.log(`Media root: ${MEDIA_ROOT}`); }); -// WebSocket keepalive endpoint (/_ws) + +// ── WebSocket keepalive endpoint (/_ws) ─────────────────────────────────────── // Server → client pings every 8 s keep the phone's WiFi radio out of Android's // deep power-save mode while audio plays with screen locked — same role as // WireGuard PersistentKeepalive. No npm dependency; uses built-in crypto. @@ -550,7 +72,7 @@ server.on('upgrade', (req, socket) => { socket.on('error', cleanup); }); -// Disable timeouts that would cut long-running audio streams +// ── Disable timeouts that would cut long-running audio streams ──────────────── server.timeout = 0; server.requestTimeout = 0; server.keepAliveTimeout = 30000;