import { API } from './api.js'; import { initKeyboard } from './keyboard.js'; import { initSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js'; import { initPlayer, togglePlay, toggleFullscreen, toggleStage, toggleDetach, exitFullscreenIfNeeded, currentMediaId, selectAndPlay, playPrevious, playNext } from './player.js'; import { initSearch, focusSearch, trigger as triggerSearch } from './search.js'; import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js'; import { initThemes } from './themes.js'; import { initNotes, open as openNotes } from './notes.js'; import { initAdmin } from './admin.js'; import { state, setMedia } from './state.js'; import { initPWA } from './pwa.js'; const pageMap = { '/index.html': 'spa', '/login.html': 'login', '/bootstrap.html': 'bootstrap' }; let scanProgressTimer = null; main(); function main() { const page = pageMap[location.pathname] || 'spa'; if (page === 'login') initLogin(); else if (page === 'bootstrap') initBootstrap(); else initApp(); initThemes(); } // Login page function initLogin() { const form = document.getElementById('login-form'); const err = document.getElementById('login-error'); if (!form) return; form.addEventListener('submit', async (e) => { e.preventDefault(); err.textContent = ''; const fd = new FormData(form); const username = fd.get('username'); const password = fd.get('password'); if (!username || !password) { err.textContent = 'Please fill in all fields.'; return; } try { await API.login(username, password); location.href = '/'; } catch (ex) { err.textContent = ex.message || 'Login failed'; } }); } // Bootstrap page function initBootstrap() { const form = document.getElementById('bootstrap-form'); const err = document.getElementById('bootstrap-error'); if (!form) return; form.addEventListener('submit', async (e) => { e.preventDefault(); err.textContent = ''; const fd = new FormData(form); const username = fd.get('username'); const password = fd.get('password'); const confirm = fd.get('password-confirm'); if (!username || !password || !confirm) { err.textContent = 'All fields are required.'; return; } if (password !== confirm) { err.textContent = 'Passwords do not match.'; return; } try { await API.bootstrap(username, password); location.href = '/login.html'; } catch (ex) { err.textContent = ex.message || 'Bootstrap failed'; } }); } // SPA async function initApp() { initPlayer(); initSelection(); initSearch({ onChange: (q) => { state.filters.search = q; state.folderPath = ''; loadMedia(); }, input: document.getElementById('search-input'), clearBtn: document.getElementById('search-clear'), }); initShuffle({ onChange: () => loadMedia(), }); initKeyboard({ navUp: () => navUp(), navDown: () => navDown(), navLeft: () => navLeft(), navRight: () => navRight(), nextSet: () => setSetByDelta(1), prevSet: () => setSetByDelta(-1), isSidebarOpen: () => document.getElementById('sidebar')?.classList.contains('open'), isSidebarFocused: () => { const sidebar = document.getElementById('sidebar'); return sidebar?.contains(document.activeElement); }, toggleSetSelect: () => toggleSetSelection(), enter: () => { const el = currentElement(); if (!el) return; selectByElement(el); if (el.classList.contains('folder-card')) { enterFolder(el.dataset.name); } else { playSelected(); } }, playPause: () => togglePlay(), fullscreen: () => toggleFullscreen(), toggleStage: () => toggleStage(), escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); }, shuffle: () => { toggleShuffle(); loadMedia(); }, share: () => shareSelected(), search: () => showSearch(), notes: openNotesForSelected, tags: openTagsForSelected, toggleDetach: () => toggleDetach(), download: downloadSelected, help: toggleHelp, toolbar: toggleToolbar, backspace: () => { navigateBack(); }, sidebar: toggleSidebar, upload: () => showUpload(), sharesToggle: toggleShares, isSharesOpen: () => document.getElementById('shares-modal')?.classList.contains('open'), sharesNavUp: () => sharesNav(-1), sharesNavDown: () => sharesNav(1), sharesCopy: copySelectedShare, sharesDelete: deleteSelectedShare, focusMinDuration: () => focusFilter('filter-min-duration'), focusMaxDuration: () => focusFilter('filter-max-duration'), }); initNotes(() => toast('Note saved')); initAdmin(); initPWA(); initUpload(); initHelp(); initShares(); // Filters document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; state.folderPath = ''; loadMedia(); }); document.getElementById('filter-favorites')?.addEventListener('click', (e) => { state.filters.favorites = !state.filters.favorites; e.target.classList.toggle('active', state.filters.favorites); state.folderPath = ''; loadMedia(); }); document.getElementById('filter-tags')?.addEventListener('change', (e) => { state.filters.tags = e.target.value; state.folderPath = ''; loadMedia(); }); document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; state.folderPath = ''; loadMedia(); }); document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; state.folderPath = ''; loadMedia(); }); document.getElementById('filter-toggle')?.addEventListener('click', () => { document.getElementById('filter-advanced')?.classList.toggle('hidden'); }); // Sidebar close 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 document.getElementById('media-grid')?.addEventListener('dblclick', (e) => { const el = e.target.closest('.media-card, .media-row'); if (el) { selectByElement(el); 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) => `
Loading...
'; 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 && !state.filters.search && !state.filters.type && !state.filters.favorites && !state.filters.tags && !state.filters.minDuration && !state.filters.maxDuration) { const data = await API.browse(singleSetId, state.folderPath); updateBreadcrumb(data.current_path); setMedia(data.media || []); renderBrowse(data); const total = (data.media?.length || 0) + (data.folders?.length || 0); resultCount.textContent = `${total} items`; } else { breadcrumb?.classList.add('hidden'); 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) : '', sort: isShuffle() ? 'random' : 'name', limit: '200', }; 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 = ``; } } 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 = ``; let accumulated = ''; for (const part of parts) { accumulated = accumulated ? `${accumulated}/${part}` : part; html += ` `; } 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 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 = data.media || []; if (!folders.length && !media.length) { grid.innerHTML = 'Folder is empty.
'; return; } const folderHtml = folders.map((f, i) => renderFolder(f, i)).join(''); const mediaHtml = media.map((m, i) => renderItem(m, i + folders.length)).join(''); grid.innerHTML = folderHtml + mediaHtml; 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'); loadMedia(); } 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 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(); }); 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 renderFolder(folder, index) { const name = typeof folder === 'string' ? folder : folder.name; const hasCover = typeof folder === 'object' && folder?.has_cover; const coverImg = hasCover ? `No results.
`; return; } grid.innerHTML = items.map((m, i) => renderItem(m, i)).join(''); grid.querySelectorAll('.media-card, .media-row').forEach((el) => { el.addEventListener('click', () => { selectByElement(el); }); const playBtn = el.querySelector('[data-action="play"]'); const favBtn = el.querySelector('[data-action="favorite"]'); const noteBtn = el.querySelector('[data-action="notes"]'); 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(); }); 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 `No share links yet.
'; return; } const now = new Date(); el.innerHTML = shares.map((sh, i) => { const expires = new Date(sh.expires_at); const expired = expires < now; const url = `${location.origin}/s/${sh.token}`; return ` `; }).join(''); state.sharesCurrentRow = 0; updateSharesSelection(); el.querySelectorAll('[data-copy]').forEach((b) => { b.addEventListener('click', () => { navigator.clipboard?.writeText(b.dataset.copy); toast('Link copied'); }); }); el.querySelectorAll('[data-revoke]').forEach((b) => { b.addEventListener('click', async () => { try { await API.revokeShare(b.dataset.revoke); toast('Share revoked'); const shares = await API.myShares(); renderSharesList(shares || []); } catch (err) { toast(err.message || 'Revoke failed', 'error'); } }); }); } function updateSharesSelection() { const rows = document.querySelectorAll('#shares-list .share-row'); rows.forEach((r, i) => { r.classList.toggle('selected', i === state.sharesCurrentRow); if (i === state.sharesCurrentRow) r.focus(); }); } function sharesNav(delta) { const rows = document.querySelectorAll('#shares-list .share-row'); if (!rows.length) return; state.sharesCurrentRow = Math.max(0, Math.min(rows.length - 1, state.sharesCurrentRow + delta)); updateSharesSelection(); } function copySelectedShare() { const rows = document.querySelectorAll('#shares-list .share-row'); const row = rows[state.sharesCurrentRow]; if (!row) return; const copyBtn = row.querySelector('[data-copy]'); if (!copyBtn) { toast('Nothing to copy'); return; } navigator.clipboard?.writeText(copyBtn.dataset.copy).then(() => toast('Share link copied')); } async function deleteSelectedShare() { const rows = document.querySelectorAll('#shares-list .share-row'); const row = rows[state.sharesCurrentRow]; if (!row) { toast('No share selected'); return; } const revokeBtn = row.querySelector('[data-revoke]'); if (!revokeBtn) return; try { await API.revokeShare(revokeBtn.dataset.revoke); toast('Share revoked'); const shares = await API.myShares(); renderSharesList(shares || []); } catch (err) { toast(err.message || 'Revoke failed', 'error'); } } function fmtDate(d) { if (!d) return ''; const dt = typeof d === 'string' ? new Date(d) : d; return dt.toLocaleDateString(); } function fmtDur(s) { if (!s || s < 0) return '0:00'; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); const mm = String(m).padStart(2, '0'); const ss = String(sec).padStart(2, '0'); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } function fmtSize(bytes) { if (!bytes || bytes <= 0) return ''; const kb = bytes / 1024; if (kb < 1024) return Math.round(kb) + ' KB'; const mb = kb / 1024; if (mb < 1024) return Math.round(mb * 10) / 10 + ' MB'; return Math.round((mb / 1024) * 10) / 10 + ' GB'; } function escapeHtml(s) { return (s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function toast(msg, type = 'info') { const t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.className = 'toast show ' + type; setTimeout(() => t.classList.remove('show'), 2800); }