/* Application bootstrap for the static, client-side metadata browser. - Downloads a zipped SQLite DB (db.zip) with progress and unzips it in-memory - Caches the uncompressed DB bytes in IndexedDB for reuse on next load - Loads sql.js (WASM) and opens the database from the cached bytes - Swaps interchangeable UX elements (views) in a viewport, with fade transitions Assumptions: - A ZIP archive is hosted at `/db.zip` containing a single `.sqlite` file. - We use `fflate` (UMD) from a CDN to unzip after download (keeps implementation minimal). - We use `sql.js` from a CDN; the WASM is located via `locateFile` (see initSql()). - We store raw DB bytes in IndexedDB (localStorage is too small for large DBs). */ (function () { // Use a relative URL so the app works when hosted under a subdirectory. const DB_ZIP_URL = './db.zip'; const IDB_NAME = 'mp3com-meta-browser'; const IDB_STORE = 'files'; const IDB_KEY = 'mp3com-db-bytes-v1'; // bump if format changes // Viewport layers const $uxRoot = document.getElementById('ux-root'); const $uxOverlays = document.getElementById('ux-overlays'); // Fade helpers function fadeIn(el) { return new Promise((resolve) => { el.classList.add('fade'); el.classList.add('fade-hidden'); requestAnimationFrame(() => { el.classList.add('fade-visible'); el.classList.remove('fade-hidden'); const onEnd = (e) => { if (e.propertyName === 'opacity') { el.removeEventListener('transitionend', onEnd); resolve(); } }; el.addEventListener('transitionend', onEnd); }); }); } function fadeOut(el) { return new Promise((resolve) => { el.classList.add('fade'); el.classList.remove('fade-visible'); el.classList.add('fade-hidden'); const onEnd = (e) => { if (e.propertyName === 'opacity') { el.removeEventListener('transitionend', onEnd); resolve(); } }; el.addEventListener('transitionend', onEnd); }); } // UX Manager const UX = (() => { let currentBase = null; // { view, el } const overlayStack = []; // [{ view, el, lastFocus }] function ensureViewEl(view) { if (!view || !view.el) throw new Error('Invalid view'); view.el.classList.add('ux-view'); return view.el; } async function replace(view) { const el = ensureViewEl(view); if (currentBase) { await fadeOut(currentBase.el); $uxRoot.removeChild(currentBase.el); if (typeof currentBase.view.destroy === 'function') currentBase.view.destroy(); } $uxRoot.appendChild(el); await fadeIn(el); currentBase = { view, el }; if (typeof view.onShow === 'function') view.onShow(); } async function openOverlay(view) { const el = ensureViewEl(view); el.classList.add('ux-view--overlay'); const lastFocus = document.activeElement; $uxOverlays.appendChild(el); await fadeIn(el); overlayStack.push({ view, el, lastFocus }); $uxRoot.setAttribute('aria-hidden', 'true'); if (typeof view.onShow === 'function') view.onShow(); } async function closeTop() { const top = overlayStack.pop(); if (!top) return; const { view, el, lastFocus } = top; await fadeOut(el); if (el.parentNode) el.parentNode.removeChild(el); if (typeof view.destroy === 'function') view.destroy(); if (overlayStack.length === 0) $uxRoot.removeAttribute('aria-hidden'); if (lastFocus && typeof lastFocus.focus === 'function') lastFocus.focus(); } window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && overlayStack.length) { e.preventDefault(); closeTop(); } }); return { replace, openOverlay, closeTop }; })(); // --- IndexedDB helpers (minimal, no external deps) --- function idbOpen() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function idbGet(key) { const db = await idbOpen(); return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(key); req.onsuccess = () => resolve(req.result ?? null); req.onerror = () => reject(req.error); }); } async function idbSet(key, value) { const db = await idbOpen(); return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readwrite'); const req = tx.objectStore(IDB_STORE).put(value, key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } // --- Download and unzip db.zip --- let loader; // set after factory creation async function fetchZipWithProgress(url) { loader.setStep('Downloading database…'); loader.setDetail('Starting download'); const resp = await fetch(url, { cache: 'force-cache' }); if (!resp.ok || !resp.body) throw new Error('Failed to fetch DB archive'); const total = parseInt(resp.headers.get('content-length') || '0', 10) || 0; const reader = resp.body.getReader(); const chunks = []; let received = 0; loader.setProgress(0, total || undefined); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.byteLength; loader.setProgress(received, total || undefined); if (total) loader.setDetail(`${((received / total) * 100).toFixed(1)}% • ${formatBytes(received)} / ${formatBytes(total)}`); else loader.setDetail(`${formatBytes(received)} downloaded`); } const zipBytes = concatUint8(chunks, received); return zipBytes; } function concatUint8(chunks, totalLen) { const out = new Uint8Array(totalLen); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.byteLength; } return out; } function formatBytes(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } async function unzipSqlite(zipBytes) { loader.setStep('Unpacking database…'); loader.setDetail('Decompressing ZIP'); const { unzipSync } = window.fflate || {}; if (!unzipSync) throw new Error('Unzip library not loaded'); const files = unzipSync(zipBytes); const names = Object.keys(files); if (!names.length) throw new Error('Empty ZIP archive'); let choice = names.find((n) => /\.sqlite$/i.test(n)) || names[0]; const dbBytes = files[choice]; loader.setDetail(`Unpacked ${choice} • ${formatBytes(dbBytes.byteLength)}`); loader.setProgress(100, 100); return dbBytes; } // --- SQL.js init --- async function initSql() { if (typeof initSqlJs !== 'function') throw new Error('sql.js not loaded'); loader.setStep('Initializing SQLite…'); loader.setDetail('Loading WASM'); const SQL = await initSqlJs({ locateFile: (file) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}`, }); return SQL; } // --- Templates & Views --- function instantiateTemplate(id) { const tpl = document.getElementById(id); if (!tpl || !('content' in tpl)) throw new Error(`Missing template: ${id}`); const frag = tpl.content.cloneNode(true); const root = frag.firstElementChild; return root; } function createTableRenderer({ columns, emptyMessage = 'No results', getRowId }) { if (!Array.isArray(columns) || columns.length === 0) throw new Error('columns required'); let currentEmpty = emptyMessage; const table = document.createElement('table'); table.className = 'table is-fullwidth is-striped is-hoverable'; const thead = document.createElement('thead'); const headRow = document.createElement('tr'); columns.forEach((column) => { const th = document.createElement('th'); th.scope = 'col'; th.textContent = column.header || ''; if (column.headerTitle) th.title = column.headerTitle; headRow.appendChild(th); }); thead.appendChild(headRow); const tbody = document.createElement('tbody'); table.appendChild(thead); table.appendChild(tbody); function renderEmpty(message) { tbody.innerHTML = ''; const tr = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = columns.length; td.className = 'has-text-centered has-text-grey'; td.textContent = message; tr.appendChild(td); tbody.appendChild(tr); tbody.dataset.state = 'empty'; } function renderRows(rows) { tbody.innerHTML = ''; if (!rows || rows.length === 0) { renderEmpty(currentEmpty); return; } delete tbody.dataset.state; rows.forEach((row) => { const tr = document.createElement('tr'); if (typeof getRowId === 'function') { const rowId = getRowId(row); if (rowId !== undefined && rowId !== null) tr.dataset.rowId = String(rowId); } columns.forEach((column) => { const td = document.createElement('td'); if (column.className) td.className = column.className; let value; if (typeof column.render === 'function') value = column.render(row); else if ('key' in column) value = row[column.key]; else value = ''; if (value instanceof Node) td.appendChild(value); else { const text = value === undefined || value === null ? '' : String(value); td.innerHTML = escapeHtml(text); } tr.appendChild(td); }); tbody.appendChild(tr); }); } renderEmpty(currentEmpty); return { el: table, setRows(rows) { renderRows(rows); }, setEmptyMessage(message) { currentEmpty = message; if (tbody.dataset.state === 'empty') renderEmpty(currentEmpty); }, clear() { renderEmpty(currentEmpty); }, }; } let paginationIdCounter = 0; function createPagination({ pageSizes = [10, 25, 50, 100], initialPageSize, onChange, } = {}) { if (!pageSizes.length) pageSizes = [25]; const defaultSize = initialPageSize && pageSizes.includes(initialPageSize) ? initialPageSize : pageSizes[0]; const selectId = `pagination-select-${++paginationIdCounter}`; const state = { page: 1, pageSize: defaultSize, total: 0, resultsCount: 0, disabled: true, }; const wrapper = document.createElement('div'); wrapper.className = 'mt-4'; wrapper.setAttribute('hidden', ''); const level = document.createElement('div'); level.className = 'level is-mobile'; wrapper.appendChild(level); const levelLeft = document.createElement('div'); levelLeft.className = 'level-left'; level.appendChild(levelLeft); const levelLeftItem = document.createElement('div'); levelLeftItem.className = 'level-item'; levelLeft.appendChild(levelLeftItem); const nav = document.createElement('nav'); nav.className = 'pagination is-small'; nav.setAttribute('role', 'navigation'); nav.setAttribute('aria-label', 'Pagination'); const prevBtn = document.createElement('button'); prevBtn.type = 'button'; prevBtn.className = 'pagination-previous'; prevBtn.textContent = 'Previous'; nav.appendChild(prevBtn); const nextBtn = document.createElement('button'); nextBtn.type = 'button'; nextBtn.className = 'pagination-next'; nextBtn.textContent = 'Next'; nav.appendChild(nextBtn); levelLeftItem.appendChild(nav); const levelRight = document.createElement('div'); levelRight.className = 'level-right'; level.appendChild(levelRight); const levelRightItem = document.createElement('div'); levelRightItem.className = 'level-item'; levelRight.appendChild(levelRightItem); const sizeField = document.createElement('div'); sizeField.className = 'field is-grouped is-align-items-center mb-0'; const sizeLabel = document.createElement('label'); sizeLabel.className = 'label is-size-7 mb-0 mr-2'; sizeLabel.setAttribute('for', selectId); sizeLabel.textContent = 'Rows per page'; sizeField.appendChild(sizeLabel); const sizeControl = document.createElement('div'); sizeControl.className = 'control'; const selectWrapper = document.createElement('div'); selectWrapper.className = 'select is-small'; const sizeSelect = document.createElement('select'); sizeSelect.id = selectId; pageSizes.forEach((size) => { const opt = document.createElement('option'); opt.value = String(size); opt.textContent = String(size); sizeSelect.appendChild(opt); }); sizeSelect.value = String(state.pageSize); selectWrapper.appendChild(sizeSelect); sizeControl.appendChild(selectWrapper); sizeField.appendChild(sizeControl); levelRightItem.appendChild(sizeField); const meta = document.createElement('p'); meta.className = 'is-size-7 has-text-grey mt-2'; meta.textContent = ''; wrapper.appendChild(meta); function emitChange() { if (typeof onChange === 'function') { onChange({ page: state.page, pageSize: state.pageSize }); } } function updateControls() { if (state.disabled) { wrapper.setAttribute('hidden', ''); } else { wrapper.removeAttribute('hidden'); } const atFirst = state.page <= 1; const hasTotal = Number.isFinite(state.total) && state.total >= 0; const hasResults = state.resultsCount > 0; const maxKnown = hasTotal ? state.page * state.pageSize >= state.total : state.resultsCount < state.pageSize; prevBtn.disabled = state.disabled || atFirst; nextBtn.disabled = state.disabled || maxKnown; sizeSelect.disabled = state.disabled; if (state.disabled) { meta.textContent = ''; return; } if (hasTotal) { if (state.total === 0) { meta.textContent = 'No results'; return; } const start = (state.page - 1) * state.pageSize + 1; const end = Math.min(state.total, start + state.resultsCount - 1); meta.textContent = `${start.toLocaleString()}–${end.toLocaleString()} of ${state.total.toLocaleString()} results`; } else if (hasResults) { const start = (state.page - 1) * state.pageSize + 1; const end = start + state.resultsCount - 1; meta.textContent = `${start.toLocaleString()}–${end.toLocaleString()} results`; } else { meta.textContent = 'No results'; } } prevBtn.addEventListener('click', () => { if (state.disabled || state.page <= 1) return; state.page -= 1; updateControls(); emitChange(); }); nextBtn.addEventListener('click', () => { if (state.disabled) return; const hasTotal = Number.isFinite(state.total) && state.total >= 0; if (hasTotal && state.page * state.pageSize >= state.total) return; if (!hasTotal && state.resultsCount < state.pageSize) return; state.page += 1; updateControls(); emitChange(); }); sizeSelect.addEventListener('change', () => { const nextSize = parseInt(sizeSelect.value, 10); if (!Number.isFinite(nextSize) || nextSize <= 0) return; if (nextSize === state.pageSize) return; state.pageSize = nextSize; state.page = 1; updateControls(); emitChange(); }); updateControls(); return { el: wrapper, get page() { return state.page; }, get pageSize() { return state.pageSize; }, setDisabled(disabled) { state.disabled = Boolean(disabled); updateControls(); }, setState({ page, pageSize, total, resultsCount }) { if (Number.isFinite(page) && page >= 1) state.page = page; if (Number.isFinite(pageSize) && pageSize > 0) { state.pageSize = pageSize; sizeSelect.value = String(state.pageSize); } if (Number.isFinite(total) && total >= 0) state.total = total; else state.total = NaN; if (Number.isFinite(resultsCount) && resultsCount >= 0) state.resultsCount = resultsCount; else state.resultsCount = 0; state.disabled = false; updateControls(); }, }; } function debounce(fn, wait) { let timer = null; return function debounced(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; fn.apply(this, args); }, wait); }; } function createLoaderView() { const el = instantiateTemplate('tpl-loader'); const $step = el.querySelector('[data-ref="step"]'); const $detail = el.querySelector('[data-ref="detail"]'); const $progress = el.querySelector('[data-ref="progress"]'); return { kind: 'base', el, setStep(text) { $step.textContent = text; }, setDetail(text) { $detail.textContent = text; }, setProgress(value, max) { if (typeof max === 'number' && max > 0) { $progress.max = max; $progress.value = value; const pct = Math.floor((value / max) * 100); $progress.textContent = pct + '%'; } else { $progress.removeAttribute('max'); $progress.value = 0; $progress.textContent = '…'; } }, destroy() {}, }; } function createSearchView(db) { const el = instantiateTemplate('tpl-search'); const $form = el.querySelector('[data-ref="form"]'); const $q = el.querySelector('[data-ref="q"]'); const $results = el.querySelector('[data-ref="results"]'); const $status = document.createElement('p'); $status.className = 'has-text-grey'; $results.innerHTML = ''; $results.appendChild($status); const columns = [ { header: 'Artist', key: 'artist' }, { header: 'Title', key: 'title' }, { header: 'Album', key: 'album' }, { header: 'Year', key: 'year', className: 'has-text-right' }, { header: 'Genre', key: 'genre' }, ]; const table = createTableRenderer({ columns, emptyMessage: 'No matches found', getRowId: (row) => row.id, }); table.el.hidden = true; $results.appendChild(table.el); const pagination = createPagination({ onChange: ({ page, pageSize }) => { state.page = page; state.pageSize = pageSize; runSearch(); }, }); $results.appendChild(pagination.el); const state = { query: '', page: 1, pageSize: pagination.pageSize, sort: 'rank', }; const searchSql = { rank: ` SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre FROM fts_tracks f JOIN tracks t ON t.id = f.rowid JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE f MATCH ? ORDER BY rank LIMIT ? OFFSET ? `, alpha: ` SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre FROM fts_tracks f JOIN tracks t ON t.id = f.rowid JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE f MATCH ? ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0) LIMIT ? OFFSET ? `, }; const countSql = ` SELECT COUNT(*) AS count FROM fts_tracks f JOIN tracks t ON t.id = f.rowid JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE f MATCH ? `; function setStatus(text) { $status.textContent = text; $status.hidden = false; table.el.hidden = true; pagination.setDisabled(true); } function enableSearch() { $q.disabled = false; $q.removeAttribute('aria-disabled'); $q.focus(); } function runSearchImmediate() { const term = state.query.trim(); if (!term) { table.clear(); setStatus('Type to search…'); return; } $status.textContent = 'Searching…'; $status.hidden = false; table.el.hidden = true; pagination.setDisabled(true); const offset = (state.page - 1) * state.pageSize; let total = 0; let rows = []; try { const countStmt = db.prepare(countSql); countStmt.bind([term]); if (countStmt.step()) { const row = countStmt.getAsObject(); total = Number(row.count) || 0; } countStmt.free(); if (total === 0) { table.clear(); $status.textContent = 'No matches found'; return; } if (offset >= total) { state.page = Math.max(1, Math.ceil(total / state.pageSize)); } const searchStmt = db.prepare(searchSql[state.sort] || searchSql.rank); searchStmt.bind([term, state.pageSize, (state.page - 1) * state.pageSize]); const nextRows = []; while (searchStmt.step()) { nextRows.push(searchStmt.getAsObject()); } rows = nextRows; searchStmt.free(); } catch (err) { console.error(err); $status.textContent = 'Search failed. Check console for details.'; return; } table.setRows(rows); $status.hidden = true; table.el.hidden = false; pagination.setState({ page: state.page, pageSize: state.pageSize, total, resultsCount: rows.length, }); } const runSearch = debounce(runSearchImmediate, 250); $form.addEventListener('submit', (e) => e.preventDefault()); $q.addEventListener('input', () => { state.query = $q.value; state.page = 1; runSearch(); }); setStatus('Type to search…'); return { kind: 'base', el, onShow() { enableSearch(); if (state.query.trim()) runSearchImmediate(); }, destroy() {}, }; } // --- Main bootstrap --- loader = createLoaderView(); UX.replace(loader); (async function main() { try { loader.setStep('Checking cache…'); loader.setDetail('Looking for cached database'); let dbBytes = await idbGet(IDB_KEY); if (dbBytes) { loader.setStep('Using cached database'); loader.setDetail(`Found ${formatBytes(dbBytes.byteLength)} in IndexedDB`); loader.setProgress(100, 100); } else { const zipBytes = await fetchZipWithProgress(DB_ZIP_URL); dbBytes = await unzipSqlite(zipBytes); loader.setDetail('Caching database for future loads'); await idbSet(IDB_KEY, dbBytes); } const SQL = await initSql(); loader.setDetail('Opening database'); const db = new SQL.Database(dbBytes); const search = createSearchView(db); await UX.replace(search); window.__db = db; } catch (err) { console.error(err); try { loader.setStep('Initialization failed'); loader.setDetail(String(err && err.message ? err.message : err)); const pb = loader.el.querySelector('[data-ref="progress"]'); if (pb) { pb.classList.remove('is-primary'); pb.classList.add('is-danger'); } } catch (_) {} } })(); function escapeHtml(s) { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/\"/g, '"') .replace(/'/g, '''); } })();