summaryrefslogtreecommitdiff
path: root/web/js
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-29 20:19:34 +0300
committerPaul Buetow <paul@buetow.org>2026-04-29 20:19:34 +0300
commit4a70c0ea083306cb79d0fa325d331ffa4333154a (patch)
tree84df7e2f781974ef45ac8c34f1a59e80fdd5c41d /web/js
parentcf30414c2a0696cc75c615f4da66ea85d0522c42 (diff)
feat: create web PWA frontend
Diffstat (limited to 'web/js')
-rw-r--r--web/js/admin.js101
-rw-r--r--web/js/api.js66
-rw-r--r--web/js/app.js311
-rw-r--r--web/js/keyboard.js28
-rw-r--r--web/js/notes.js45
-rw-r--r--web/js/player.js223
-rw-r--r--web/js/pwa.js7
-rw-r--r--web/js/search.js35
-rw-r--r--web/js/selection.js43
-rw-r--r--web/js/shuffle.js24
-rw-r--r--web/js/state.js11
-rw-r--r--web/js/themes.js20
12 files changed, 914 insertions, 0 deletions
diff --git a/web/js/admin.js b/web/js/admin.js
new file mode 100644
index 0000000..c992d1d
--- /dev/null
+++ b/web/js/admin.js
@@ -0,0 +1,101 @@
+import { API } from './api.js';
+
+export function initAdmin() {
+ const btn = document.getElementById('admin-toggle');
+ const modal = document.getElementById('admin-modal');
+ const closeBtn = document.getElementById('admin-close');
+ const rescanBtn = document.getElementById('admin-rescan');
+ const trashBtn = document.getElementById('admin-trash');
+ const form = document.getElementById('admin-user-form');
+
+ btn?.addEventListener('click', () => {
+ modal?.classList.add('open');
+ refreshAdmin();
+ });
+ closeBtn?.addEventListener('click', () => modal?.classList.remove('open'));
+ modal?.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); });
+
+ rescanBtn?.addEventListener('click', async () => {
+ rescanBtn.disabled = true;
+ try { await API.rescan(); toast('Rescan triggered'); }
+ catch (err) { toast(err.message, 'error'); }
+ finally { rescanBtn.disabled = false; }
+ });
+
+ trashBtn?.addEventListener('click', async () => {
+ try {
+ const data = await API.trash();
+ renderTrash(data);
+ } catch (err) { toast(err.message, 'error'); }
+ });
+
+ form?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const fd = new FormData(form);
+ const body = {
+ username: fd.get('username'),
+ password: fd.get('password'),
+ is_admin: !!fd.get('is_admin'),
+ };
+ try {
+ await API.createUser(body);
+ form.reset();
+ toast('User created');
+ await refreshAdmin();
+ } catch (err) { toast(err.message, 'error'); }
+ });
+}
+
+async function refreshAdmin() {
+ try {
+ const [users, perms] = await Promise.all([API.users(), API.permissions()]);
+ renderUsers(users);
+ renderPermissions(perms);
+ } catch (err) { toast(err.message, 'error'); }
+}
+
+function esc(s) {
+ return (s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
+}
+
+function renderUsers(users) {
+ const el = document.getElementById('admin-users');
+ if (!el) return;
+ el.innerHTML = `<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:0.35rem;">
+ ${users.map((u) => `<li style="display:flex;align-items:center;gap:0.5rem;justify-content:space-between;padding:0.25rem 0.35rem;border-radius:var(--radius-sm);${u.is_admin ? 'background:var(--accent-soft);' : ''}">
+ <span>${esc(u.username)}${u.is_admin ? ' <span class="badge">admin</span>' : ''}</span>
+ <button class="btn btn-danger btn-sm" data-id="${u.id}">Remove</button>
+ </li>`).join('')}
+ </ul>`;
+ el.querySelectorAll('button[data-id]').forEach((b) => {
+ b.addEventListener('click', async () => {
+ try { await API.deleteUser(b.dataset.id); toast('User removed'); await refreshAdmin(); }
+ catch (err) { toast(err.message, 'error'); }
+ });
+ });
+}
+
+function renderPermissions(data) {
+ const el = document.getElementById('admin-permissions');
+ if (!el || !data) return;
+ el.innerHTML = `<p class="text-muted" style="font-size:0.8rem;">Use the API to manage permissions directly.</p>`;
+}
+
+function renderTrash(data) {
+ const el = document.getElementById('admin-users');
+ if (!el || !Array.isArray(data)) return;
+ el.innerHTML = `<h4 style="font-size:0.9rem;">Trash</h4><ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:0.35rem;">
+ ${data.map((m) => `<li style="display:flex;align-items:center;justify-content:space-between;padding:0.25rem 0.35rem;border-radius:var(--radius-sm);">
+ <span>${esc(m.file_name)}</span>
+ <span class="text-muted" style="font-size:0.75rem;">${esc(m.deleted_at || '')}</span>
+ </li>`).join('')}
+ </ul>`;
+}
+
+function toast(msg, type = 'info') {
+ const t = document.getElementById('toast');
+ if (!t) return;
+ t.textContent = msg;
+ t.className = 'toast show ' + type;
+ setTimeout(() => t.classList.remove('show'), 2800);
+}
diff --git a/web/js/api.js b/web/js/api.js
new file mode 100644
index 0000000..6c2f0df
--- /dev/null
+++ b/web/js/api.js
@@ -0,0 +1,66 @@
+const API_BASE = '';
+
+async function api(path, options = {}) {
+ const url = `${API_BASE}${path}`;
+ const isForm = options.body instanceof FormData;
+ const headers = { Accept: 'application/json', ...(options.headers || {}) };
+ if (!isForm && options.body && !headers['Content-Type']) {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ const res = await fetch(url, {
+ credentials: 'include',
+ headers,
+ method: options.method || 'GET',
+ body: isForm ? options.body : (options.body ? JSON.stringify(options.body) : undefined),
+ });
+
+ if (res.status === 204) return null;
+ if (res.status === 401) {
+ const p = location.pathname;
+ if (!p.includes('login') && !p.includes('bootstrap')) {
+ location.href = '/login.html';
+ }
+ throw new Error('Unauthorized');
+ }
+ if (!res.ok) {
+ let msg = res.statusText;
+ try {
+ const j = await res.json();
+ if (j.error) msg = j.error;
+ } catch {}
+ throw new Error(msg || `HTTP ${res.status}`);
+ }
+ const ct = res.headers.get('content-type') || '';
+ return ct.includes('application/json') ? res.json() : res.text();
+}
+
+export const API = {
+ login: (username, password) => api('/api/login', { method: 'POST', body: { username, password } }),
+ logout: () => api('/api/logout', { method: 'POST' }),
+ bootstrap: (username, password) => api('/api/bootstrap', { method: 'POST', body: { username, password } }),
+ sets: () => api('/api/sets'),
+ media: (qs) => {
+ const params = new URLSearchParams();
+ Object.entries(qs).forEach(([k, v]) => {
+ if (v !== '' && v !== null && v !== undefined && v !== false) params.set(k, v);
+ });
+ const s = params.toString();
+ return api(`/api/media${s ? '?' + s : ''}`);
+ },
+ mediaDetail: (id) => api(`/api/media/${id}`),
+ progress: (mediaId, positionSeconds) => api('/api/progress', { method: 'POST', body: { media_id: mediaId, position_seconds: positionSeconds } }),
+ favorite: (id) => api(`/api/media/${id}/favorite`, { method: 'POST' }),
+ notes: (id) => api(`/api/media/${id}/notes`),
+ saveNote: (id, content) => api(`/api/media/${id}/notes`, { method: 'POST', body: { content } }),
+ deleteNote: (id) => api(`/api/media/${id}/notes`, { method: 'DELETE' }),
+ share: (id) => api(`/api/media/${id}/shares`, { method: 'POST' }),
+ trash: () => api('/api/admin/trash'),
+ users: () => api('/api/admin/users'),
+ createUser: (body) => api('/api/admin/users', { method: 'POST', body }),
+ deleteUser: (id) => api(`/api/admin/users/${id}`, { method: 'DELETE' }),
+ permissions: () => api('/api/admin/permissions'),
+ setPermissions: (body) => api('/api/admin/permissions', { method: 'POST', body }),
+ delPermissions: (body) => api('/api/admin/permissions', { method: 'DELETE', body }),
+ rescan: () => api('/api/admin/rescan', { method: 'POST' }),
+};
diff --git a/web/js/app.js b/web/js/app.js
new file mode 100644
index 0000000..8e8393e
--- /dev/null
+++ b/web/js/app.js
@@ -0,0 +1,311 @@
+import { API } from './api.js';
+import { initKeyboard } from './keyboard.js';
+import { initSelection, select, selectByElement, next, prev, currentIndex, currentElement } from './selection.js';
+import { initPlayer, togglePlay, toggleFullscreen, exitFullscreenIfNeeded, currentMediaId, selectAndPlay, playPrevious, playNext } from './player.js';
+import { initSearch, focusSearch, trigger as triggerSearch } from './search.js';
+import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js';
+import { initThemes } from './themes.js';
+import { initNotes, open as openNotes } from './notes.js';
+import { initAdmin } from './admin.js';
+import { state, setMedia } from './state.js';
+import { initPWA } from './pwa.js';
+
+const qs = document.querySelector.bind(document);
+const pageMap = { '/index.html': 'spa', '/login.html': 'login', '/bootstrap.html': 'bootstrap' };
+
+main();
+
+function main() {
+ const page = pageMap[location.pathname] || 'spa';
+ if (page === 'login') initLogin();
+ else if (page === 'bootstrap') initBootstrap();
+ else initApp();
+ initThemes();
+}
+
+// Login page
+function initLogin() {
+ const form = document.getElementById('login-form');
+ const err = document.getElementById('login-error');
+ if (!form) return;
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ err.textContent = '';
+ const fd = new FormData(form);
+ const username = fd.get('username');
+ const password = fd.get('password');
+ if (!username || !password) { err.textContent = 'Please fill in all fields.'; return; }
+ try {
+ await API.login(username, password);
+ location.href = '/';
+ } catch (ex) {
+ err.textContent = ex.message || 'Login failed';
+ }
+ });
+}
+
+// Bootstrap page
+function initBootstrap() {
+ const form = document.getElementById('bootstrap-form');
+ const err = document.getElementById('bootstrap-error');
+ if (!form) return;
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ err.textContent = '';
+ const fd = new FormData(form);
+ const username = fd.get('username');
+ const password = fd.get('password');
+ const confirm = fd.get('password-confirm');
+ if (!username || !password || !confirm) { err.textContent = 'All fields are required.'; return; }
+ if (password !== confirm) { err.textContent = 'Passwords do not match.'; return; }
+ try {
+ await API.bootstrap(username, password);
+ location.href = '/login.html';
+ } catch (ex) {
+ err.textContent = ex.message || 'Bootstrap failed';
+ }
+ });
+}
+
+// SPA
+async function initApp() {
+ initPlayer();
+ initSelection();
+ initSearch({
+ onChange: (q) => {
+ state.filters.search = q;
+ loadMedia();
+ },
+ input: document.getElementById('search-input'),
+ clearBtn: document.getElementById('search-clear'),
+ });
+ initShuffle({
+ onChange: () => loadMedia(),
+ });
+ initKeyboard({
+ navUp: () => prev(),
+ navDown: () => next(),
+ navLeft: () => setSetByDelta(-1),
+ navRight: () => setSetByDelta(1),
+ enter: () => {
+ const el = currentElement();
+ if (el) { selectByElement(el); playSelected(); }
+ },
+ playPause: () => togglePlay(),
+ fullscreen: () => toggleFullscreen(),
+ escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); },
+ shuffle: () => { toggleShuffle(); loadMedia(); },
+ share: () => shareSelected(),
+ search: focusSearch,
+ notes: openNotesForSelected,
+ });
+ initNotes(() => toast('Note saved'));
+ initAdmin();
+ initPWA();
+
+ // Filters
+ document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; loadMedia(); });
+ document.getElementById('filter-favorites')?.addEventListener('click', (e) => {
+ state.filters.favorites = !state.filters.favorites;
+ e.target.classList.toggle('active', state.filters.favorites);
+ loadMedia();
+ });
+
+ // Menu toggle
+ document.getElementById('menu-btn')?.addEventListener('click', () => {
+ document.getElementById('sidebar')?.classList.toggle('open');
+ });
+ document.getElementById('logout-btn')?.addEventListener('click', async () => {
+ try { await API.logout(); location.href = '/login.html'; } catch {}
+ });
+
+ const user = await API.loginProfile ? API.loginProfile : undefined; // not a route; we just try to bootstrap auth via /api/sets
+ try {
+ const sets = await API.sets();
+ state.sets = sets || [];
+ state.isAdmin = !!state.sets?.__isAdmin; // backend may not send this; we'll infer from admin endpoint later
+ // Admin check via admin users endpoint
+ API.users().then(() => { state.isAdmin = true; showAdmin(); }).catch(() => {});
+ renderSets();
+ if (state.sets.length) { state.selectedSetId = state.sets[0].id; setActiveSet(state.sets[0].id); loadMedia(); }
+ } catch (err) {
+ toast(err.message || 'Error loading sets', 'error');
+ }
+
+ // Play on double-click
+ document.getElementById('media-grid')?.addEventListener('dblclick', (e) => {
+ const el = e.target.closest('.media-card, .media-row');
+ if (el) { selectByElement(el); playSelected(); }
+ });
+}
+
+function renderSets() {
+ const el = document.getElementById('set-list');
+ if (!el) return;
+ el.innerHTML = state.sets.map((s) =>
+ `<a href="#" class="set-item" data-id="${s.id}">${escapeHtml(s.name)}</a>`
+ ).join('');
+ el.querySelectorAll('.set-item').forEach((a) => {
+ a.addEventListener('click', (e) => {
+ e.preventDefault();
+ state.selectedSetId = parseInt(a.dataset.id, 10);
+ setActiveSet(state.selectedSetId);
+ loadMedia();
+ });
+ });
+}
+
+function setActiveSet(id) {
+ document.querySelectorAll('.set-item').forEach((a) => a.classList.toggle('active', parseInt(a.dataset.id, 10) === id));
+}
+
+function setSetByDelta(dx) {
+ if (!state.sets.length) return;
+ let idx = state.sets.findIndex((s) => s.id === state.selectedSetId);
+ if (idx < 0) idx = 0;
+ const nextIdx = (idx + dx + state.sets.length) % state.sets.length;
+ state.selectedSetId = state.sets[nextIdx].id;
+ setActiveSet(state.selectedSetId);
+ loadMedia();
+}
+
+async function loadMedia() {
+ const grid = document.getElementById('media-grid');
+ if (!grid) return;
+ grid.innerHTML = '<p class="text-muted" style="grid-column:1/-1;font-size:0.85rem;">Loading...</p>';
+ try {
+ const params = {
+ set_id: state.selectedSetId ?? '',
+ type: state.filters.type,
+ search: state.filters.search,
+ favorites: state.filters.favorites ? 'true' : '',
+ sort: isShuffle() ? 'random' : 'name',
+ limit: '200',
+ };
+ const data = await API.media(params);
+ const list = Array.isArray(data) ? data : data?.media || [];
+ setMedia(list);
+ renderGrid(list);
+ document.getElementById('result-count').textContent = `${list.length} items`;
+ } catch (err) {
+ grid.innerHTML = `<p class="error-message" style="grid-column:1/-1;">${escapeHtml(err.message)}</p>`;
+ }
+}
+
+function renderGrid(items) {
+ const grid = document.getElementById('media-grid');
+ if (!grid) return;
+ if (!items.length) { grid.innerHTML = `<p class="text-muted" style="grid-column:1/-1;font-size:0.85rem;">No results.</p>`; return; }
+ grid.innerHTML = items.map((m, i) => renderItem(m, i)).join('');
+ grid.querySelectorAll('.media-card, .media-row').forEach((el) => {
+ el.addEventListener('click', () => { selectByElement(el); });
+ const playBtn = el.querySelector('[data-action="play"]');
+ const favBtn = el.querySelector('[data-action="favorite"]');
+ const noteBtn = el.querySelector('[data-action="notes"]');
+ playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); });
+ favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); });
+ noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); });
+ });
+}
+
+function renderItem(m, index) {
+ if (m.type === 'video') {
+ return `
+ <div class="media-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
+ <div class="thumb-wrap">
+ ${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`}
+ <span class="badge">${fmtDur(m.duration)}</span>
+ </div>
+ <div class="meta">
+ <div class="title">${escapeHtml(m.file_name)}</div>
+ <div class="subtitle">${escapeHtml(m.codec || '')} ${m.resolution || ''} ${m.bitrate ? Math.round(m.bitrate / 1000) + 'kbps' : ''}</div>
+ </div>
+ <div style="display:flex;gap:0.25rem;padding:0 0.5rem 0.5rem;">
+ <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button>
+ <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button>
+ <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button>
+ </div>
+ </div>
+ `;
+ }
+ return `
+ <div class="media-row" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
+ <span class="row-icon">🎵</span>
+ <div class="row-body">
+ <div class="row-title">${escapeHtml(m.file_name)}</div>
+ <div class="row-meta">${escapeHtml(m.codec || '')} ${m.bitrate ? Math.round(m.bitrate/1000)+'kbps' : ''}</div>
+ </div>
+ <span class="row-duration">${fmtDur(m.duration)}</span>
+ <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button>
+ <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button>
+ <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button>
+ </div>
+ `;
+}
+
+function playSelected() {
+ const el = currentElement();
+ if (!el) return;
+ const idx = parseInt(el.dataset.index, 10);
+ const media = state.media[idx];
+ if (media) selectAndPlay(media, idx);
+}
+
+async function shareSelected() {
+ const el = currentElement();
+ if (!el) return;
+ const id = el.dataset.id;
+ try {
+ const res = await API.share(id);
+ const token = res?.token || res?.share?.token;
+ const url = `${location.origin}/s/${token}`;
+ navigator.clipboard?.writeText(url);
+ toast('Share link copied');
+ } catch (err) { toast(err.message || 'Share failed', 'error'); }
+}
+
+async function toggleFavorite(id, btn) {
+ try {
+ await API.favorite(id);
+ btn?.classList.toggle('active');
+ } catch (err) { toast(err.message || 'Favorite failed', 'error'); }
+}
+
+async function openNotesForSelected() {
+ const el = currentElement();
+ if (!el) return;
+ const id = el.dataset.id;
+ let content = '';
+ try {
+ const note = await API.notes(id);
+ content = note?.content || '';
+ } catch {}
+ openNotes(id, content);
+}
+
+function showAdmin() {
+ const btn = document.getElementById('admin-toggle');
+ if (btn) btn.classList.remove('hidden');
+}
+
+function fmtDur(s) {
+ if (!s || s < 0) return '0:00';
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = Math.floor(s % 60);
+ const mm = String(m).padStart(2, '0');
+ const ss = String(sec).padStart(2, '0');
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
+}
+
+function escapeHtml(s) {
+ return (s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
+
+function toast(msg, type = 'info') {
+ const t = document.getElementById('toast');
+ if (!t) return;
+ t.textContent = msg;
+ t.className = 'toast show ' + type;
+ setTimeout(() => t.classList.remove('show'), 2800);
+}
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
new file mode 100644
index 0000000..58ba0f3
--- /dev/null
+++ b/web/js/keyboard.js
@@ -0,0 +1,28 @@
+export function initKeyboard(handlers) {
+ document.addEventListener('keydown', (e) => {
+ const tag = e.target.tagName;
+ const editing = tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable;
+ if (editing) {
+ if (e.key === 'Escape') {
+ e.target.blur();
+ handlers.escape?.(e);
+ }
+ return;
+ }
+ switch (e.key) {
+ case 'ArrowUp': e.preventDefault(); handlers.navUp?.(e); break;
+ case 'ArrowDown': e.preventDefault(); handlers.navDown?.(e); break;
+ case 'ArrowLeft': handlers.navLeft?.(e); break;
+ case 'ArrowRight': handlers.navRight?.(e); break;
+ case 'Enter': handlers.enter?.(e); break;
+ case ' ': e.preventDefault(); handlers.playPause?.(e); break;
+ case 'p': handlers.playPause?.(e); break;
+ case 'f': handlers.fullscreen?.(e); break;
+ case 'Escape': handlers.escape?.(e); break;
+ case 'r': handlers.shuffle?.(e); break;
+ case 's': handlers.share?.(e); break;
+ case '/': e.preventDefault(); handlers.search?.(e); break;
+ case 'n': handlers.notes?.(e); break;
+ }
+ });
+}
diff --git a/web/js/notes.js b/web/js/notes.js
new file mode 100644
index 0000000..c3ab6ac
--- /dev/null
+++ b/web/js/notes.js
@@ -0,0 +1,45 @@
+import { API } from './api.js';
+
+let currentMediaId = null;
+
+export function initNotes(onSave) {
+ const modal = document.getElementById('notes-modal');
+ const closeBtn = document.getElementById('notes-close');
+ const saveBtn = document.getElementById('notes-save');
+ const delBtn = document.getElementById('notes-delete');
+ const area = document.getElementById('notes-textarea');
+
+ const close = () => { modal.classList.remove('open'); currentMediaId = null; };
+ closeBtn?.addEventListener('click', close);
+ modal?.addEventListener('click', (e) => { if (e.target === modal) close(); });
+ saveBtn?.addEventListener('click', async () => {
+ if (!currentMediaId) return;
+ try {
+ await API.saveNote(currentMediaId, area.value);
+ onSave?.('saved');
+ close();
+ } catch (err) {
+ onSave?.('error', err.message);
+ }
+ });
+ delBtn?.addEventListener('click', async () => {
+ if (!currentMediaId) return;
+ try {
+ await API.deleteNote(currentMediaId);
+ area.value = '';
+ onSave?.('deleted');
+ close();
+ } catch (err) {
+ onSave?.('error', err.message);
+ }
+ });
+}
+
+export function open(mediaId, existingContent) {
+ currentMediaId = mediaId;
+ const modal = document.getElementById('notes-modal');
+ const area = document.getElementById('notes-textarea');
+ if (area) area.value = existingContent || '';
+ modal?.classList.add('open');
+ document.getElementById('notes-textarea')?.focus();
+}
diff --git a/web/js/player.js b/web/js/player.js
new file mode 100644
index 0000000..f87dd8d
--- /dev/null
+++ b/web/js/player.js
@@ -0,0 +1,223 @@
+import { API } from './api.js';
+import { state, setMedia } from './state.js';
+
+let currentMedia = null;
+let isPlaying = false;
+let progressTimer = null;
+let progressInterval = null;
+let currentMediaIndex = -1;
+
+const els = () => ({
+ video: document.getElementById('media-video'),
+ audio: document.getElementById('media-audio'),
+ player: document.getElementById('player'),
+ btnPlay: document.getElementById('btn-play'),
+ btnPrev: document.getElementById('btn-prev'),
+ btnNext: document.getElementById('btn-next'),
+ btnMute: document.getElementById('btn-mute'),
+ btnFs: document.getElementById('btn-fullscreen'),
+ bigPlay: document.getElementById('big-play'),
+ track: document.getElementById('progress-track'),
+ fill: document.getElementById('progress-fill'),
+ thumb: document.getElementById('progress-thumb'),
+ volume: document.getElementById('volume-slider'),
+ timeElapsed: document.getElementById('time-elapsed'),
+ timeTotal: document.getElementById('time-total'),
+});
+
+export function initPlayer() {
+ const e = els();
+ if (!e.video || !e.audio) return;
+
+ e.btnPlay?.addEventListener('click', togglePlay);
+ e.btnPrev?.addEventListener('click', playPrevious);
+ e.btnNext?.addEventListener('click', playNext);
+ e.btnMute?.addEventListener('click', toggleMute);
+ e.btnFs?.addEventListener('click', toggleFullscreen);
+ e.bigPlay?.addEventListener('click', togglePlay);
+ e.bigPlay?.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); togglePlay(); } });
+
+ e.volume?.addEventListener('input', () => {
+ const v = parseFloat(e.volume.value);
+ e.video.volume = v;
+ e.audio.volume = v;
+ e.video.muted = v === 0;
+ e.audio.muted = v === 0;
+ updateMuteIcon();
+ });
+
+ [e.video, e.audio].forEach((m) => {
+ m.addEventListener('timeupdate', () => {
+ if (!m.duration || isNaN(m.duration)) return;
+ const pct = (m.currentTime / m.duration) * 100;
+ e.fill.style.width = pct.toFixed(2) + '%';
+ e.thumb.style.left = pct.toFixed(2) + '%';
+ e.timeElapsed.textContent = fmt(m.currentTime);
+ });
+ m.addEventListener('loadedmetadata', () => {
+ e.timeTotal.textContent = fmt(isFinite(m.duration) ? m.duration : 0);
+ });
+ m.addEventListener('ended', () => {
+ updateUI(false);
+ isPlaying = false;
+ stopProgressTimer();
+ playNext();
+ });
+ m.addEventListener('play', () => { updateUI(true); isPlaying = true; startProgressTimer(); });
+ m.addEventListener('pause', () => { updateUI(false); isPlaying = false; stopProgressTimer(); });
+ });
+
+ let seeking = false;
+ e.track?.addEventListener('click', (ev) => {
+ const m = currentMediaElement();
+ if (!m || !m.duration) return;
+ const r = e.track.getBoundingClientRect();
+ const frac = (ev.clientX - r.left) / r.width;
+ m.currentTime = Math.max(0, Math.min(1, frac)) * m.duration;
+ });
+ e.track?.addEventListener('keydown', (ev) => {
+ const m = currentMediaElement();
+ if (!m || !m.duration) return;
+ if (ev.key === 'ArrowLeft') { ev.preventDefault(); m.currentTime = Math.max(0, m.currentTime - 5); }
+ if (ev.key === 'ArrowRight') { ev.preventDefault(); m.currentTime = Math.min(m.duration, m.currentTime + 5); }
+ });
+}
+
+export function togglePlay() {
+ const m = currentMediaElement();
+ if (!m) return;
+ if (m.paused) { m.play().catch(() => {}); } else { m.pause(); }
+}
+
+export function selectAndPlay(media, index) {
+ currentMedia = media;
+ currentMediaIndex = index ?? -1;
+ loadMedia(media);
+ isPlaying = true;
+ currentMediaElement()?.play().catch(() => {});
+ highlightPlayingCard();
+}
+
+function loadMedia(media) {
+ const e = els();
+ const isVideo = media.type === 'video';
+ const src = `/api/media/${media.id}/stream`;
+ if (isVideo) {
+ e.video.style.display = '';
+ e.audio.style.display = 'none';
+ e.audio.pause(); e.audio.src = '';
+ e.video.src = src;
+ e.video.currentTime = media.resume_from ?? 0;
+ } else {
+ e.video.style.display = 'none';
+ e.audio.style.display = '';
+ e.video.pause(); e.video.src = '';
+ e.audio.src = src;
+ e.audio.currentTime = media.resume_from ?? 0;
+ }
+ e.player?.classList.add('open');
+ e.btnPlay.textContent = '⏸';
+ e.bigPlay?.classList.add('hidden');
+ e.timeTotal.textContent = fmt(media.duration ?? 0);
+ // Reset progress visual
+ e.fill.style.width = '0%';
+ e.thumb.style.left = '0%';
+}
+
+function currentMediaElement() {
+ const e = els();
+ if (currentMedia?.type === 'audio') return e.audio;
+ return e.video;
+}
+
+function updateUI(playing) {
+ const e = els();
+ isPlaying = playing;
+ e.btnPlay.textContent = playing ? '⏸' : '▶';
+ if (playing) e.bigPlay?.classList.add('hidden');
+ else e.bigPlay?.classList.remove('hidden');
+}
+
+function toggleMute() {
+ const m = currentMediaElement();
+ if (!m) return;
+ m.muted = !m.muted;
+ updateMuteIcon();
+}
+
+function updateMuteIcon() {
+ const m = currentMediaElement();
+ const e = els();
+ if (!m || !e.btnMute) return;
+ e.btnMute.textContent = m.muted || m.volume === 0 ? '🔇' : '🔊';
+}
+
+export function toggleFullscreen() {
+ const p = els().player;
+ if (!p) return;
+ if (document.fullscreenElement) {
+ document.exitFullscreen().catch(() => {});
+ p.classList.remove('is-fullscreen');
+ } else {
+ p.requestFullscreen().catch(() => {});
+ p.classList.add('is-fullscreen');
+ }
+}
+
+export function exitFullscreenIfNeeded() {
+ if (document.fullscreenElement) {
+ document.exitFullscreen().catch(() => {});
+ els().player?.classList.remove('is-fullscreen');
+ }
+}
+
+function startProgressTimer() {
+ stopProgressTimer();
+ // Report every 3s while playing
+ progressInterval = setInterval(() => {
+ const m = currentMediaElement();
+ if (m && currentMedia && !m.paused) {
+ API.progress(currentMedia.id, m.currentTime).catch(() => {});
+ }
+ }, 3000);
+}
+
+function stopProgressTimer() {
+ clearInterval(progressInterval);
+ progressInterval = null;
+}
+
+function highlightPlayingCard() {
+ document.querySelectorAll('.media-card, .media-row').forEach((c) => c.classList.remove('playing'));
+ const idx = currentMediaIndex;
+ if (idx >= 0) {
+ const cards = Array.from(document.querySelectorAll('.media-card, .media-row'));
+ if (cards[idx]) cards[idx].classList.add('playing');
+ }
+}
+
+export function playPrevious() {
+ const list = state.media;
+ if (!list.length) return;
+ const idx = currentMediaIndex > 0 ? currentMediaIndex - 1 : list.length - 1;
+ selectAndPlay(list[idx], idx);
+}
+
+export function playNext() {
+ const list = state.media;
+ if (!list.length) return;
+ const idx = currentMediaIndex >= 0 && currentMediaIndex + 1 < list.length ? currentMediaIndex + 1 : 0;
+ selectAndPlay(list[idx], idx);
+}
+
+export function currentMediaId() { return currentMedia?.id; }
+
+function fmt(s) {
+ if (!isFinite(s) || s < 0) return '0:00';
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = Math.floor(s % 60);
+ const mm = String(m).padStart(2, '0');
+ const ss = String(sec).padStart(2, '0');
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
+}
diff --git a/web/js/pwa.js b/web/js/pwa.js
new file mode 100644
index 0000000..5e88ce7
--- /dev/null
+++ b/web/js/pwa.js
@@ -0,0 +1,7 @@
+export function initPWA() {
+ if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
+ });
+ }
+}
diff --git a/web/js/search.js b/web/js/search.js
new file mode 100644
index 0000000..dfb698a
--- /dev/null
+++ b/web/js/search.js
@@ -0,0 +1,35 @@
+let _onChange = () => {};
+let _debounceTimer = null;
+
+export function initSearch({ onChange, input, clearBtn }) {
+ _onChange = onChange;
+ if (!input) return;
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ input.value = '';
+ input.blur();
+ trigger('');
+ }
+ });
+ input.addEventListener('input', () => {
+ clearTimeout(_debounceTimer);
+ _debounceTimer = setTimeout(() => trigger(input.value.trim()), 300);
+ });
+ if (clearBtn) {
+ clearBtn.addEventListener('click', () => {
+ input.value = '';
+ input.focus();
+ trigger('');
+ });
+ }
+}
+
+export function focusSearch() {
+ const input = document.getElementById('search-input');
+ if (input) input.focus();
+}
+
+export function trigger(query) {
+ clearTimeout(_debounceTimer);
+ _onChange(query);
+}
diff --git a/web/js/selection.js b/web/js/selection.js
new file mode 100644
index 0000000..00b91b8
--- /dev/null
+++ b/web/js/selection.js
@@ -0,0 +1,43 @@
+let selectedIndex = -1;
+
+export function initSelection() {
+ const grid = document.getElementById('media-grid');
+ if (!grid) return;
+ grid.addEventListener('click', (e) => {
+ const card = e.target.closest('.media-card, .media-row');
+ if (!card) return;
+ selectByElement(card);
+ });
+}
+
+function refreshCards() {
+ return Array.from(document.querySelectorAll('.media-card, .media-row'));
+}
+
+export function select(index) {
+ const cards = refreshCards();
+ if (!cards.length) { selectedIndex = -1; return; }
+ selectedIndex = Math.max(0, Math.min(index, cards.length - 1));
+ cards.forEach((c) => c.classList.remove('selected'));
+ const el = cards[selectedIndex];
+ if (el) {
+ el.classList.add('selected');
+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+}
+
+export function selectByElement(el) {
+ const cards = refreshCards();
+ const idx = cards.indexOf(el);
+ if (idx >= 0) select(idx);
+}
+
+export function next(step = 1) {
+ const cards = refreshCards();
+ if (!cards.length) return;
+ select((selectedIndex + step + cards.length) % cards.length);
+}
+
+export function prev(step = 1) { next(-step); }
+export function currentIndex() { return selectedIndex; }
+export function currentElement() { return refreshCards()[selectedIndex] ?? null; }
diff --git a/web/js/shuffle.js b/web/js/shuffle.js
new file mode 100644
index 0000000..bef4dad
--- /dev/null
+++ b/web/js/shuffle.js
@@ -0,0 +1,24 @@
+let shuffleOn = false;
+
+export function initShuffle({ onChange }) {
+ const btn = document.getElementById('shuffle-toggle');
+ if (!btn) return;
+ btn.addEventListener('click', () => {
+ shuffleOn = !shuffleOn;
+ updateUI();
+ onChange?.(shuffleOn);
+ });
+}
+
+export function toggle() {
+ shuffleOn = !shuffleOn;
+ updateUI();
+ return shuffleOn;
+}
+
+export function isOn() { return shuffleOn; }
+
+function updateUI() {
+ const btn = document.getElementById('shuffle-toggle');
+ if (btn) btn.classList.toggle('active', shuffleOn);
+}
diff --git a/web/js/state.js b/web/js/state.js
new fi