diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/js/app.js | 1362 | ||||
| -rw-r--r-- | web/js/dom.js | 47 | ||||
| -rw-r--r-- | web/js/views/admin-status.js | 39 | ||||
| -rw-r--r-- | web/js/views/help.js | 33 | ||||
| -rw-r--r-- | web/js/views/media-actions.js | 72 | ||||
| -rw-r--r-- | web/js/views/media-grid.js | 334 | ||||
| -rw-r--r-- | web/js/views/media-info.js | 121 | ||||
| -rw-r--r-- | web/js/views/playback-nav.js | 247 | ||||
| -rw-r--r-- | web/js/views/sets.js | 93 | ||||
| -rw-r--r-- | web/js/views/shares.js | 130 | ||||
| -rw-r--r-- | web/js/views/tags.js | 79 | ||||
| -rw-r--r-- | web/js/views/upload.js | 47 | ||||
| -rw-r--r-- | web/sw.js | 14 |
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 || []) { |
