summaryrefslogtreecommitdiff
path: root/web/js
diff options
context:
space:
mode:
Diffstat (limited to 'web/js')
-rw-r--r--web/js/admin.js168
-rw-r--r--web/js/api.js117
-rw-r--r--web/js/app.js427
-rw-r--r--web/js/detach.js152
-rw-r--r--web/js/detachPopup.js296
-rw-r--r--web/js/dom.js27
-rw-r--r--web/js/imageViewer.js193
-rw-r--r--web/js/keyboard.js308
-rw-r--r--web/js/lightbox.js177
-rw-r--r--web/js/notes.js45
-rw-r--r--web/js/playback.js727
-rw-r--r--web/js/player.js1
-rw-r--r--web/js/podcasts.js157
-rw-r--r--web/js/pwa.js7
-rw-r--r--web/js/search.js175
-rw-r--r--web/js/selection.js157
-rw-r--r--web/js/shuffle.js36
-rw-r--r--web/js/state.js26
-rw-r--r--web/js/tests/admin-rescan.test.js178
-rw-r--r--web/js/tests/keyboard-enter.test.js75
-rw-r--r--web/js/tests/media-pagination.test.js56
-rw-r--r--web/js/tests/playback-resume.test.js51
-rw-r--r--web/js/tests/podcast-episodes-api.test.js74
-rw-r--r--web/js/tests/progress-actions.test.js120
-rw-r--r--web/js/tests/search-parser.test.js33
-rw-r--r--web/js/tests/shuffle.test.js165
-rw-r--r--web/js/themes.js20
-rw-r--r--web/js/utils.js21
-rw-r--r--web/js/views/admin-status.js52
-rw-r--r--web/js/views/help.js31
-rw-r--r--web/js/views/media-actions.js93
-rw-r--r--web/js/views/media-grid.js562
-rw-r--r--web/js/views/media-info.js142
-rw-r--r--web/js/views/playback-nav.js251
-rw-r--r--web/js/views/sets.js100
-rw-r--r--web/js/views/shares.js131
-rw-r--r--web/js/views/tags.js129
-rw-r--r--web/js/views/upload.js47
38 files changed, 0 insertions, 5527 deletions
diff --git a/web/js/admin.js b/web/js/admin.js
deleted file mode 100644
index ac43643..0000000
--- a/web/js/admin.js
+++ /dev/null
@@ -1,168 +0,0 @@
-import { API } from './api.js';
-import { escapeHtml, toast } from './utils.js';
-import { triggerRescan } from './views/admin-status.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 triggerRescan(); }
- 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 renderUsers(users) {
- const el = document.getElementById('admin-users');
- if (!el) return;
- el.innerHTML = `<ul class="admin-list">
- ${users.map((u) => `<li class="${u.is_admin ? 'is-admin' : ''}">
- <span>${escapeHtml(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) {
- const msg = err.message || '';
- if (msg.toLowerCase().includes('cannot delete yourself') || msg.toLowerCase().includes('self-delete')) {
- toast('You cannot delete yourself', 'error');
- } else {
- toast(err.message, 'error');
- }
- }
- });
- });
-}
-
-function renderPermissions(data) {
- const el = document.getElementById('admin-permissions');
- if (!el || !data) return;
- if (!data.sets?.length || !data.users?.length) {
- el.innerHTML = '<p class="text-muted text-xs">No sets or users to manage.</p>';
- return;
- }
-
- // Build a lookup: setId -> userId -> role
- const roleMap = {};
- data.permissions?.forEach((p) => {
- if (!roleMap[p.set_id]) roleMap[p.set_id] = {};
- roleMap[p.set_id][p.user_id] = p.role;
- });
-
- let html = '<table class="admin-table"><thead><tr><th>Set</th>';
- data.users.forEach((u) => {
- html += `<th>${escapeHtml(u.username)}</th>`;
- });
- html += '</tr></thead><tbody>';
-
- data.sets.forEach((s) => {
- html += `<tr><td>${escapeHtml(s.name)}</td>`;
- data.users.forEach((u) => {
- const role = roleMap[s.id]?.[u.id] || '';
- const selectId = `perm-${s.id}-${u.id}`;
- html += `<td>
- <select id="${selectId}" class="perm-select" data-set="${s.id}" data-user="${u.id}">
- <option value="" ${!role ? 'selected' : ''}>—</option>
- <option value="viewer" ${role === 'viewer' ? 'selected' : ''}>Viewer</option>
- <option value="owner" ${role === 'owner' ? 'selected' : ''}>Owner</option>
- </select>
- </td>`;
- });
- html += '</tr>';
- });
-
- html += '</tbody></table>';
- el.innerHTML = html;
-
- el.querySelectorAll('.perm-select').forEach((sel) => {
- sel.addEventListener('change', async () => {
- const setId = sel.dataset.set;
- const userId = sel.dataset.user;
- const role = sel.value;
- try {
- if (role) {
- await API.setPermissions({ set_id: parseInt(setId), user_id: parseInt(userId), role });
- toast('Permission granted');
- } else {
- await API.delPermissions({ set_id: parseInt(setId), user_id: parseInt(userId) });
- toast('Permission revoked');
- }
- } catch (err) { toast(err.message, 'error'); }
- });
- });
-}
-
-function renderTrash(data) {
- const el = document.getElementById('admin-users');
- if (!el || !Array.isArray(data)) return;
- if (!data.length) {
- el.innerHTML = '<h4 class="text-90">Trash</h4><p class="text-muted text-xs">No deleted items.</p>';
- return;
- }
- el.innerHTML = `<h4 class="text-90">Trash</h4><ul class="admin-list">
- ${data.map((m) => `<li>
- <span>${escapeHtml(m.file_name)}</span>
- <span class="text-muted text-75">${escapeHtml(m.deleted_at || '')}</span>
- <button class="btn btn-primary btn-sm" data-id="${m.id}">Restore</button>
- </li>`).join('')}
- </ul>`;
-
- el.querySelectorAll('button[data-id]').forEach((b) => {
- b.addEventListener('click', async () => {
- try {
- await API.restore(b.dataset.id);
- toast('Item restored');
- const data = await API.trash();
- renderTrash(data);
- } catch (err) { toast(err.message, 'error'); }
- });
- });
-}
diff --git a/web/js/api.js b/web/js/api.js
deleted file mode 100644
index 5decd89..0000000
--- a/web/js/api.js
+++ /dev/null
@@ -1,117 +0,0 @@
-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) {
- let msg = 'Unauthorized';
- try {
- const j = await res.json();
- if (j.error) msg = j.error;
- } catch {}
- const p = location.pathname;
- if (!p.includes('login') && !p.includes('bootstrap')) {
- location.href = '/login.html';
- }
- throw new Error(msg);
- }
- 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 } }),
- config: () => api('/api/config'),
- 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 } }),
- progressStatus: (mediaId, status) => api('/api/progress/status', { method: 'POST', body: { media_id: mediaId, status } }),
- inProgress: () => api('/api/in-progress'),
- 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' }),
- myShares: () => api('/api/shares'),
- revokeShare: (token) => api(`/api/shares/${token}`, { method: 'DELETE' }),
- tags: () => api('/api/tags'),
- download: (id) => api(`/api/media/${id}/download`),
- regenThumbnail: (id) => api(`/api/media/${id}/thumbnail`, { method: 'POST' }),
- browse: (setId, parent) => {
- const params = new URLSearchParams();
- if (parent) params.set('parent', parent);
- const s = params.toString();
- return api(`/api/sets/${setId}/browse${s ? '?' + s : ''}`);
- },
- regenCover: (setId, folder = '') => {
- const params = new URLSearchParams();
- if (folder) params.set('folder', folder);
- const s = params.toString();
- return api(`/api/sets/${setId}/cover${s ? '?' + s : ''}`, { method: 'POST' });
- },
- upload: (setId, formData) => api(`/api/sets/${setId}/upload`, { method: 'POST', body: formData }),
- addTag: (id, tag) => api(`/api/media/${id}/tags`, { method: 'POST', body: { tag } }),
- removeTag: (id, tag) => api(`/api/media/${id}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE' }),
- restore: (id) => api(`/api/media/${id}/restore`, { method: 'POST' }),
- trash: () => api('/api/admin/trash'),
- users: () => api('/api/admin/users'),
- scanProgress: () => api('/api/admin/scan-progress'),
- 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' }),
- podcasts: () => api('/api/podcasts'),
- podcastEpisodes: (setId, pagination, legacyOffset) => {
- let limit = 50;
- let offset = 0;
- if (typeof pagination === 'number') {
- if (typeof legacyOffset === 'number') {
- limit = pagination;
- offset = legacyOffset;
- } else {
- offset = pagination;
- }
- } else if (pagination) {
- limit = pagination.limit ?? limit;
- offset = pagination.offset ?? offset;
- }
- const params = new URLSearchParams({ limit, offset });
- return api(`/api/podcasts/${setId}/episodes?${params}`);
- },
- subscribePodcast: (feedUrl, setName) => api('/api/podcasts', { method: 'POST', body: { feed_url: feedUrl, set_name: setName } }),
- downloadEpisode: (episodeId) => api(`/api/podcasts/episodes/${episodeId}/download`, { method: 'POST' }),
- toggleEpisodeComplete: (episodeId) => api(`/api/podcasts/episodes/${episodeId}/complete`, { method: 'POST' }),
-};
diff --git a/web/js/app.js b/web/js/app.js
deleted file mode 100644
index d7665fa..0000000
--- a/web/js/app.js
+++ /dev/null
@@ -1,427 +0,0 @@
-import { API } from './api.js';
-import { initKeyboard } from './keyboard.js';
-import { initSelection, selectByElement, currentElement, navUp, navDown, navLeft, navRight } from './selection.js';
-import {
- initPlayer,
- togglePlay,
- toggleFullscreen,
- toggleMinimize,
- toggleDetach,
- exitFullscreenIfNeeded,
- toggleCrop,
- shiftCropPosition,
- cycleCropPosition,
- currentMediaInfo,
- hasLoadedMedia,
- seekPercent,
- stopAndClose,
- zoomIn as playerZoomIn,
- zoomOut as playerZoomOut,
- isImageMode as playerIsImageMode,
-} from './player.js';
-import { initSearch, parseQuery, showSearchHelp } from './search.js';
-import { initShuffle, enable as enableShuffle, isOn as isShuffle, revision as shuffleRevision } from './shuffle.js';
-import { initThemes } from './themes.js';
-import { initNotes } from './notes.js';
-import { initAdmin } from './admin.js';
-import { initPodcasts } from './podcasts.js';
-import { state } from './state.js';
-import { initPWA } from './pwa.js';
-import { closeAllModals } from './dom.js';
-import { toast } from './utils.js';
-import { showAdmin, triggerRescan } from './views/admin-status.js';
-import { initHelp, showSearch, toggleHelp, toggleSidebar } from './views/help.js';
-import {
- initMediaGrid,
- loadMedia,
- enterFolder,
- navigateBack,
- setMediaPageSize,
-} from './views/media-grid.js';
-import {
- downloadSelected,
- markAsFinished,
- markAsNotStarted,
- openNotesForSelected,
- regenThumb,
- selectedMediaId,
- shareSelected,
- toggleFavorite,
-} from './views/media-actions.js';
-import {
- closeMediaInfo,
- initMediaInfo,
- isMediaInfoOpen,
- scrollMediaInfo,
- toggleMediaInfo,
-} from './views/media-info.js';
-import {
- navigatePlayable,
- initPlaybackNav,
- playRandom,
- playSelected,
- seekByKeyboard,
-} from './views/playback-nav.js';
-import {
- initSets,
- renderSets,
- selectSetByHotkey,
- setSetByDelta,
- toggleSetSelection,
- updateSetRowsUI,
-} from './views/sets.js';
-import {
- copySelectedShare,
- deleteSelectedShare,
- initShares,
- isSharesOpen,
- sharesNav,
- toggleShares,
-} from './views/shares.js';
-import { initTags, openTagsForElement, openTagsForSelected } from './views/tags.js';
-import { initUpload, showUpload } from './views/upload.js';
-
-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();
-}
-
-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';
- }
- });
-}
-
-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';
- }
- });
-}
-
-async function initApp() {
- initPlayer({
- onNext: (options) => navigatePlayable(options?.delta ?? 1, options),
- onPrevious: (options) => navigatePlayable(options?.delta ?? -1, options),
- });
- initSelection();
- initSearch({
- onChange: (q) => {
- const parsed = parseQuery(q);
- state.virtualSet = '';
- syncChromeVirtualSets();
- applySearchSet(parsed);
- delete parsed.set;
- Object.assign(state.filters, parsed);
- state.folderPath = '';
- document.dispatchEvent(new CustomEvent('filters:changed'));
- console.log('[search] raw:', q, 'parsed:', parsed, 'filters:', state.filters);
- loadMedia();
- },
- input: document.getElementById('search-input'),
- clearBtn: document.getElementById('search-clear'),
- });
- document.addEventListener('search:navigate-results', () => navDown());
- initShuffle({ onChange: () => loadMedia() });
- initPlaybackNav({ isShuffle });
- initSets({
- onLoadMedia: () => {
- state.virtualSet = '';
- syncChromeVirtualSets();
- loadMedia();
- },
- });
- initMediaGrid({
- isShuffle,
- shuffleRevision,
- onSetCleared: updateSetRowsUI,
- openNotesForSelected,
- openTagsForElement,
- openSet,
- playSelected,
- regenThumb,
- markAsFinished: markAsFinishedAndRefresh,
- markAsNotStarted: markAsNotStartedAndRefresh,
- toggleFavorite,
- onVirtualSetCleared: syncChromeVirtualSets,
- });
- initKeyboard(keyboardHandlers());
- initNotes(() => toast('Note saved'));
- initAdmin();
- initPodcasts();
- initPWA();
- initUpload({ onLoadMedia: loadMedia });
- initHelp();
- initShares();
- initMediaInfo({
- markAsFinished: markAsFinishedAndRefresh,
- markAsNotStarted: markAsNotStartedAndRefresh,
- });
- initTags({
- onFilterChange: () => {
- document.dispatchEvent(new CustomEvent('filters:changed'));
- loadMedia();
- },
- });
- initChrome();
-
- try {
- const [cfg, sets] = await Promise.all([
- API.config().catch(() => null),
- API.sets(),
- ]);
- setMediaPageSize(cfg?.media_page_size);
- state.sets = (sets || []).slice().sort((a, b) => a.name.localeCompare(b.name));
- API.users().then(() => {
- state.isAdmin = true;
- showAdmin();
- }).catch(() => {});
- renderSets();
- await loadMedia();
- } catch (err) {
- toast(err.message || 'Error loading sets', 'error');
- }
-}
-
-function applySearchSet(parsed) {
- const setQuery = parsed.set?.trim();
- if (!setQuery) return;
- const needle = setQuery.toLowerCase();
- const match = state.sets.find((set) => String(set.id) === setQuery) ||
- state.sets.find((set) => set.name.toLowerCase() === needle) ||
- state.sets.find((set) => set.name.toLowerCase().includes(needle));
- if (!match) return;
- state.virtualSet = '';
- state.selectedSetId = match.id;
- state.selectedSetIds = [match.id];
- updateSetRowsUI();
-}
-
-function keyboardHandlers() {
- return {
- navUp: () => navUp(),
- navDown: () => navDown(),
- navLeft: () => navLeft(),
- navRight: () => navRight(),
- seekBackward: (e) => seekByKeyboard(-1, e.repeat),
- seekForward: (e) => seekByKeyboard(1, e.repeat),
- seekPercent: (e, percent) => {
- if (!hasLoadedMedia()) return false;
- if (!document.fullscreenElement) return false;
- return seekPercent(percent);
- },
- nextSet: () => setSetByDelta(1),
- prevSet: () => setSetByDelta(-1),
- selectSetByHotkey: (key) => selectSetByHotkey(key),
- isSidebarOpen: () => document.getElementById('sidebar')?.classList.contains('open'),
- isSidebarFocused: () => {
- const sidebar = document.getElementById('sidebar');
- return sidebar?.contains(document.activeElement);
- },
- toggleSetSelect: () => toggleSetSelection(),
- enter: () => {
- activateGridElement(focusedGridElement() || currentElement() || firstGridElement());
- },
- playPause: () => togglePlay(),
- nextTrack: () => navigatePlayable(1, { forcePlay: true }),
- prevTrack: () => navigatePlayable(-1, { forcePlay: true }),
- playRandom: () => playRandom(),
- rescanMedia: () => {
- if (state.isAdmin) triggerRescan();
- },
- mediaInfo: () => toggleMediaInfo(),
- fullscreen: () => toggleFullscreen(),
- toggleMinimize: () => toggleMinimize(),
- toggleCrop: () => toggleCrop(),
- shiftCropPosition: (dx, dy) => shiftCropPosition(dx, dy),
- cycleCropPosition: () => cycleCropPosition(),
- escape: () => {
- exitFullscreenIfNeeded();
- const el = currentElement();
- if (el) el.classList.remove('selected');
- closeAllModals();
- },
- shuffle: () => {
- enableShuffle();
- loadMedia();
- },
- share: () => shareSelected(),
- search: () => showSearch(),
- notes: openNotesForSelected,
- tags: openTagsForSelected,
- toggleDetach: () => toggleDetach(),
- stopAndClose: () => stopAndClose(),
- download: downloadSelected,
- help: toggleHelp,
- backspace: () => { navigateBack(); },
- isMediaInfoOpen,
- closeMediaInfo,
- mediaInfoScroll: scrollMediaInfo,
- sidebar: toggleSidebar,
- upload: () => showUpload(),
- sharesToggle: toggleShares,
- searchHelp: () => showSearchHelp(),
- regenThumbnail: () => {
- const id = selectedMediaId();
- if (id) regenThumb(id);
- },
- isSharesOpen,
- sharesNavUp: () => sharesNav(-1),
- sharesNavDown: () => sharesNav(1),
- sharesCopy: copySelectedShare,
- sharesDelete: deleteSelectedShare,
- isImageMode: () => currentMediaInfo()?.type === 'image',
- imageFullscreenNavigate: (delta) => {
- if (!document.fullscreenElement || !playerIsImageMode()) return false;
- navigatePlayable(delta, { forcePlay: true });
- return true;
- },
- zoomIn: () => {
- if (playerIsImageMode()) playerZoomIn();
- },
- zoomOut: () => {
- if (playerIsImageMode()) playerZoomOut();
- },
- };
-}
-
-function focusedGridElement() {
- const active = document.activeElement;
- if (!active || typeof active.closest !== 'function') return null;
- return active.closest('#media-grid .media-card, #media-grid .media-row, #media-grid .folder-card, #media-grid .set-card');
-}
-
-function firstGridElement() {
- return document.querySelector('#media-grid .media-card, #media-grid .media-row, #media-grid .folder-card, #media-grid .set-card');
-}
-
-function activateGridElement(el) {
- if (!el) return;
- selectByElement(el);
- if (el.classList.contains('set-card')) {
- const id = parseInt(el.dataset.setId, 10);
- openSet(id);
- return;
- }
- if (el.classList.contains('folder-card')) {
- enterFolder(el.dataset.name);
- return;
- }
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media) playSelected();
-}
-
-function openSet(id) {
- if (!Number.isFinite(id)) return;
- state.virtualSet = '';
- state.selectedSetId = id;
- state.selectedSetIds = [id];
- state.folderPath = '';
- state.mediaPage = 0;
- updateSetRowsUI();
- loadMedia();
-}
-
-async function markAsFinishedAndRefresh(id) {
- const updated = await markAsFinished(id);
- if (updated && state.virtualSet === 'in-progress') await loadMedia();
- return updated;
-}
-
-async function markAsNotStartedAndRefresh(id) {
- const updated = await markAsNotStarted(id);
- if (updated && state.virtualSet === 'in-progress') await loadMedia();
- return updated;
-}
-
-function openInProgress() {
- state.virtualSet = state.virtualSet === 'in-progress' ? '' : 'in-progress';
- state.selectedSetId = null;
- state.selectedSetIds = [];
- state.folderPath = '';
- state.mediaPage = 0;
- updateSetRowsUI();
- syncChromeVirtualSets();
- loadMedia();
-}
-
-function syncChromeVirtualSets() {
- document.getElementById('in-progress-toggle')?.classList.toggle('active', state.virtualSet === 'in-progress');
-}
-
-function initChrome() {
- const shuffleBtn = document.getElementById('shuffle-toggle');
- if (shuffleBtn && !document.getElementById('in-progress-toggle')) {
- const btn = document.createElement('button');
- btn.id = 'in-progress-toggle';
- btn.className = 'icon-btn';
- btn.type = 'button';
- btn.title = 'In Progress';
- btn.setAttribute('aria-label', 'Show in-progress media');
- btn.textContent = '◷';
- shuffleBtn.insertAdjacentElement('afterend', btn);
- }
- document.getElementById('in-progress-toggle')?.addEventListener('click', openInProgress);
-
- document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
- toggleSidebar();
- });
-
- document.getElementById('menu-close')?.addEventListener('click', () => {
- document.getElementById('sidebar')?.classList.remove('open');
- document.querySelector('.page')?.classList.remove('has-sidebar');
- });
-
- document.getElementById('logout-btn')?.addEventListener('click', async () => {
- try {
- await API.logout();
- location.href = '/login.html';
- } catch {}
- });
-}
diff --git a/web/js/detach.js b/web/js/detach.js
deleted file mode 100644
index c216f3f..0000000
--- a/web/js/detach.js
+++ /dev/null
@@ -1,152 +0,0 @@
-let deps = {};
-let detachWindow = null;
-let detachReady = false;
-let pendingDetachMessage = null;
-let detachedPlaybackState = null;
-
-export function initDetach(options = {}) {
- deps = options;
- window.addEventListener('message', (ev) => {
- if (ev.origin !== window.location.origin) return;
- if (!ev.data || typeof ev.data !== 'object') return;
- if (ev.data.type === 'detach-ready') {
- onDetachReady(ev.source);
- } else if (ev.data.type === 'detach-closing') {
- onDetachClosing(ev.data.state);
- } else if (ev.data.type === 'detach-state') {
- detachedPlaybackState = mergeDetachedState(detachedPlaybackState, ev.data.state);
- } else if (ev.data.type === 'detach-prev') {
- deps.triggerPrevious?.({ forcePlay: ev.data.play ?? true });
- } else if (ev.data.type === 'detach-next') {
- deps.triggerNext?.({ forcePlay: ev.data.play ?? true });
- } else if (ev.data.type === 'detach-nav') {
- deps.triggerNavigate?.(Number(ev.data.delta || 0), { forcePlay: ev.data.play ?? true });
- }
- });
-}
-
-export function isDetached() {
- return !!detachWindow && !detachWindow.closed;
-}
-
-export function detachedIsPlaying() {
- return !!detachedPlaybackState?.playing;
-}
-
-export function toggleDetach() {
- if (isDetached()) {
- reattachDetached();
- return;
- }
- const e = deps.els?.() || {};
- if (!e.player) return;
-
- const snapshot = deps.localPlaybackState?.() || {};
- const features = 'width=640,height=480,resizable=yes,scrollbars=no,status=no,location=no,menubar=no,toolbar=no';
- detachWindow = window.open('/detach.html', 'playerDetach', features);
- if (!detachWindow) return;
-
- detachReady = false;
- detachedPlaybackState = snapshot;
- const media = deps.getCurrentMedia?.();
- if (media) {
- pendingDetachMessage = detachedLoadMessage(media, snapshot.currentTime, snapshot.playing);
- }
- deps.currentMediaElement?.()?.pause();
- e.player.classList.add('hidden');
- showDetachedPlaceholder(true);
-}
-
-export function sendDetachedLoad(media, resumeFrom = 0, play = true) {