summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/js/app.js1362
-rw-r--r--web/js/dom.js47
-rw-r--r--web/js/views/admin-status.js39
-rw-r--r--web/js/views/help.js33
-rw-r--r--web/js/views/media-actions.js72
-rw-r--r--web/js/views/media-grid.js334
-rw-r--r--web/js/views/media-info.js121
-rw-r--r--web/js/views/playback-nav.js247
-rw-r--r--web/js/views/sets.js93
-rw-r--r--web/js/views/shares.js130
-rw-r--r--web/js/views/tags.js79
-rw-r--r--web/js/views/upload.js47
-rw-r--r--web/sw.js14
13 files changed, 1422 insertions, 1196 deletions
diff --git a/web/js/app.js b/web/js/app.js
index 102072b..81799c6 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -1,19 +1,93 @@
import { API } from './api.js';
import { initKeyboard } from './keyboard.js';
-import { initSelection, clearSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js';
-import { initPlayer, togglePlay, toggleFullscreen, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, toggleCrop, shiftCropPosition, cycleCropPosition, currentMediaId, currentMediaInfo, hasLoadedMedia, isPlaybackActive, seekRelative, seekPercent, selectAndPlay, stopAndClose, zoomIn as playerZoomIn, zoomOut as playerZoomOut, toggleSlideshow as playerToggleSlideshow, isImageMode as playerIsImageMode } from './player.js';
-import { initSearch, focusSearch, trigger as triggerSearch, parseQuery, showSearchHelp } from './search.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,
+ toggleSlideshow as playerToggleSlideshow,
+ isImageMode as playerIsImageMode,
+} from './player.js';
+import { initSearch, parseQuery, showSearchHelp } 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 { initNotes } from './notes.js';
import { initAdmin } from './admin.js';
import { initPodcasts } from './podcasts.js';
-import { state, setMedia } from './state.js';
+import { state } from './state.js';
import { initPWA } from './pwa.js';
-import { initLightbox, open as openLightbox, close as closeLightbox, isOpen as isLightboxOpen, next as lightboxNext, prev as lightboxPrev, zoomIn as lightboxZoomIn, zoomOut as lightboxZoomOut, toggleSlideshow as lightboxToggleSlideshow } from './lightbox.js';
+import {
+ initLightbox,
+ open as openLightbox,
+ close as closeLightbox,
+ isOpen as isLightboxOpen,
+ next as lightboxNext,
+ prev as lightboxPrev,
+ zoomIn as lightboxZoomIn,
+ zoomOut as lightboxZoomOut,
+ toggleSlideshow as lightboxToggleSlideshow,
+} from './lightbox.js';
+import { closeAllModals, toast } from './dom.js';
+import { showAdmin } from './views/admin-status.js';
+import { initHelp, showSearch, toggleHelp, toggleSidebar } from './views/help.js';
+import {
+ initMediaGrid,
+ loadMedia,
+ enterFolder,
+ navigateBack,
+} from './views/media-grid.js';
+import {
+ downloadSelected,
+ 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,
+ setSetByDelta,
+ toggleSetSelection,
+} 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' };
-let scanProgressTimer = null;
main();
@@ -25,7 +99,6 @@ function main() {
initThemes();
}
-// Login page
function initLogin() {
const form = document.getElementById('login-form');
const err = document.getElementById('login-error');
@@ -36,7 +109,10 @@ function initLogin() {
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; }
+ if (!username || !password) {
+ err.textContent = 'Please fill in all fields.';
+ return;
+ }
try {
await API.login(username, password);
location.href = '/';
@@ -46,7 +122,6 @@ function initLogin() {
});
}
-// Bootstrap page
function initBootstrap() {
const form = document.getElementById('bootstrap-form');
const err = document.getElementById('bootstrap-error');
@@ -58,8 +133,14 @@ function initBootstrap() {
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; }
+ 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';
@@ -69,7 +150,6 @@ function initBootstrap() {
});
}
-// SPA
async function initApp() {
initPlayer({
onNext: (options) => navigatePlayable(1, options),
@@ -87,17 +167,46 @@ async function initApp() {
input: document.getElementById('search-input'),
clearBtn: document.getElementById('search-clear'),
});
- initShuffle({
- onChange: () => loadMedia(),
- });
- initLightbox({
- onNavigate: (delta) => {
- const images = state.media.filter((m) => m.type === 'image');
- if (!images.length) return;
- // Not needed - lightbox navigates internally
- },
+ initShuffle({ onChange: () => loadMedia() });
+ initLightbox({ onNavigate: () => {} });
+ initPlaybackNav({ isShuffle });
+ initSets({ onLoadMedia: loadMedia });
+ initMediaGrid({
+ isShuffle,
+ openLightbox,
+ openNotesForSelected,
+ openTagsForElement,
+ playSelected,
+ regenThumb,
+ toggleFavorite,
});
- initKeyboard({
+ initKeyboard(keyboardHandlers());
+ initNotes(() => toast('Note saved'));
+ initAdmin();
+ initPodcasts();
+ initPWA();
+ initUpload({ onLoadMedia: loadMedia });
+ initHelp();
+ initShares();
+ initMediaInfo();
+ initTags();
+ initChrome();
+
+ try {
+ const sets = await API.sets();
+ state.sets = sets || [];
+ API.users().then(() => {
+ state.isAdmin = true;
+ showAdmin();
+ }).catch(() => {});
+ renderSets();
+ } catch (err) {
+ toast(err.message || 'Error loading sets', 'error');
+ }
+}
+
+function keyboardHandlers() {
+ return {
navUp: () => navUp(),
navDown: () => navDown(),
navLeft: () => navLeft(),
@@ -123,14 +232,14 @@ async function initApp() {
selectByElement(el);
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?.type === 'image') {
+ openLightbox(state.media, media.id);
} else {
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media?.type === 'image') {
- openLightbox(state.media, media.id);
- } else {
- playSelected();
- }
+ playSelected();
}
},
playPause: () => togglePlay(),
@@ -143,8 +252,17 @@ async function initApp() {
toggleCrop: () => toggleCrop(),
shiftCropPosition: (dx, dy) => shiftCropPosition(dx, dy),
cycleCropPosition: () => cycleCropPosition(),
- escape: () => { exitFullscreenIfNeeded(); closeLightbox(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); },
- shuffle: () => { toggleShuffle(); loadMedia(); },
+ escape: () => {
+ exitFullscreenIfNeeded();
+ closeLightbox();
+ const el = currentElement();
+ if (el) el.classList.remove('selected');
+ closeAllModals();
+ },
+ shuffle: () => {
+ toggleShuffle();
+ loadMedia();
+ },
share: () => shareSelected(),
search: () => showSearch(),
notes: openNotesForSelected,
@@ -161,8 +279,11 @@ async function initApp() {
upload: () => showUpload(),
sharesToggle: toggleShares,
searchHelp: () => showSearchHelp(),
- regenThumbnail: () => { const id = selectedMediaId(); if (id) regenThumb(id); },
- isSharesOpen: () => document.getElementById('shares-modal')?.classList.contains('open'),
+ regenThumbnail: () => {
+ const id = selectedMediaId();
+ if (id) regenThumb(id);
+ },
+ isSharesOpen,
sharesNavUp: () => sharesNav(-1),
sharesNavDown: () => sharesNav(1),
sharesCopy: copySelectedShare,
@@ -170,1182 +291,33 @@ async function initApp() {
isLightboxOpen,
isImageMode: () => currentMediaInfo()?.type === 'image',
zoomIn: () => {
- if (isLightboxOpen()) { lightboxZoomIn(); }
- else if (playerIsImageMode()) { playerZoomIn(); }
+ if (isLightboxOpen()) lightboxZoomIn();
+ else if (playerIsImageMode()) playerZoomIn();
},
zoomOut: () => {
- if (isLightboxOpen()) { lightboxZoomOut(); }
- else if (playerIsImageMode()) { playerZoomOut(); }
+ if (isLightboxOpen()) lightboxZoomOut();
+ else if (playerIsImageMode()) playerZoomOut();
},
toggleSlideshow: () => {
- if (isLightboxOpen()) { lightboxToggleSlideshow(); }
- else if (playerIsImageMode()) { playerToggleSlideshow(); }
+ if (isLightboxOpen()) lightboxToggleSlideshow();
+ else if (playerIsImageMode()) playerToggleSlideshow();
},
lightboxNext,
lightboxPrev,
closeLightbox,
- });
- initNotes(() => toast('Note saved'));
- initAdmin();
- initPodcasts();
- initPWA();
- initUpload();
- initHelp();
- initShares();
- initMediaInfo();
+ };
+}
- // Sidebar close
+function initChrome() {
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 {}
- });
-
- try {
- const sets = await API.sets();
- state.sets = sets || [];
- // Admin check via admin users endpoint
- API.users().then(() => { state.isAdmin = true; showAdmin(); }).catch(() => {});
- renderSets();
- // Do NOT auto-load a set by default; keep the page blank until the user chooses.
- } catch (err) {
- toast(err.message || 'Error loading sets', 'error');
- }
-
- // Play on double-click (or open lightbox for images)
- document.getElementById('media-grid')?.addEventListener('dblclick', (e) => {
- const el = e.target.closest('.media-card, .media-row');
- if (!el) return;
- selectByElement(el);
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media?.type === 'image') {
- openLightbox(state.media, media.id);
- } else {
- playSelected();
- }
- });
-
- // Folder navigation
- document.getElementById('media-grid')?.addEventListener('click', (e) => {
- const folder = e.target.closest('.folder-card');
- if (folder) {
- e.stopPropagation();
- enterFolder(folder.dataset.name);
- }
- });
-
- // Tags modal close
- document.getElementById('tags-close')?.addEventListener('click', closeTagsModal);
- document.getElementById('tags-modal')?.addEventListener('click', (e) => { if (e.target === document.getElementById('tags-modal')) closeTagsModal(); });
- document.getElementById('tags-add')?.addEventListener('click', addTagForSelected);
- document.getElementById('tags-new')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTagForSelected(); } });
-}
-
-function renderSets() {
- const el = document.getElementById('set-list');
- if (!el) return;
- el.innerHTML = state.sets.map((s) =>
- `<div class="set-row" tabindex="0" role="button" data-id="${s.id}" aria-label="Set ${escapeHtml(s.name)}">
- <span class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</span>
- <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button>
- </div>`
- ).join('');
- el.querySelectorAll('.set-row').forEach((row) => {
- row.addEventListener('click', (e) => {
- if (e.target.closest('[data-cover-set]')) return;
- e.preventDefault();
- // Single-select on click
- const id = parseInt(row.dataset.id, 10);
- state.selectedSetIds = [id];
- state.selectedSetId = id;
- state.folderPath = '';
- updateSetRowsUI();
- loadMedia();
- });
- });
- el.querySelectorAll('[data-cover-set]').forEach((b) => {
- b.addEventListener('click', async (e) => {
- e.stopPropagation();
- const id = b.dataset.coverSet;
- try { await API.regenCover(id); toast('Cover regenerated'); }
- catch (err) { toast(err.message || 'Cover failed', 'error'); }
- });
- });
- updateSetRowsUI();
-}
-
-function updateSetRowsUI() {
- document.querySelectorAll('#set-list .set-row').forEach((row) => {
- const id = parseInt(row.dataset.id, 10);
- const item = row.querySelector('.set-item');
- if (!item) return;
- item.classList.toggle('active', id === state.selectedSetId);
- item.classList.toggle('selected', state.selectedSetIds.includes(id));
- });
-}
-
-function toggleSetSelection() {
- const sidebar = document.getElementById('sidebar');
- if (!sidebar) return;
- const focused = document.activeElement.closest('.set-row');
- if (!focused) return;
- const id = parseInt(focused.dataset.id, 10);
- const idx = state.selectedSetIds.indexOf(id);
- if (idx >= 0) {
- state.selectedSetIds.splice(idx, 1);
- } else {
- state.selectedSetIds.push(id);
- }
- // Also sync primary selectedSetId for media loading
- if (state.selectedSetIds.length) {
- state.selectedSetId = state.selectedSetIds[state.selectedSetIds.length - 1];
- } else {
- state.selectedSetId = null;
- }
- state.folderPath = '';
- updateSetRowsUI();
- 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;
- state.selectedSetIds = [state.selectedSetId];
- state.folderPath = '';
- setActiveSet(state.selectedSetId);
- loadMedia();
-}
-
-async function loadMedia() {
- const grid = document.getElementById('media-grid');
- const hint = document.getElementById('empty-hint');
- if (!grid) return;
- grid.classList.remove('hidden');
- hint?.classList.add('hidden');
- grid.innerHTML = '<p class="text-muted text-sm grid-full">Loading...</p>';
-
- const resultCount = document.getElementById('result-count');
- const breadcrumb = document.getElementById('breadcrumb-bar');
-
- try {
- const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : '';
- const singleSetId = state.selectedSetIds.length === 1 ? state.selectedSetIds[0] : null;
-
- // When viewing a single set with no filters/search, use the browse endpoint
- // so that subfolders are presented as folders to navigate into.
- if (singleSetId && !setIds && !isShuffle() && !hasActiveFilters()) {
- const data = await API.browse(singleSetId, state.folderPath);
- updateBreadcrumb(data.current_path);
- setMedia(mediaWithBrowsePath(data.media || [], data.current_path || ''));
- renderBrowse(data);
- const total = (data.media?.length || 0) + (data.folders?.length || 0);
- resultCount.textContent = `${total} items`;
- } else {
- breadcrumb?.classList.add('hidden');
- const sort = isShuffle() ? 'random' : (state.filters.sort || 'name');
- const params = {
- set_id: setIds ? '' : String(singleSetId || state.selectedSetId || ''),
- set_ids: setIds,
- type: state.filters.type,
- search: state.filters.search,
- favorites: state.filters.favorites ? 'true' : '',
- tags: state.filters.tags || '',
- min_duration: state.filters.minDuration ? String(parseFloat(state.filters.minDuration) * 60) : '',
- max_duration: state.filters.maxDuration ? String(parseFloat(state.filters.maxDuration) * 60) : '',
- filesize_min: state.filters.minFileSize ? String(parseInt(state.filters.minFileSize, 10) * 1024 * 1024) : '',
- filesize_max: state.filters.maxFileSize ? String(parseInt(state.filters.maxFileSize, 10) * 1024 * 1024) : '',
- sort,
- limit: '1000',
- };
- const data = await API.media(params);
- const list = Array.isArray(data) ? data : data?.media || [];
- setMedia(list);
- renderGrid(list);
- resultCount.textContent = `${list.length} items`;
- }
- } catch (err) {
- grid.innerHTML = `<p class="error-message grid-full">${escapeHtml(err.message)}</p>`;
- }
-}
-
-function updateBreadcrumb(currentPath) {
- const el = document.getElementById('breadcrumb-bar');
- if (!el) return;
- const setName = state.sets.find((s) => s.id === state.selectedSetId)?.name || 'Set';
- const parts = currentPath ? currentPath.split('/') : [];
-
- let html = `<button class="breadcrumb-root" data-folder="">${escapeHtml(setName)}</button>`;
- let accumulated = '';
- for (const part of parts) {
- accumulated = accumulated ? `${accumulated}/${part}` : part;
- html += ` <span class="breadcrumb-sep">/</span> <button class="breadcrumb-part" data-folder="${escapeHtml(accumulated)}">${escapeHtml(part)}</button>`;
- }
- el.innerHTML = html;
- el.classList.remove('hidden');
- el.querySelectorAll('button').forEach((b) => {
- b.addEventListener('click', () => {
- state.folderPath = b.dataset.folder;
- loadMedia();
- });
- });
-}
-
-function enterFolder(name) {
- state.folderPath = state.folderPath ? `${state.folderPath}/${name}` : name;
- loadMedia();
-}
-
-function mediaWithBrowsePath(media, path) {
- return media.map((m) => ({ ...m, browse_path: path || '' }));
-}
-
-function navigateBack() {
- if (!state.folderPath) return;
- const last = state.folderPath.lastIndexOf('/');
- if (last < 0) {
- state.folderPath = '';
- } else {
- state.folderPath = state.folderPath.substring(0, last);
- }
- loadMedia();
-}
-
-function renderBrowse(data) {
- const grid = document.getElementById('media-grid');
- if (!grid) return;
- const folders = data.folders || [];
- const media = mediaWithBrowsePath(data.media || [], data.current_path || '');
- if (!folders.length && !media.length) {
- grid.innerHTML = '<p class="text-muted text-sm grid-full">Folder is empty.</p>';
- clearSelection();
- return;
- }
- const folderHtml = folders.map((f, i) => renderFolder(f, i)).join('');
- const mediaHtml = media.map((m, i) => renderItem(m, i)).join('');
- grid.innerHTML = folderHtml + mediaHtml;
- clearSelection();
-
- grid.querySelectorAll('.folder-card').forEach((el) => {
- el.addEventListener('click', () => enterFolder(el.dataset.name));
- });
-
- grid.querySelectorAll('.folder-card [data-action="regen-folder-cover"]').forEach((b) => {
- b.addEventListener('click', async (e) => {
- e.stopPropagation();
- const setId = state.selectedSetId;
- if (!setId) return;
- const name = b.closest('.folder-card')?.dataset.name;
- const folder = state.folderPath ? `${state.folderPath}/${name}` : name;
- try {
- await API.regenCover(setId, folder);
- toast('Folder cover regenerated');
- await loadMedia();
- // Force refresh the folder cover image to bypass browser cache
- const folderCard = document.querySelector(`.folder-card[data-name="${CSS.escape(name)}"]`);
- const img = folderCard?.querySelector('img');
- if (img) {
- const base = img.src.split('?')[0];
- img.src = `${base}?t=${Date.now()}`;
- }
- }
- catch (err) { toast(err.message || 'Cover failed', 'error'); }
- });
- });
-
- grid.querySelectorAll('.media-card, .media-row').forEach((el) => {
- el.addEventListener('click', () => { selectByElement(el); });
- const playBtn = el.querySelector('[data-action="play"]');
- const viewBtn = el.querySelector('[data-action="view"]');
- const favBtn = el.querySelector('[data-action="favorite"]');
- const noteBtn = el.querySelector('[data-action="notes"]');
- const downloadBtn = el.querySelector('[data-action="download"]');
- const tagBtn = el.querySelector('[data-action="tags"]');
- const thumbBtn = el.querySelector('[data-action="regen-thumb"]');
- playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); });
- viewBtn?.addEventListener('click', (e) => {
- e.stopPropagation();
- selectByElement(el);
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media) openLightbox(state.media, media.id);
- });
- favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); });
- noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); });
- downloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); window.open(`/api/media/${el.dataset.id}/download`, '_blank'); });
- tagBtn?.addEventListener('click', (e) => { e.stopPropagation(); openTagsForElement(el); });
- thumbBtn?.addEventListener('click', (e) => { e.stopPropagation(); regenThumb(el.dataset.id); });
- });
-
- // For podcast sets, render episodes from the browse response after media.
- if (data.episodes && data.episodes.length) {
- import('./podcasts.js').then(m => {
- m.renderPodcastEpisodes(grid, data.episodes);
- }).catch(() => {});
- }
-}
-
-function renderFolder(folder, index) {
- const name = typeof folder === 'string' ? folder : folder.name;
- const hasCover = typeof folder === 'object' && folder?.has_cover;
- const coverImg = hasCover
- ? `<img src="/api/sets/${state.selectedSetId}/cover?folder=${encodeURIComponent(state.folderPath ? `${state.folderPath}/${name}` : name)}" alt="" loading="lazy">`
- : null;
- return `
- <div class="media-card folder-card" data-name="${escapeHtml(name)}" data-index="${index}" tabindex="0" role="button" aria-label="Folder ${escapeHtml(name)}">
- <div class="thumb-wrap">
- ${hasCover ? coverImg : '<span class="placeholder">📁</span>'}
- <div class="card-actions">
- <button class="icon-btn btn-sm" data-action="regen-folder-cover" title="Regenerate cover">🔄</button>
- </div>
- </div>
- <div class="meta">
- <div class="title">${escapeHtml(name)}</div>
- <div class="subtitle">Folder</div>
- </div>
- </div>
- `;
-}
-
-function renderGrid(items) {
- const grid = document.getElementById('media-grid');
- if (!grid) return;
- if (!items.length) { grid.innerHTML = `<p class="text-muted text-sm grid-full">No results.</p>`; clearSelection(); return; }
- grid.innerHTML = items.map((m, i) => renderItem(m, i)).join('');
- clearSelection();
- grid.querySelectorAll('.media-card, .media-row').forEach((el) => {
- el.addEventListener('click', () => { selectByElement(el); });
- const playBtn = el.querySelector('[data-action="play"]');
- const viewBtn = el.querySelector('[data-action="view"]');
- const favBtn = el.querySelector('[data-action="favorite"]');
- const noteBtn = el.querySelector('[data-action="notes"]');
- const downloadBtn = el.querySelector('[data-action="download"]');
- const tagBtn = el.querySelector('[data-action="tags"]');
- const thumbBtn = el.querySelector('[data-action="regen-thumb"]');
- playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); });
- viewBtn?.addEventListener('click', (e) => {
- e.stopPropagation();
- selectByElement(el);
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media) openLightbox(state.media, media.id);
- });
- favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); });
- noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); });
- downloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); window.open(`/api/media/${el.dataset.id}/download`, '_blank'); });
- tagBtn?.addEventListener('click', (e) => { e.stopPropagation(); openTagsForElement(el); });
- thumbBtn?.addEventListener('click', (e) => { e.stopPropagation(); regenThumb(el.dataset.id); });
- });
-}
-
-function renderItem(m, index) {
- const sizeText = fmtSize(m.file_size_bytes);
- 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)}${sizeText ? ' • ' + sizeText : ''}</span>
- <div class="card-actions">
- <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>
- <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button>
- <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button>
- <button class="icon-btn btn-sm" data-action="regen-thumb" title="Regenerate thumbnail">🔄</button>
- </div>
- </div>
- <div class="meta">
- <div class="title">${escapeHtml(m.file_name)}</div>
- <div class="subtitle">${escapeHtml(m.codec || '')} ${escapeHtml(m.resolution || '')} ${m.bitrate ? Math.round(m.bitrate / 1000) + 'kbps' : ''}</div>
- </div>
- </div>
- `;
- }
- if (m.type === 'image') {
- const resText = escapeHtml(m.resolution || '');
- const safeSizeText = escapeHtml(sizeText || '');
- return `
- <div class="media-card image-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">${resText}${safeSizeText ? ' • ' + safeSizeText : ''}</span>
- <div class="card-actions">
- <button class="icon-btn btn-sm" data-action="view" title="View">👁</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>
- <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button>
- <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button>
- <button class="icon-btn btn-sm" data-action="regen-thumb" title="Regenerate thumbnail">🔄</button>
- </div>
- </div>
- <div class="meta">
- <div class="title">${escapeHtml(m.file_name)}</div>
- <div class="subtitle">${resText} ${safeSizeText}</div>
- </div>
- </div>
- `;
- }
- return `
- <div class="media-card audio-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 cover</span>`}
- <span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span>
- <div class="card-actions">
- <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>
- <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button>
- <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button>
- </div>
- </div>
- <div class="meta">
- <div class="title">${escapeHtml(m.file_name)}</div>
- <div class="subtitle">${escapeHtml(m.codec || '')} ${m.bitrate ? Math.round(m.bitrate/1000)+'kbps' : ''}</div>
- </div>
- </div>
- `;
-}
-
-async function playSelected() {
- const el = currentElement();
- if (!el) return;
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (!media) return;
- try {
- const detail = await API.mediaDetail(media.id);
- const resumeFrom = detail?.progress?.position_seconds ?? 0;
- selectAndPlay(media, idx, resumeFrom);
- } catch {
- selectAndPlay(media, idx, 0);
- }
-}
-
-async function playRandom() {
- if (!state.media.length) return;
- const idx = Math.floor(Math.random() * state.media.length);
- const media = state.media[idx];
- if (!media) return;
- try {
- const detail = await API.mediaDetail(media.id);
- const resumeFrom = detail?.progress?.position_seconds ?? 0;
- selectAndPlay(media, idx, resumeFrom);
- } catch {
- selectAndPlay(media, idx, 0);
- }
-}
-
-function visiblePlayableCards() {
- return Array.from(document.querySelectorAll('#media-grid .media-card[data-id], #media-grid .media-row[data-id]'));
-}
-
-function selectedPlayableIndex(cards, preferCurrent) {
- const currentID = String(currentMediaId() || '');
- if (preferCurrent && currentID) {
- const idx = cards.findIndex((el) => el.dataset.id === currentID);
- if (idx >= 0) return idx;
- }
- const selected = currentElement();
- const idx = selected ? cards.indexOf(selected) : -1;
- return idx >= 0 ? idx : -1;
-}
-
-async function navigatePlayable(delta, options = {}) {
- const cards = visiblePlayableCards();
- const active = options.forcePlay || isPlaybackActive();
- const base = selectedPlayableIndex(cards, active);
-
- if (cards.length && !(active && base < 0)) {
- const atForwardEdge = delta > 0 && base === cards.length - 1;
- const atBackwardEdge = delta < 0 && base === 0;
- if (!((atForwardEdge || atBackwardEdge) && canTraverseBrowseFolders())) {
- const nextIdx = base >= 0
- ? (base + delta + cards.length) % cards.length
- : (delta > 0 ? 0 : cards.length - 1);
- const target = cards[nextIdx];
- selectByElement(target);
- if (active) await playSelected();
- return;
- }
- }
-
- const crossFolderTarget = await findCrossFolderPlayable(delta, active);
- if (!crossFolderTarget) return;
- await openPlayableTarget(crossFolderTarget, active);
-}
-
-function canTraverseBrowseFolders() {
- return !!state.selectedSetId && state.selectedSetIds.length === 1 && !isShuffle() && !hasActiveFilters();
-}
-
-function hasActiveFilters() {
- return !!(
- state.filters.search ||
- state.filters.type ||
- state.filters.favorites ||
- state.filters.tags ||
- state.filters.minDuration ||
- state.filters.maxDuration ||
- state.filters.minFileSize ||
- state.filters.maxFileSize ||
- state.filters.sort
- );
-}
-
-async function findCrossFolderPlayable(delta, preferCurrent) {
- if (!canTraverseBrowseFolders()) return null;
- const current = currentPlaybackCandidate(preferCurrent);
- if (!current) return null;
- const currentPath = current.browse_path ?? state.folderPath ?? '';
- const currentData = await browsePath(currentPath);
- const media = mediaWithBrowsePath(currentData.media || [], currentPath);
- const idx = media.findIndex((m) => String(m.id) === String(current.id));
-
- if (idx >= 0) {
- const nextInFolder = idx + delta;
- if (nextInFolder >= 0 && nextInFolder < media.length) {
- return { path: currentPath, media: media[nextInFolder] };
- }
- }
-
- return delta > 0
- ? findAfterFolder(currentPath)
- : findBeforeFolder(currentPath);
-}
-
-function currentPlaybackCandidate(preferCurrent) {
- const playing = currentMediaInfo();
- if (preferCurrent && playing) return playing;
- const selected = currentElement();
- if (selected?.dataset?.id) {
- const selectedMedia = state.media.find((m) => String(m.id) === selected.dataset.id);
- if (selectedMedia) return selectedMedia;
- }
- return playing;
-}
-
-async function findAfterFolder(path) {
- if (!path) return firstPlayableInFolder('');
- let childPath = normalizePath(path);
- while (childPath) {
- const parent = parentPath(childPath);
- const name = baseName(childPath);
- const data = await browsePath(parent);
- const items = browseItems(data, parent);
- const idx = items.findIndex((item) => item.type === 'folder' && item.name === name);
- for (const item of items.slice(idx + 1)) {
- const target = item.type === 'media'
- ? { path: parent, media: item.media }
- : await firstPlayableInFolder(joinPath(parent, item.name));
- if (target) return target;
- }
- childPath = parent;
- }
- return firstPlayableInFolder('');
-}
-
-async function findBeforeFolder(path) {
- if (!path) return lastPlayableInFolder('');
- let childPath = normalizePath(path);
- while (childPath) {
- const parent = parentPath(childPath);
- const name = baseName(childPath);
- const data = await browsePath(parent);
- const items = browseItems(data, parent);
- const idx = items.findIndex((item) => item.type === 'folder' && item.name === name);
- for (const item of items.slice(0, Math.max(0, idx)).reverse()) {
- const target = item.type === 'media'
- ? { path: parent, media: item.media }
- : await lastPlayableInFolder(joinPath(parent, item.name));
- if (target) return target;
- }
- childPath = parent;
- }
- return lastPlayableInFolder('');
-}
-
-async function firstPlayableInFolder(path) {
- const data = await browsePath(path);
- for (const folder of data.folders || []) {