From 4a70c0ea083306cb79d0fa325d331ffa4333154a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 29 Apr 2026 20:19:34 +0300 Subject: feat: create web PWA frontend --- ... | 129 ++++++++++++++++++++ web/bootstrap.html | 25 +++- web/css/components.css | 134 +++++++++++++++++++++ web/css/layout.css | 276 +++++++++++++++++++++++++++++++++++++++++++ web/css/login.css | 27 +++++ web/css/player.css | 132 +++++++++++++++++++++ web/css/theme.css | 105 +++++++++++++++++ web/favicon.svg | 1 + web/index.html | 121 ++++++++++++++++++- web/js/admin.js | 101 ++++++++++++++++ web/js/api.js | 66 +++++++++++ web/js/app.js | 311 +++++++++++++++++++++++++++++++++++++++++++++++++ web/js/keyboard.js | 28 +++++ web/js/notes.js | 45 +++++++ web/js/player.js | 223 +++++++++++++++++++++++++++++++++++ web/js/pwa.js | 7 ++ web/js/search.js | 35 ++++++ web/js/selection.js | 43 +++++++ web/js/shuffle.js | 24 ++++ web/js/state.js | 11 ++ web/js/themes.js | 20 ++++ web/login.html | 21 +++- web/manifest.json | 11 ++ web/sw.js | 41 +++++++ 24 files changed, 1932 insertions(+), 5 deletions(-) create mode 100644 ... create mode 100644 web/css/components.css create mode 100644 web/css/layout.css create mode 100644 web/css/login.css create mode 100644 web/css/player.css create mode 100644 web/css/theme.css create mode 100644 web/favicon.svg create mode 100644 web/js/admin.js create mode 100644 web/js/api.js create mode 100644 web/js/app.js create mode 100644 web/js/keyboard.js create mode 100644 web/js/notes.js create mode 100644 web/js/player.js create mode 100644 web/js/pwa.js create mode 100644 web/js/search.js create mode 100644 web/js/selection.js create mode 100644 web/js/shuffle.js create mode 100644 web/js/state.js create mode 100644 web/js/themes.js create mode 100644 web/manifest.json create mode 100644 web/sw.js diff --git a/... b/... new file mode 100644 index 0000000..f16c0de --- /dev/null +++ b/... @@ -0,0 +1,129 @@ + + + + + +KISS Media Player + + + + + + + + + + + + +
+
+
+ + +
+ +
+
+
+
+ + + + + + + + + + + + diff --git a/web/bootstrap.html b/web/bootstrap.html index 378b595..440a55f 100644 --- a/web/bootstrap.html +++ b/web/bootstrap.html @@ -2,9 +2,30 @@ -Bootstrap - KISS Media Player + +Bootstrap — KISS Media Player + + + -

First Admin Setup

+
+

Create Admin Account

+

One-time setup. This form is available only while no users exist.

+ +
+ diff --git a/web/css/components.css b/web/css/components.css new file mode 100644 index 0000000..6d2f074 --- /dev/null +++ b/web/css/components.css @@ -0,0 +1,134 @@ +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.85rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 0.85rem; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + background: var(--bg-elevated); + color: var(--text-primary); + transition: background var(--transition-fast), border-color var(--transition-fast), transform 80ms ease; +} +.btn:hover { background: var(--bg-surface-hover); } +.btn:active { transform: translateY(1px); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent-active); +} +.btn-primary:hover { background: var(--accent-hover); } +.btn-danger { + background: var(--danger); + color: #fff; + border-color: #b91c1c; +} +.btn-danger:hover { background: #e04e4e; } +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border-color: var(--border); +} +.btn-ghost:hover { color: var(--text-primary); background: var(--bg-surface-hover); } +.btn-sm { padding: 0.3rem 0.55rem; font-size: 0.78rem; } +.btn-lg { padding: 0.65rem 1.1rem; font-size: 0.95rem; } + +/* Icon buttons */ +.icon-btn { + width: 2.25rem; + height: 2.25rem; + border-radius: var(--radius-sm); + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.05rem; + line-height: 1; + padding: 0; + transition: background var(--transition-fast), color var(--transition-fast); +} +.icon-btn:hover { background: var(--bg-surface-hover); color: var(--text-primary); } +.icon-btn.active { color: var(--accent); background: var(--accent-soft); } + +/* Forms */ +input, textarea, select { + font: inherit; + color: var(--text-primary); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.65rem; + width: 100%; +} +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +label { + display: block; + font-size: 0.8rem; + font-weight: 600; + margin: 0.35rem 0 0.2rem; + color: var(--text-secondary); +} + +/* Error message */ +.error-message { + color: var(--danger); + font-size: 0.85rem; + min-height: 1.25rem; + margin: 0.35rem 0 0; +} + +/* Search bar */ +.search-bar { + display: flex; + align-items: center; + gap: 0.4rem; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.25rem 0.6rem; + max-width: 22rem; + width: 100%; +} +.search-bar input { + background: transparent; + border: none; + padding: 0.35rem 0.2rem; + flex: 1 1 auto; +} +.search-bar input:focus { box-shadow: none; } + +/* Toast */ +.toast { + position: fixed; + bottom: 1rem; + right: 1rem; + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: 0.65rem 0.9rem; + z-index: 110; + font-size: 0.85rem; + transform: translateY(120%); + opacity: 0; + transition: transform var(--transition-base), opacity var(--transition-base); +} +.toast.show { transform: translateY(0); opacity: 1; } +.toast.info { border-left: 4px solid var(--accent); } +.toast.success { border-left: 4px solid var(--success); } +.toast.error { border-left: 4px solid var(--danger); } diff --git a/web/css/layout.css b/web/css/layout.css new file mode 100644 index 0000000..9f51008 --- /dev/null +++ b/web/css/layout.css @@ -0,0 +1,276 @@ +/* Reset & base */ +*, *::before, *::after { box-sizing: border-box; } +html, body { + height: 100%; +} +body { + margin: 0; + font-family: var(--font-sans); + background: var(--bg-body); + color: var(--text-primary); + line-height: 1.45; +} + +/* Header */ +.site-header { + position: fixed; + inset: 0 0 auto 0; + height: var(--header-height); + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: var(--gap); + padding: 0 1rem; + z-index: 50; +} +.site-header .logo { + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-primary); + text-decoration: none; +} +.site-header .spacer { flex: 1 1 auto; } + +/* Sidebar */ +.sidebar { + position: fixed; + inset: var(--header-height) auto 0 0; + width: var(--sidebar-width); + background: var(--bg-surface); + border-right: 1px solid var(--border); + overflow: auto; + padding: 0.75rem 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + z-index: 40; +} +.sidebar h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin: 0.5rem 0.5rem 0.25rem; + padding-bottom: 0.25rem; + border-bottom: 1px solid var(--border); +} +.sidebar .set-item { + display: block; + padding: 0.4rem 0.6rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + cursor: pointer; + user-select: none; +} +.sidebar .set-item:hover, +.sidebar .set-item:focus-visible { + background: var(--bg-surface-hover); + color: var(--text-primary); + outline: none; +} +.sidebar .set-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} + +/* Main layout */ +.page { + padding-top: var(--header-height); + padding-left: var(--sidebar-width); + min-height: 100vh; +} +.main-content { + padding: var(--gap); + max-width: 100%; +} + +/* Media grid */ +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--gap); +} +.media-card { + position: relative; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast); +} +.media-card:hover { + box-shadow: var(--shadow-md); + border-color: var(--border-strong); +} +.media-card.selected { + border-color: var(--selected-border); + background: var(--selected-bg); +} +.media-card.playing { + border-color: var(--playing-border); + background: var(--playing-bg); +} +.media-card .thumb-wrap { + position: relative; + aspect-ratio: 16 / 9; + background: var(--bg-elevated); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.media-card .thumb-wrap img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.media-card .thumb-wrap .placeholder { + color: var(--text-muted); + font-size: 0.85rem; +} +.media-card .meta { + padding: 0.55rem 0.65rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-height: 3.5rem; +} +.media-card .title { + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.media-card .subtitle { + font-size: 0.78rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.media-card .badge { + position: absolute; + top: 0.35rem; + right: 0.35rem; + font-size: 0.65rem; + padding: 0.15rem 0.4rem; + border-radius: var(--radius-sm); + background: var(--bg-overlay); + color: var(--text-primary); +} + +/* Audio row variant */ +.media-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} +.media-row:hover { + background: var(--bg-surface-hover); + border-color: var(--border-strong); +} +.media-row.selected { + border-color: var(--selected-border); + background: var(--selected-bg); +} +.media-row.playing { + border-color: var(--playing-border); + background: var(--playing-bg); +} +.media-row .row-icon { + color: var(--text-muted); + font-size: 1.1rem; +} +.media-row .row-body { + min-width: 0; + flex: 1 1 auto; +} +.media-row .row-title { + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.media-row .row-meta { + font-size: 0.78rem; + color: var(--text-secondary); +} +.media-row .row-duration { + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: var(--bg-overlay); + display: grid; + place-items: center; + z-index: 100; + opacity: 0; + visibility: hidden; + transition: opacity var(--transition-base), visibility var(--transition-base); +} +.modal-overlay.open { + opacity: 1; + visibility: visible; +} +.modal { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: min(40rem, 92vw); + max-height: 86vh; + overflow: auto; + padding: 1.25rem; +} +.modal-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.modal-header h3 { margin: 0; font-size: 1.1rem; } +.modal-header .spacer { flex: 1 1 auto; } + +/* Responsive */ +@media (max-width: 860px) { + .sidebar { + position: fixed; + inset: var(--header-height) auto auto 0; + width: 100%; + height: auto; + max-height: 70vh; + border-right: none; + border-bottom: 1px solid var(--border); + display: none; + } + .sidebar.open { display: flex; } + .page { padding-left: 0; } + .media-grid { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); } +} + +@media (max-width: 520px) { + .media-grid { grid-template-columns: repeat(2, 1fr); } +} + +/* Utilities */ +.hidden { display: none !important; } +.overflow-hidden { overflow: hidden; } + diff --git a/web/css/login.css b/web/css/login.css new file mode 100644 index 0000000..a32ebd8 --- /dev/null +++ b/web/css/login.css @@ -0,0 +1,27 @@ +/* Login / bootstrap page layout */ +.login-container { + min-height: 100vh; + display: grid; + place-items: center; + padding: 2rem 1rem; + background: var(--bg-body); +} +.login-container h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; +} +.login-container .subtitle { + margin: 0 0 1rem; + color: var(--text-secondary); + font-size: 0.85rem; +} +.login-form { + width: 100%; + max-width: 22rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: 1.25rem 1.25rem 1rem; +} +.login-form .btn { width: 100%; margin-top: 0.5rem; } diff --git a/web/css/player.css b/web/css/player.css new file mode 100644 index 0000000..e1c13b4 --- /dev/null +++ b/web/css/player.css @@ -0,0 +1,132 @@ +/* Player */ +.player { + position: fixed; + inset: auto 0 0 var(--sidebar-width); + background: var(--player-bg); + color: var(--text-primary); + border-top: 1px solid var(--border); + z-index: 60; + transition: inset var(--transition-base); +} +.player.is-fullscreen { + inset: 0; + background: #000; + display: flex; + flex-direction: column; + justify-content: center; +} + +.player .stage { + position: relative; + background: #000; +} +.player:not(.is-fullscreen) .stage { + height: 0; + overflow: hidden; + transition: height var(--transition-base); +} +.player.open .stage { + height: clamp(10rem, 28vh, 26rem); +} +.player.is-fullscreen .stage { + flex: 1 1 auto; + height: auto; + display: flex; + align-items: center; + justify-content: center; +} + +.player video, .player audio { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} +.player.is-fullscreen video, .player.is-fullscreen audio { + max-height: 100%; +} + +/* Overlay controls */ +.player .controls { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; +} +.player .controls .spacer { flex: 1 1 auto; } + +.player .time { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-secondary); + white-space: nowrap; +} + +/* Progress bar */ +.progress-track { + position: relative; + flex: 1 1 auto; + height: 0.35rem; + background: var(--player-progress-bg); + border-radius: 999px; + cursor: pointer; + overflow: hidden; +} +.progress-track:hover { height: 0.5rem; } +.progress-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0%; + background: var(--player-progress-fill); + border-radius: 999px; + transition: width 120ms linear; +} +.progress-thumb { + position: absolute; + top: 50%; + width: 0.9rem; + height: 0.9rem; + background: var(--player-thumb); + border-radius: 50%; + transform: translate(-50%, -50%) scale(0); + transition: transform var(--transition-fast); + pointer-events: none; +} +.progress-track:hover .progress-thumb, +.progress-track:focus-within .progress-thumb { + transform: translate(-50%, -50%) scale(1); +} + +/* Volume */ +.volume-control { + display: flex; + align-items: center; + gap: 0.25rem; +} +.volume-slider { + width: 5rem; + height: 0.25rem; + cursor: pointer; + accent-color: var(--accent); +} + +/* Big play button */ +.big-play { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(0,0,0,0.35); + cursor: pointer; + z-index: 5; +} +.big-play .play-icon { + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + background: rgba(255,255,255,0.9); + color: #000; + display: grid; + place-items: center; + font-size: 1.25rem; +} diff --git a/web/css/theme.css b/web/css/theme.css new file mode 100644 index 0000000..927b3b0 --- /dev/null +++ b/web/css/theme.css @@ -0,0 +1,105 @@ +:root { + /* Core palette */ + --bg-body: #0f1117; + --bg-surface: #161920; + --bg-surface-hover: #1b1f27; + --bg-elevated: #1e222c; + --bg-overlay: rgba(0,0,0,0.65); + + /* Text */ + --text-primary: #e6e8ef; + --text-secondary: #a3a8b8; + --text-muted: #6b7280; + --text-inverse: #0b0d12; + + /* Accent */ + --accent: #5e9eff; + --accent-hover: #7cb2ff; + --accent-active: #3d8bf7; + --accent-soft: rgba(94,158,255,0.18); + + /* Borders */ + --border: #252a36; + --border-strong: #2e3546; + + /* Feedback */ + --danger: #f25c5c; + --danger-soft: rgba(242,92,92,0.18); + --success: #3ddc84; + --success-soft: rgba(61,220,132,0.18); + --warn: #ffb300; + + /* Player */ + --player-bg: #0b0d12; + --player-progress-bg: #2e3546; + --player-progress-fill: #5e9eff; + --player-thumb: #7cb2ff; + + /* Selection */ + --selected-bg: rgba(94,158,255,0.25); + --selected-border: #5e9eff; + --playing-bg: rgba(61,220,132,0.18); + --playing-border: #3ddc84; + + /* Layout */ + --header-height: 3.25rem; + --sidebar-width: 16rem; + --gap: 0.75rem; + --radius-sm: 0.375rem; + --radius-md: 0.625rem; + --radius-lg: 0.875rem; + + /* Typography */ + --font-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.35); + --shadow-md: 0 4px 12px rgba(0,0,0,0.45); + --shadow-lg: 0 10px 28px rgba(0,0,0,0.55); + + /* Transitions */ + --transition-fast: 120ms ease; + --transition-base: 200ms ease; +} + +/* Light theme overrides */ +[data-theme="light"] { + --bg-body: #f4f5f8; + --bg-surface: #ffffff; + --bg-surface-hover: #f0f2f7; + --bg-elevated: #ffffff; + --bg-overlay: rgba(0,0,0,0.35); + + --text-primary: #12131a; + --text-secondary: #4a4f5e; + --text-muted: #8b90a0; + --text-inverse: #ffffff; + + --accent: #2b6cb0; + --accent-hover: #1a5490; + --accent-active: #17447a; + --accent-soft: rgba(43,108,176,0.14); + + --border: #d6dae4; + --border-strong: #c3c9d6; + + --danger: #c53030; + --danger-soft: rgba(197,48,48,0.14); + --success: #258855; + --success-soft: rgba(37,136,85,0.14); + + --player-bg: #0b0d12; + --player-progress-bg: #d6dae4; + --player-progress-fill: #2b6cb0; + --player-thumb: #1a5490; + + --selected-bg: rgba(43,108,176,0.18); + --selected-border: #2b6cb0; + --playing-bg: rgba(37,136,85,0.14); + --playing-border: #258855; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.08); + --shadow-md: 0 4px 12px rgba(0,0,0,0.10); + --shadow-lg: 0 10px 28px rgba(0,0,0,0.12); +} diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..a77ff7a --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/index.html b/web/index.html index 630ee64..f16c0de 100644 --- a/web/index.html +++ b/web/index.html @@ -2,9 +2,128 @@ + KISS Media Player + + + + + + -

Hello KISS

+ + + + +
+
+
+ + +
+ +
+
+
+
+ + + + + + + + + + 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 = ``; + 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 = `

Use the API to manage permissions directly.

`; +} + +function renderTrash(data) { + const el = document.getElementById('admin-users'); + if (!el || !Array.isArray(data)) return; + el.innerHTML = `

Trash

`; +} + +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) => + `${escapeHtml(s.name)}` + ).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 = '

Loading...

'; + 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 = `

${escapeHtml(err.message)}

`; + } +} + +function renderGrid(items) { + const grid = document.getElementById('media-grid'); + if (!grid) return; + if (!items.length) { grid.innerHTML = `

No results.

`; 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 ` +
+
+ ${m.thumbnail_path ? `` : `No image`} + ${fmtDur(m.duration)} +
+
+
${escapeHtml(m.file_name)}
+
${escapeHtml(m.codec || '')} ${m.resolution || ''} ${m.bitrate ? Math.round(m.bitrate / 1000) + 'kbps' : ''}
+
+
+ + + +
+
+ `; + } + return ` +
+ 🎵 +
+
${escapeHtml(m.file_name)}
+
${escapeHtml(m.codec || '')} ${m.bitrate ? Math.round(m.bitrate/1000)+'kbps' : ''}
+
+ ${fmtDur(m.duration)} + + + +
+ `; +} + +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(-ste