diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-29 20:19:34 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-29 20:19:34 +0300 |
| commit | 4a70c0ea083306cb79d0fa325d331ffa4333154a (patch) | |
| tree | 84df7e2f781974ef45ac8c34f1a59e80fdd5c41d /web/js | |
| parent | cf30414c2a0696cc75c615f4da66ea85d0522c42 (diff) | |
feat: create web PWA frontend
Diffstat (limited to 'web/js')
| -rw-r--r-- | web/js/admin.js | 101 | ||||
| -rw-r--r-- | web/js/api.js | 66 | ||||
| -rw-r--r-- | web/js/app.js | 311 | ||||
| -rw-r--r-- | web/js/keyboard.js | 28 | ||||
| -rw-r--r-- | web/js/notes.js | 45 | ||||
| -rw-r--r-- | web/js/player.js | 223 | ||||
| -rw-r--r-- | web/js/pwa.js | 7 | ||||
| -rw-r--r-- | web/js/search.js | 35 | ||||
| -rw-r--r-- | web/js/selection.js | 43 | ||||
| -rw-r--r-- | web/js/shuffle.js | 24 | ||||
| -rw-r--r-- | web/js/state.js | 11 | ||||
| -rw-r--r-- | web/js/themes.js | 20 |
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) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 |
