/* 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 the locally bundled sql.js WASM build (FTS5-enabled) 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 ship the sql.js WASM runtime locally to guarantee FTS5 support. - 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, isHidden, isClosing }] function ensureViewEl(view) { if (!view || !view.el) throw new Error('Invalid view'); view.el.classList.add('ux-view'); if (view.kind) view.el.dataset.uxKind = view.kind; return view.el; } function assertKind(view, expected) { if (!view) throw new Error('Missing view'); if (view.kind && view.kind !== expected) { throw new Error(`Expected view kind "${expected}" but received "${view.kind}"`); } } async function closeAllOverlays({ restoreFocus = false } = {}) { while (overlayStack.length) { const shouldRestore = restoreFocus && overlayStack.length === 1; await closeTop({ restoreFocus: shouldRestore }); } } function syncOverlayVisibility() { const topIndex = overlayStack.length - 1; overlayStack.forEach((entry, idx) => { const { el, view } = entry; const shouldHide = idx !== topIndex; if (shouldHide) { if (!el.hasAttribute('hidden')) el.setAttribute('hidden', ''); el.setAttribute('aria-hidden', 'true'); el.setAttribute('inert', ''); el.inert = true; if (!entry.isHidden) { entry.isHidden = true; if (view && typeof view.onHide === 'function') view.onHide(); } } else { if (el.hasAttribute('hidden')) el.removeAttribute('hidden'); el.removeAttribute('aria-hidden'); el.removeAttribute('inert'); el.inert = false; entry.isHidden = false; } }); if (currentBase && currentBase.el) { if (overlayStack.length > 0) { currentBase.el.setAttribute('aria-hidden', 'true'); currentBase.el.setAttribute('inert', ''); currentBase.el.inert = true; } else { currentBase.el.removeAttribute('aria-hidden'); currentBase.el.removeAttribute('inert'); currentBase.el.inert = false; } } } async function replace(view) { assertKind(view, 'base'); await closeAllOverlays({ restoreFocus: false }); const el = ensureViewEl(view); const prev = currentBase; if (prev) { const prevEl = prev.el; if (prevEl && prevEl.isConnected) { await fadeOut(prevEl); if (prevEl.parentNode === $uxRoot) $uxRoot.removeChild(prevEl); } if (prev.view && typeof prev.view.destroy === 'function') prev.view.destroy(); } $uxRoot.appendChild(el); currentBase = { view, el }; syncOverlayVisibility(); await fadeIn(el); if (typeof view.onShow === 'function') view.onShow(); } async function openOverlay(view) { assertKind(view, 'overlay'); const el = ensureViewEl(view); el.classList.add('ux-view--overlay'); const lastFocus = document.activeElement; const prevEntry = overlayStack.length ? overlayStack[overlayStack.length - 1] : null; const entry = { view, el, lastFocus, isHidden: false, isClosing: false, prev: prevEntry }; overlayStack.push(entry); $uxOverlays.appendChild(el); syncOverlayVisibility(); await fadeIn(el); if (typeof view.onShow === 'function') view.onShow(); } async function closeTop({ restoreFocus = true } = {}) { const top = overlayStack[overlayStack.length - 1]; if (!top) return; if (top.isClosing) return; top.isClosing = true; const { view, el, lastFocus } = top; if (view && typeof view.onHide === 'function') view.onHide(); await fadeOut(el); if (el.parentNode) el.parentNode.removeChild(el); if (typeof view.destroy === 'function') view.destroy(); overlayStack.pop(); syncOverlayVisibility(); if (restoreFocus && lastFocus && typeof lastFocus.focus === 'function') { lastFocus.focus(); } } window.addEventListener('keydown', (e) => { if (Keyboard.isEscapeKey(e) && overlayStack.length) { e.preventDefault(); closeTop(); } }); return { replace, openOverlay, closeTop, closeAllOverlays }; })(); // --- 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]}`; } function formatNumber(value) { if (!Number.isFinite(value)) return '—'; return Number(value).toLocaleString(); } function formatDuration(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return '—'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60) .toString() .padStart(2, '0'); return `${mins}:${secs}`; } function formatBitrate(kbps) { if (!Number.isFinite(kbps) || kbps <= 0) return '—'; return `${kbps.toLocaleString()} kbps`; } function formatSamplerate(hz) { if (!Number.isFinite(hz) || hz <= 0) return '—'; return `${hz.toLocaleString()} Hz`; } function escapeSqlText(value) { return `'${String(value).replace(/'/g, "''")}'`; } function applyFtsMatch(sql, matchExpr) { const placeholder = 'MATCH ?'; if (!sql.includes(placeholder)) throw new Error('Expected FTS MATCH placeholder'); return sql.replace(placeholder, `MATCH ${escapeSqlText(matchExpr)}`); } 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; } // --- SQLite WASM init --- async function initSql() { if (typeof initSqlJs !== 'function') throw new Error('sql.js runtime not loaded'); loader.setStep('Initializing SQLite…'); loader.setDetail('Loading WASM'); const SQL = await initSqlJs({ locateFile: (file) => `./${file}`, }); return createSqlCompat(SQL); } function createSqlCompat(SQL) { class Statement { constructor(stmt) { this._stmt = stmt; this._columnNames = null; } bind(values) { if (values !== undefined) this._stmt.bind(values); return true; } step() { return !!this._stmt.step(); } getAsObject() { if (!this._columnNames) this._columnNames = this._stmt.getColumnNames(); const row = Object.create(null); const count = this._columnNames.length; for (let i = 0; i < count; i += 1) { row[this._columnNames[i]] = this._stmt.get(i); } return row; } free() { this._stmt.free(); } } class Database { constructor(bytes) { const source = bytes instanceof Uint8Array ? bytes : bytes ? new Uint8Array(bytes) : undefined; this._db = source ? new SQL.Database(source) : new SQL.Database(); } prepare(sql) { return new Statement(this._db.prepare(sql)); } exec(sql) { return this._db.exec(sql); } close() { this._db.close(); } } function ensureFts5Available(dbHandle) { let stmt; try { stmt = dbHandle.prepare('CREATE VIRTUAL TABLE temp.__fts5_check USING fts5(content)'); stmt.step(); } catch (err) { const reason = err && err.message ? err.message : String(err); throw new Error(`FTS5 verification failed (${reason}). Ensure the SQLite WASM build includes FTS5.`); } finally { try { if (stmt) stmt.free(); } catch (_) {} try { dbHandle.exec('DROP TABLE IF EXISTS temp.__fts5_check'); } catch (_) {} } } return { Database, ensureFts5Available }; } // --- 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, interactive = false, onRowRender, }) { 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); } if (interactive) { tr.tabIndex = 0; tr.classList.add('is-selectable-row'); } 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); }); if (typeof onRowRender === 'function') onRowRender(tr, row); 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); }, }; } const Keyboard = (() => { function isActivationKey(event) { return event && (event.key === 'Enter' || event.key === ' '); } function isEscapeKey(event) { return event && event.key === 'Escape'; } return { isActivationKey, isEscapeKey, }; })(); function bindRowActivation(tableEl, handler) { if (!tableEl || typeof handler !== 'function') return () => {}; function findRow(target) { let el = target; while (el && el !== tableEl) { if (el.dataset && el.dataset.rowId) return el; el = el.parentElement; } return null; } const onClick = (event) => { const row = findRow(event.target); if (!row) return; handler(row.dataset.rowId, event); }; const onKeyDown = (event) => { if (!Keyboard.isActivationKey(event)) return; const row = findRow(event.target); if (!row) return; event.preventDefault(); handler(row.dataset.rowId, event); }; tableEl.addEventListener('click', onClick); tableEl.addEventListener('keydown', onKeyDown); return () => { tableEl.removeEventListener('click', onClick); tableEl.removeEventListener('keydown', onKeyDown); }; } function createAsyncListState({ table, statusEl, pagination }) { if (!table || !table.el) throw new Error('table required'); if (!statusEl) throw new Error('statusEl required'); const baseClasses = statusEl.className; function applyTone(tone) { statusEl.className = baseClasses; if (tone === 'error') { statusEl.classList.remove('has-text-grey'); statusEl.classList.add('has-text-danger'); } else { if (baseClasses && baseClasses.includes('has-text-danger')) return; if (!statusEl.classList.contains('has-text-grey') && (!baseClasses || !baseClasses.includes('has-text-danger'))) { statusEl.classList.add('has-text-grey'); } statusEl.classList.remove('has-text-danger'); } } function showMessage(text, tone) { applyTone(tone); statusEl.textContent = text; statusEl.hidden = false; table.el.hidden = true; if (pagination) pagination.setDisabled(true); } function showRows({ rows, page, pageSize, total, resultsCount }) { applyTone(); table.setRows(rows); statusEl.hidden = true; table.el.hidden = false; if (pagination) { pagination.setState({ page, pageSize, total, resultsCount: resultsCount !== undefined ? resultsCount : rows.length, }); } } return { showIdle(text) { showMessage(text, 'default'); }, showLoading(text = 'Loading…') { showMessage(text, 'default'); }, showError(text) { showMessage(text, 'error'); }, showEmpty(text) { table.clear(); showMessage(text, 'default'); }, showRows, disablePagination() { if (pagination) pagination.setDisabled(true); }, }; } 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, { initialQuery = '' } = {}) { 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 $backBtn = el.querySelector('[data-action="back"]'); const $sortButtons = el.querySelector('[data-ref="sort-buttons"]'); 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, interactive: true, }); 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 listState = createAsyncListState({ table, statusEl: $status, pagination }); const state = { query: initialQuery, page: 1, pageSize: pagination.pageSize, sort: 'rank', }; const sortOptions = [ { key: 'rank', label: 'Relevance' }, { key: 'alpha', label: 'A–Z' }, ]; if (!$backBtn) throw new Error('Missing back button'); if (!$sortButtons) throw new Error('Missing sort container'); sortOptions.forEach((opt) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'button is-small'; btn.dataset.sort = opt.key; btn.textContent = opt.label; btn.addEventListener('click', () => { if (state.sort === opt.key) return; state.sort = opt.key; state.page = 1; updateSortButtons(); runSearchImmediate(); }); $sortButtons.appendChild(btn); }); function updateSortButtons() { [...$sortButtons.querySelectorAll('button')].forEach((btn) => { if (btn.dataset.sort === state.sort) btn.classList.add('is-link'); else btn.classList.remove('is-link'); }); } const searchSql = { rank: ` SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre FROM fts_tracks JOIN tracks t ON t.id = fts_tracks.rowid JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE fts_tracks 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 JOIN tracks t ON t.id = fts_tracks.rowid JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE fts_tracks 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 JOIN tracks t ON t.id = fts_tracks.rowid JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE fts_tracks MATCH ? `; function enableSearch() { $q.disabled = false; $q.removeAttribute('aria-disabled'); if (state.query) { $q.value = state.query; } $q.focus(); } function buildSearchMatchExpression(rawInput) { const trimmed = String(rawInput || '').trim(); if (!trimmed) return ''; // Preserve power user syntax (field scoping, phrase searches, boolean ops) if (/["':*()^+-]/.test(trimmed)) { return trimmed; } const tokens = trimmed .split(/\s+/) .map((token) => token.replace(/[^0-9a-z]/gi, '').toLowerCase()) .filter(Boolean); if (!tokens.length) return ''; return tokens.map((token) => `${token}*`).join(' '); } function runSearchImmediate() { const matchExpr = buildSearchMatchExpression(state.query); if (!matchExpr) { table.clear(); listState.showIdle('Type to search…'); return; } listState.showLoading('Searching…'); const offset = (state.page - 1) * state.pageSize; const effectiveSearchSql = searchSql[state.sort] || searchSql.rank; let total = 0; let rows = []; try { console.debug('[Search] Preparing count query', { sql: countSql.trim(), ftsExpr: matchExpr, chars: Array.from(matchExpr).map((ch) => ch.codePointAt(0)), }); const countStmt = db.prepare(applyFtsMatch(countSql, matchExpr)); if (countStmt.step()) { const row = countStmt.getAsObject(); total = Number(row.count) || 0; } countStmt.free(); if (total === 0) { table.clear(); listState.showEmpty('No matches found'); return; } if (offset >= total) { state.page = Math.max(1, Math.ceil(total / state.pageSize)); } const searchParams = [state.pageSize, (state.page - 1) * state.pageSize]; console.debug('[Search] Preparing row query', { sql: effectiveSearchSql.trim(), ftsExpr: matchExpr, params: searchParams, chars: Array.from(matchExpr).map((ch) => ch.codePointAt(0)), }); const searchStmt = db.prepare(applyFtsMatch(effectiveSearchSql, matchExpr)); searchStmt.bind(searchParams); const nextRows = []; while (searchStmt.step()) { nextRows.push(searchStmt.getAsObject()); } rows = nextRows; searchStmt.free(); } catch (err) { console.error(err); listState.showError('Search failed. Check console for details.'); return; } listState.showRows({ rows, total, page: state.page, pageSize: state.pageSize, }); } const runSearch = debounce(runSearchImmediate, 250); $form.addEventListener('submit', (e) => e.preventDefault()); $q.addEventListener('input', () => { state.query = $q.value; state.page = 1; runSearch(); }); $backBtn.addEventListener('click', () => navigateTo('nav')); const unbindRows = bindRowActivation(table.el, (rowId) => { if (!rowId) return; const trackId = Number(rowId); if (!Number.isFinite(trackId)) return; UX.openOverlay(createTrackOverlay(db, trackId)); }); updateSortButtons(); listState.showIdle('Type to search…'); return { kind: 'base', el, onShow() { enableSearch(); if (state.query.trim()) runSearchImmediate(); }, destroy() { unbindRows(); }, }; } function createNavView(db) { const el = instantiateTemplate('tpl-nav'); const $backTargets = Array.from(el.querySelectorAll('button[data-action]')); const actions = { search: () => navigateTo('search'), artists: () => navigateTo('browseArtists'), albums: () => navigateTo('browseAlbums'), years: () => navigateTo('browseYears'), genres: () => navigateTo('browseGenres'), stats: () => navigateTo('stats'), }; $backTargets.forEach((btn) => { const action = btn.dataset.action; if (actions[action]) { btn.addEventListener('click', () => actions[action]()); } btn.addEventListener('keydown', (event) => { let delta = 0; if (event.key === 'ArrowRight' || event.key === 'ArrowDown') delta = 1; else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') delta = -1; else return; event.preventDefault(); const index = $backTargets.indexOf(event.currentTarget); if (index === -1) return; const nextIndex = (index + delta + $backTargets.length) % $backTargets.length; $backTargets[nextIndex].focus(); }); }); return { kind: 'base', el, onShow() { if ($backTargets.length) $backTargets[0].focus(); }, destroy() {}, }; } function createBrowseArtistsView(db, { initialPrefix = null, initialFilter = '' } = {}) { const el = instantiateTemplate('tpl-browse-artists'); const $filter = el.querySelector('[data-ref="filter"]'); const $results = el.querySelector('[data-ref="results"]'); const $back = el.querySelector('[data-action="back"]'); const jumpButtons = Array.from(el.querySelectorAll('[data-action="jump"]')); const $status = document.createElement('p'); $status.className = 'has-text-grey'; $results.innerHTML = ''; $results.appendChild($status); const table = createTableRenderer({ columns: [{ header: 'Artist', key: 'name' }], emptyMessage: 'No artists found', getRowId: (row) => row.id, interactive: true, }); table.el.hidden = true; $results.appendChild(table.el); const pagination = createPagination({ onChange: ({ page, pageSize }) => { state.page = page; state.pageSize = pageSize; loadArtists(); }, }); $results.appendChild(pagination.el); const listState = createAsyncListState({ table, statusEl: $status, pagination }); const defaultPageSize = Math.max(1, Number(pagination.pageSize) || 25); const state = { prefix: initialPrefix, filter: initialFilter, page: 1, pageSize: defaultPageSize, }; function getNormalizedPagination() { let pageSize = Number(state.pageSize); if (!Number.isFinite(pageSize) || pageSize <= 0) pageSize = defaultPageSize; else pageSize = Math.max(1, Math.floor(pageSize)); let page = Number(state.page); if (!Number.isFinite(page) || page <= 0) page = 1; else page = Math.max(1, Math.floor(page)); if (state.pageSize !== pageSize) state.pageSize = pageSize; if (state.page !== page) state.page = page; const offset = Math.max(0, (page - 1) * pageSize); return { page, pageSize, offset }; } function escapeLike(str) { return String(str).replace(/[\\%_]/g, (m) => `\\${m}`); } function buildLikeTerm() { const typed = state.filter.trim(); if (typed) return `%${escapeLike(typed)}%`; if (state.prefix) return `${escapeLike(state.prefix)}%`; return '%'; } const baseCountSql = 'SELECT COUNT(*) AS count FROM artists'; const baseRowsSql = ` SELECT id, name FROM artists ORDER BY name COLLATE NOCASE LIMIT ? OFFSET ? `; const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"'; const rowsSql = ` SELECT id, name FROM artists WHERE name LIKE ? ESCAPE "\\" ORDER BY name COLLATE NOCASE LIMIT ? OFFSET ? `; const ftsCountSql = ` SELECT COUNT(*) AS count FROM ( SELECT a.id FROM fts_tracks JOIN tracks t ON t.id = fts_tracks.rowid JOIN artists a ON a.id = t.artist_id WHERE fts_tracks MATCH ? GROUP BY a.id ) AS matches `; const ftsRowsSql = ` SELECT a.id, a.name FROM fts_tracks JOIN tracks t ON t.id = fts_tracks.rowid JOIN artists a ON a.id = t.artist_id WHERE fts_tracks MATCH ? GROUP BY a.id ORDER BY a.name COLLATE NOCASE LIMIT ? OFFSET ? `; function buildArtistFtsMatch(input) { const tokens = String(input) .trim() .toLowerCase() .split(/\s+/) .map((token) => token.replace(/[^0-9a-z]/gi, '').slice(0, 32)) .filter((token) => token.length >= 2); if (!tokens.length) return null; return tokens.map((token) => `artist:${token}*`).join(' AND '); } function updateJumpButtons() { jumpButtons.forEach((btn) => { const letter = btn.dataset.letter || 'all'; const active = (!state.prefix && letter === 'all') || (state.prefix && letter.toLowerCase() === state.prefix.toLowerCase()); if (active) btn.classList.add('is-link'); else btn.classList.remove('is-link'); }); } function loadArtistsImmediate() { const typedFilter = state.filter.trim(); const ftsMatch = buildArtistFtsMatch(typedFilter); const likeTerm = buildLikeTerm(); const useUnfilteredQuery = !typedFilter && !state.prefix; listState.showLoading('Loading…'); let { page, pageSize, offset } = getNormalizedPagination(); let total = 0; const rows = []; let usedFts = false; try { if (ftsMatch) { const ftsCountStmt = db.prepare(applyFtsMatch(ftsCountSql, ftsMatch)); if (ftsCountStmt.step()) total = Number(ftsCountStmt.getAsObject().count) || 0; ftsCountStmt.free(); if (total > 0) { if (offset >= total) { state.page = Math.max(1, Math.ceil(total / pageSize)); ({ page, pageSize, offset } = getNormalizedPagination()); } const ftsRowsStmt = db.prepare(applyFtsMatch(ftsRowsSql, ftsMatch)); ftsRowsStmt.bind([pageSize, offset]); while (ftsRowsStmt.step()) rows.push(ftsRowsStmt.getAsObject()); ftsRowsStmt.free(); usedFts = true; } } if (!usedFts) { const countStmt = db.prepare(useUnfilteredQuery ? baseCountSql : countSql); if (!useUnfilteredQuery) countStmt.bind([likeTerm]); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); if (total === 0) { table.clear(); listState.showEmpty('No artists found'); return; } if (offset >= total) { state.page = Math.max(1, Math.ceil(total / pageSize)); ({ page, pageSize, offset } = getNormalizedPagination()); } const rowsStmt = db.prepare(useUnfilteredQuery ? baseRowsSql : rowsSql); if (useUnfilteredQuery) rowsStmt.bind([pageSize, offset]); else rowsStmt.bind([likeTerm, pageSize, offset]); while (rowsStmt.step()) rows.push(rowsStmt.getAsObject()); rowsStmt.free(); } } catch (err) { console.error(err); listState.showError('Failed to load artists.'); return; } if (total === 0) { table.clear(); listState.showEmpty('No artists found'); return; } ({ page, pageSize } = getNormalizedPagination()); listState.showRows({ rows, total, page, pageSize, }); } const loadArtists = debounce(loadArtistsImmediate, 200); $filter.value = state.filter; $filter.addEventListener('input', () => { state.filter = $filter.value; state.prefix = null; state.page = 1; updateJumpButtons(); loadArtists(); }); jumpButtons.forEach((btn) => { btn.addEventListener('click', () => { const letter = btn.dataset.letter || 'all'; state.prefix = letter === 'all' ? null : letter; state.filter = ''; $filter.value = ''; state.page = 1; updateJumpButtons(); loadArtistsImmediate(); }); }); $back.addEventListener('click', () => navigateTo('nav')); const unbindRows = bindRowActivation(table.el, (rowId) => { const artistId = Number(rowId); if (!Number.isFinite(artistId)) return; UX.openOverlay(createArtistOverlay(db, artistId)); }); updateJumpButtons(); listState.showLoading('Loading…'); loadArtistsImmediate(); return { kind: 'base', el, onShow() { $filter.focus(); }, destroy() { unbindRows(); }, }; } function createBrowseAlbumsView(db, { initialSort = 'artist' } = {}) { const el = instantiateTemplate('tpl-browse-albums'); const $results = el.querySelector('[data-ref="results"]'); const $back = el.querySelector('[data-action="back"]'); const $sortSelect = el.querySelector('[data-ref="sort"]'); const $status = document.createElement('p'); $status.className = 'has-text-grey'; $results.innerHTML = ''; $results.appendChild($status); const table = createTableRenderer({ columns: [ { header: 'Album', key: 'title' }, { header: 'Artist', key: 'artist' }, { header: 'Year', key: 'year', className: 'has-text-right' }, ], emptyMessage: 'No albums found', getRowId: (row) => row.id, interactive: true, }); table.el.hidden = true; $results.appendChild(table.el); const pagination = createPagination({ onChange: ({ page, pageSize }) => { state.page = page; state.pageSize = pageSize; loadAlbums(); }, }); $results.appendChild(pagination.el); const listState = createAsyncListState({ table, statusEl: $status, pagination }); const state = { sort: initialSort, page: 1, pageSize: pagination.pageSize, }; const orderMap = { artist: 'a.name COLLATE NOCASE, COALESCE(al.year, 0), al.title COLLATE NOCASE', year: 'COALESCE(al.year, 0), a.name COLLATE NOCASE, al.title COLLATE NOCASE', title: 'al.title COLLATE NOCASE, a.name COLLATE NOCASE, COALESCE(al.year, 0)', }; const countSql = 'SELECT COUNT(*) AS count FROM albums'; const baseSql = ` SELECT al.id, al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id = al.artist_id ORDER BY %ORDER% LIMIT ? OFFSET ? `; function loadAlbumsImmediate() { listState.showLoading('Loading…'); let total = 0; const rows = []; try { const countStmt = db.prepare(countSql); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); if (total === 0) { table.clear(); listState.showEmpty('No albums found'); return; } const maxPage = Math.max(1, Math.ceil(total / state.pageSize)); if (state.page > maxPage) state.page = maxPage; const order = orderMap[state.sort] || orderMap.artist; const stmt = db.prepare(baseSql.replace('%ORDER%', order)); stmt.bind([state.pageSize, (state.page - 1) * state.pageSize]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); } catch (err) { console.error(err); listState.showError('Failed to load albums.'); return; } listState.showRows({ rows, total, page: state.page, pageSize: state.pageSize, }); } const loadAlbums = debounce(loadAlbumsImmediate, 200); $sortSelect.value = state.sort; $sortSelect.addEventListener('change', () => { const next = $sortSelect.value; if (!orderMap[next]) return; state.sort = next; state.page = 1; loadAlbumsImmediate(); }); $back.addEventListener('click', () => navigateTo('nav')); const unbindRows = bindRowActivation(table.el, (rowId) => { const albumId = Number(rowId); if (!Number.isFinite(albumId)) return; UX.openOverlay(createAlbumOverlay(db, albumId)); }); listState.showLoading('Loading…'); loadAlbumsImmediate(); return { kind: 'base', el, onShow() { $sortSelect.focus(); }, destroy() { unbindRows(); }, }; } function createBrowseYearsView(db, { presetYear = null } = {}) { const el = instantiateTemplate('tpl-browse-years'); const $back = el.querySelector('[data-action="back"]'); const $yearsCol = el.querySelector('[data-ref="years"]'); const $tracksCol = el.querySelector('[data-ref="tracks"]'); const $yearStatus = document.createElement('p'); $yearStatus.className = 'has-text-grey'; $yearStatus.textContent = 'Loading years…'; $yearsCol.innerHTML = ''; $yearsCol.appendChild($yearStatus); const $tracksStatus = document.createElement('p'); $tracksStatus.className = 'has-text-grey'; $tracksStatus.textContent = 'Select a year to view tracks.'; $tracksCol.innerHTML = ''; $tracksCol.appendChild($tracksStatus); const trackTable = createTableRenderer({ columns: [ { header: 'Track', key: 'title' }, { header: 'Artist', key: 'artist' }, { header: 'Album', key: 'album' }, { header: 'Genre', key: 'genre' }, ], emptyMessage: 'No tracks for this year', getRowId: (row) => row.id, interactive: true, }); trackTable.el.hidden = true; $tracksCol.appendChild(trackTable.el); const pagination = createPagination({ onChange: ({ page, pageSize }) => { state.page = page; state.pageSize = pageSize; loadTracksImmediate(); }, }); $tracksCol.appendChild(pagination.el); const trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination }); const state = { years: [], selectedYear: presetYear, page: 1, pageSize: pagination.pageSize, }; let yearButtons = []; const yearsSql = ` SELECT year, COUNT(*) AS cnt FROM tracks WHERE year IS NOT NULL GROUP BY year ORDER BY year `; const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE year = ?'; const tracksRowsSql = ` SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.genre FROM tracks t JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE t.year = ? ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE LIMIT ? OFFSET ? `; function updateYearButtons() { yearButtons.forEach((btn) => { const btnYear = Number(btn.dataset.year); if (state.selectedYear !== null && btnYear === state.selectedYear) btn.classList.add('is-link'); else btn.classList.remove('is-link'); }); } function buildYearList() { $yearsCol.innerHTML = ''; if (!state.years.length) { const empty = document.createElement('p'); empty.className = 'has-text-grey'; empty.textContent = 'No year data available.'; $yearsCol.appendChild(empty); return; } const menu = document.createElement('aside'); menu.className = 'menu'; const list = document.createElement('ul'); list.className = 'menu-list browse-years-list'; menu.appendChild(list); yearButtons = []; state.years.forEach((entry) => { const li = document.createElement('li'); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'button is-small is-fullwidth is-light has-text-left'; btn.dataset.year = entry.year; btn.innerHTML = `${escapeHtml(String(entry.year))} (${formatNumber(entry.cnt)})`; btn.addEventListener('click', () => { state.selectedYear = Number(entry.year); state.page = 1; updateYearButtons(); loadTracksImmediate(); }); li.appendChild(btn); list.appendChild(li); yearButtons.push(btn); }); $yearsCol.appendChild(menu); updateYearButtons(); } function loadYears() { try { const stmt = db.prepare(yearsSql); const data = []; while (stmt.step()) data.push(stmt.getAsObject()); stmt.free(); state.years = data.map((row) => ({ year: Number(row.year), cnt: Number(row.cnt) })); buildYearList(); } catch (err) { console.error(err); $yearsCol.innerHTML = '

Failed to load years.

'; } } function loadTracksImmediate() { if (!Number.isFinite(state.selectedYear)) { trackTable.clear(); trackListState.showIdle('Select a year to view tracks.'); return; } trackListState.showLoading('Loading tracks…'); let total = 0; const rows = []; try { const countStmt = db.prepare(tracksCountSql); countStmt.bind([state.selectedYear]); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); if (total === 0) { trackTable.clear(); trackListState.showEmpty('No tracks recorded for this year.'); return; } const maxPage = Math.max(1, Math.ceil(total / state.pageSize)); if (state.page > maxPage) state.page = maxPage; const stmt = db.prepare(tracksRowsSql); stmt.bind([state.selectedYear, state.pageSize, (state.page - 1) * state.pageSize]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); } catch (err) { console.error(err); trackListState.showError('Failed to load tracks.'); return; } trackListState.showRows({ rows, total, page: state.page, pageSize: state.pageSize, }); } const unbindRows = bindRowActivation(trackTable.el, (rowId) => { const trackId = Number(rowId); if (!Number.isFinite(trackId)) return; UX.openOverlay(createTrackOverlay(db, trackId)); }); $back.addEventListener('click', () => navigateTo('nav')); loadYears(); if (Number.isFinite(state.selectedYear)) loadTracksImmediate(); return { kind: 'base', el, onShow() { if (yearButtons.length) { const target = yearButtons.find((btn) => Number(btn.dataset.year) === state.selectedYear) || yearButtons[0]; if (target) target.focus(); } }, destroy() { unbindRows(); }, }; } function createBrowseGenresView(db, { presetGenre = null } = {}) { const el = instantiateTemplate('tpl-browse-genres'); const $back = el.querySelector('[data-action="back"]'); const $genres = el.querySelector('[data-ref="genres"]'); const $tracksSection = el.querySelector('[data-ref="tracks"]'); const $selectedGenre = el.querySelector('[data-ref="selected-genre"]'); $tracksSection.hidden = true; const $genreStatus = document.createElement('p'); $genreStatus.className = 'has-text-grey'; $genreStatus.textContent = 'Loading genres…'; $genres.innerHTML = ''; $genres.appendChild($genreStatus); const $tracksStatus = document.createElement('p'); $tracksStatus.className = 'has-text-grey'; $tracksStatus.textContent = 'Select a genre to view tracks.'; $tracksSection.innerHTML = ''; $tracksSection.appendChild($selectedGenre); $tracksSection.appendChild($tracksStatus); const trackTable = createTableRenderer({ columns: [ { header: 'Track', key: 'title' }, { header: 'Artist', key: 'artist' }, { header: 'Album', key: 'album' }, { header: 'Year', key: 'year', className: 'has-text-right' }, ], emptyMessage: 'No tracks for this genre', getRowId: (row) => row.id, interactive: true, }); trackTable.el.hidden = true; $tracksSection.appendChild(trackTable.el); const pagination = createPagination({ onChange: ({ page, pageSize }) => { state.page = page; state.pageSize = pageSize; loadTracksImmediate(); }, }); $tracksSection.appendChild(pagination.el); const trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination }); const state = { genres: [], selectedGenre: presetGenre, page: 1, pageSize: pagination.pageSize, }; let genreButtons = []; const genresSql = ` SELECT genre, COUNT(*) AS cnt FROM tracks WHERE genre IS NOT NULL AND genre != '' GROUP BY genre ORDER BY cnt DESC, genre COLLATE NOCASE `; const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE genre = ?'; const tracksRowsSql = ` SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.year FROM tracks t JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE t.genre = ? ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE LIMIT ? OFFSET ? `; function renderGenres() { $genres.innerHTML = ''; if (!state.genres.length) { const empty = document.createElement('p'); empty.className = 'has-text-grey'; empty.textContent = 'No genres recorded.'; $genres.appendChild(empty); return; } const list = document.createElement('div'); list.className = 'buttons are-small is-flex is-flex-wrap-wrap'; genreButtons = []; state.genres.forEach((entry) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'button is-light'; btn.dataset.genre = entry.genre; btn.innerHTML = `${escapeHtml(entry.genre)} (${formatNumber(entry.cnt)})`; btn.addEventListener('click', () => { state.selectedGenre = entry.genre; state.page = 1; updateGenreButtons(); loadTracksImmediate(); }); list.appendChild(btn); genreButtons.push(btn); }); $genres.appendChild(list); updateGenreButtons(); } function updateGenreButtons() { genreButtons.forEach((btn) => { if (btn.dataset.genre === state.selectedGenre) btn.classList.add('is-link'); else btn.classList.remove('is-link'); }); } function loadGenres() { try { const stmt = db.prepare(genresSql); const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); state.genres = rows.map((row) => ({ genre: row.genre, cnt: Number(row.cnt) })); renderGenres(); } catch (err) { console.error(err); $genres.innerHTML = '

Failed to load genres.

'; } } function loadTracksImmediate() { if (!state.selectedGenre) { trackTable.clear(); $tracksSection.hidden = true; trackListState.showIdle('Select a genre to view tracks.'); return; } $tracksSection.hidden = false; $selectedGenre.textContent = `Tracks tagged ${state.selectedGenre}`; trackListState.showLoading('Loading tracks…'); let total = 0; const rows = []; try { const countStmt = db.prepare(tracksCountSql); countStmt.bind([state.selectedGenre]); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); if (total === 0) { trackTable.clear(); trackListState.showEmpty('No tracks found for this genre.'); return; } const maxPage = Math.max(1, Math.ceil(total / state.pageSize)); if (state.page > maxPage) state.page = maxPage; const stmt = db.prepare(tracksRowsSql); stmt.bind([state.selectedGenre, state.pageSize, (state.page - 1) * state.pageSize]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); } catch (err) { console.error(err); trackListState.showError('Failed to load tracks.'); return; } trackListState.showRows({ rows, total, page: state.page, pageSize: state.pageSize, }); } const unbindRows = bindRowActivation(trackTable.el, (rowId) => { const trackId = Number(rowId); if (!Number.isFinite(trackId)) return; UX.openOverlay(createTrackOverlay(db, trackId)); }); $back.addEventListener('click', () => navigateTo('nav')); loadGenres(); if (state.selectedGenre) loadTracksImmediate(); return { kind: 'base', el, onShow() { if (genreButtons.length) { const target = genreButtons.find((btn) => btn.dataset.genre === state.selectedGenre) || genreButtons[0]; if (target) target.focus(); } }, destroy() { unbindRows(); }, }; } function createStatsView(db) { const el = instantiateTemplate('tpl-stats'); const $back = el.querySelector('[data-action="back"]'); const $cards = el.querySelector('[data-ref="cards"]'); const $lists = el.querySelector('[data-ref="lists"]'); const siteStatsSql = 'SELECT name, value FROM site_stats'; const totalsSql = ` SELECT (SELECT COUNT(*) FROM artists) AS artists, (SELECT COUNT(*) FROM albums) AS albums, (SELECT COUNT(*) FROM tracks) AS tracks `; const topArtistsSql = ` SELECT a.id AS artist_id, a.name AS artist, COUNT(*) AS cnt FROM tracks t JOIN artists a ON a.id = t.artist_id GROUP BY a.id ORDER BY cnt DESC, a.name COLLATE NOCASE LIMIT 10 `; const topYearsSql = ` SELECT year, COUNT(*) AS cnt FROM tracks WHERE year IS NOT NULL GROUP BY year ORDER BY cnt DESC, year DESC LIMIT 10 `; const topGenresSql = ` SELECT genre, COUNT(*) AS cnt FROM tracks WHERE genre IS NOT NULL AND genre != '' GROUP BY genre ORDER BY cnt DESC, genre COLLATE NOCASE LIMIT 10 `; function loadPrecomputedStats() { try { const stmt = db.prepare(siteStatsSql); const map = new Map(); try { while (stmt.step()) { const row = stmt.getAsObject(); map.set(String(row.name), String(row.value)); } } finally { stmt.free(); } return map.size ? map : null; } catch (err) { console.warn('Failed to load precomputed stats', err); return null; } } function collectOrderedStats(statsMap, prefix, transform) { if (!statsMap) return null; const items = []; statsMap.forEach((rawValue, name) => { if (!name.startsWith(prefix)) return; const rank = Number(name.slice(prefix.length)); if (!Number.isFinite(rank) || rank <= 0) return; let value; try { value = transform(rawValue); } catch (err) { console.warn('Failed to parse stat', name, err); value = null; } if (value) items.push({ rank, value }); }); if (!items.length) return null; items.sort((a, b) => a.rank - b.rank); return items.map((item) => item.value); } const siteStats = loadPrecomputedStats(); function renderTotals() { const hasPrecomputedCounts = siteStats && siteStats.has('count.artists') && siteStats.has('count.albums') && siteStats.has('count.tracks'); if (hasPrecomputedCounts) { const metrics = [ { label: 'Artists', value: Number(siteStats.get('count.artists')), action: () => navigateTo('browseArtists') }, { label: 'Albums', value: Number(siteStats.get('count.albums')), action: () => navigateTo('browseAlbums') }, { label: 'Tracks', value: Number(siteStats.get('count.tracks')), action: () => navigateTo('search') }, ]; renderMetricCards(metrics); return; } try { const stmt = db.prepare(totalsSql); stmt.step(); const totals = stmt.getAsObject(); stmt.free(); const metrics = [ { label: 'Artists', value: Number(totals.artists), action: () => navigateTo('browseArtists') }, { label: 'Albums', value: Number(totals.albums), action: () => navigateTo('browseAlbums') }, { label: 'Tracks', value: Number(totals.tracks), action: () => navigateTo('search') }, ]; renderMetricCards(metrics); } catch (err) { console.error(err); $cards.innerHTML = '
Failed to load stats.
'; } } function renderMetricCards(metrics) { $cards.innerHTML = ''; metrics.forEach((metric) => { const column = document.createElement('div'); column.className = 'column'; const box = document.createElement('div'); box.className = 'box has-text-centered'; const value = document.createElement('p'); value.className = 'title is-3'; value.textContent = formatNumber(metric.value); const label = document.createElement('p'); label.className = 'subtitle is-6'; label.textContent = metric.label; const button = document.createElement('button'); button.type = 'button'; button.className = 'button is-small is-link'; button.textContent = `Open ${metric.label}`; button.addEventListener('click', metric.action); box.appendChild(value); box.appendChild(label); box.appendChild(button); column.appendChild(box); $cards.appendChild(column); }); } function renderTopLists() { const parseJson = (value) => { if (!value) return null; return JSON.parse(value); }; const precomputedArtists = collectOrderedStats(siteStats, 'top.artist.', (raw) => { const data = parseJson(raw); if (!data) return null; return { artist_id: Number(data.artist_id), artist: data.name, cnt: Number(data.tracks), }; }); const precomputedYears = collectOrderedStats(siteStats, 'top.year.', (raw) => { const data = parseJson(raw); if (!data) return null; return { year: Number(data.year), cnt: Number(data.tracks), }; }); const precomputedGenres = collectOrderedStats(siteStats, 'top.genre.', (raw) => { const data = parseJson(raw); if (!data) return null; return { genre: data.genre, cnt: Number(data.tracks), }; }); let topArtists = precomputedArtists || []; let topYears = precomputedYears || []; let topGenres = precomputedGenres || []; if (!precomputedArtists || !precomputedYears || !precomputedGenres) { try { if (!precomputedArtists) { const artistStmt = db.prepare(topArtistsSql); while (artistStmt.step()) topArtists.push(artistStmt.getAsObject()); artistStmt.free(); } if (!precomputedYears) { const yearStmt = db.prepare(topYearsSql); while (yearStmt.step()) topYears.push(yearStmt.getAsObject()); yearStmt.free(); } if (!precomputedGenres) { const genreStmt = db.prepare(topGenresSql); while (genreStmt.step()) topGenres.push(genreStmt.getAsObject()); genreStmt.free(); } } catch (err) { console.error(err); $lists.innerHTML = '

Failed to load ranking lists.

'; return; } } $lists.innerHTML = ''; const columns = document.createElement('div'); columns.className = 'columns'; const artistCol = document.createElement('div'); artistCol.className = 'column'; artistCol.appendChild(buildListBox('Top Artists', topArtists, (row) => { UX.openOverlay(createArtistOverlay(db, Number(row.artist_id))); }, (row) => `${row.artist} — ${formatNumber(row.cnt)} tracks`)); const yearCol = document.createElement('div'); yearCol.className = 'column'; yearCol.appendChild(buildListBox('Busiest Years', topYears, (row) => { navigateTo('browseYears', { presetYear: Number(row.year) }); }, (row) => `${row.year}: ${formatNumber(row.cnt)} tracks`)); const genreCol = document.createElement('div'); genreCol.className = 'column'; genreCol.appendChild(buildListBox('Top Genres', topGenres, (row) => { navigateTo('browseGenres', { presetGenre: row.genre }); }, (row) => `${row.genre} — ${formatNumber(row.cnt)} tracks`)); columns.appendChild(artistCol); columns.appendChild(yearCol); columns.appendChild(genreCol); $lists.appendChild(columns); } function buildListBox(title, rows, onActivate, getLabel) { const box = document.createElement('div'); box.className = 'box'; const heading = document.createElement('h3'); heading.className = 'title is-5'; heading.textContent = title; box.appendChild(heading); if (!rows.length) { const empty = document.createElement('p'); empty.className = 'has-text-grey'; empty.textContent = 'No data.'; box.appendChild(empty); return box; } const list = document.createElement('ul'); list.className = 'menu-list'; rows.forEach((row) => { const li = document.createElement('li'); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'button is-text is-small has-text-left'; btn.textContent = getLabel(row); btn.addEventListener('click', () => onActivate(row)); li.appendChild(btn); list.appendChild(li); }); box.appendChild(list); return box; } $back.addEventListener('click', () => navigateTo('nav')); renderTotals(); renderTopLists(); return { kind: 'base', el, onShow() { const firstButton = el.querySelector('button'); if (firstButton) firstButton.focus(); }, destroy() {}, }; } function createArtistOverlay(db, artistId) { const el = instantiateTemplate('tpl-artist'); const $name = el.querySelector('[data-ref="name"]'); const $meta = el.querySelector('[data-ref="meta"]'); const $close = el.querySelector('[data-action="close"]'); const tabListItems = Array.from(el.querySelectorAll('.tabs ul li')); const tabLinks = Array.from(el.querySelectorAll('.tabs [data-tab]')); const panels = { albums: el.querySelector('[data-tabpanel="albums"]'), tracks: el.querySelector('[data-tabpanel="tracks"]'), }; let artistInfo = null; const headerSql = ` SELECT a.name, (SELECT COUNT(*) FROM albums WHERE artist_id = a.id) AS album_count, (SELECT COUNT(*) FROM tracks WHERE artist_id = a.id) AS track_count FROM artists a WHERE a.id = ? `; const albumsSql = ` SELECT id, title, year FROM albums WHERE artist_id = ? ORDER BY COALESCE(year, 0), title COLLATE NOCASE `; const tracksSql = ` SELECT id, title, year, genre FROM tracks WHERE artist_id = ? ORDER BY COALESCE(year, 0), title COLLATE NOCASE LIMIT 100 `; const albumStatus = document.createElement('p'); albumStatus.className = 'has-text-grey'; albumStatus.textContent = 'Loading albums…'; panels.albums.appendChild(albumStatus); const albumTable = createTableRenderer({ columns: [ { header: 'Year', key: 'year', className: 'has-text-right' }, { header: 'Album', key: 'title' }, ], emptyMessage: 'No albums recorded.', getRowId: (row) => row.id, interactive: true, }); albumTable.el.hidden = true; panels.albums.appendChild(albumTable.el); const trackStatus = document.createElement('p'); trackStatus.className = 'has-text-grey'; trackStatus.textContent = 'Loading tracks…'; panels.tracks.appendChild(trackStatus); const trackTable = createTableRenderer({ columns: [ { header: 'Title', key: 'title' }, { header: 'Year', key: 'year', className: 'has-text-right' }, { header: 'Genre', key: 'genre' }, ], emptyMessage: 'No tracks recorded.', getRowId: (row) => row.id, interactive: true, }); trackTable.el.hidden = true; panels.tracks.appendChild(trackTable.el); const loaded = new Set(); function loadHeader() { try { const stmt = db.prepare(headerSql); stmt.bind([artistId]); if (stmt.step()) { artistInfo = stmt.getAsObject(); $name.textContent = artistInfo.name; const albumCount = formatNumber(artistInfo.album_count); const trackCount = formatNumber(artistInfo.track_count); $meta.textContent = `${albumCount} album${artistInfo.album_count === 1 ? '' : 's'} • ${trackCount} track${artistInfo.track_count === 1 ? '' : 's'}`; } else { $name.textContent = 'Unknown artist'; $meta.textContent = ''; } stmt.free(); } catch (err) { console.error(err); $meta.textContent = 'Failed to load artist details.'; } } function loadAlbums() { if (loaded.has('albums')) return; loaded.add('albums'); try { const rows = []; const stmt = db.prepare(albumsSql); stmt.bind([artistId]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); albumTable.setRows(rows); albumStatus.hidden = true; albumTable.el.hidden = false; } catch (err) { console.error(err); albumStatus.textContent = 'Failed to load albums.'; } } function loadTracks() { if (loaded.has('tracks')) return; loaded.add('tracks'); try { const rows = []; const stmt = db.prepare(tracksSql); stmt.bind([artistId]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); trackTable.setRows(rows); trackStatus.hidden = true; trackTable.el.hidden = false; } catch (err) { console.error(err); trackStatus.textContent = 'Failed to load tracks.'; } } function setActiveTab(tab) { Object.keys(panels).forEach((key, index) => { const panel = panels[key]; const li = tabListItems[index]; const link = tabLinks[index]; const isActive = key === tab; if (panel) panel.hidden = !isActive; if (li) { if (isActive) li.classList.add('is-active'); else li.classList.remove('is-active'); } if (link) link.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); if (tab === 'albums') loadAlbums(); else if (tab === 'tracks') loadTracks(); } tabLinks.forEach((link) => { link.addEventListener('click', (event) => { event.preventDefault(); const tab = link.dataset.tab; if (!tab) return; setActiveTab(tab); }); }); $close.addEventListener('click', () => UX.closeTop()); const unbindAlbumRows = bindRowActivation(albumTable.el, (rowId) => { const albumId = Number(rowId); if (!Number.isFinite(albumId)) return; UX.openOverlay(createAlbumOverlay(db, albumId)); }); const unbindTrackRows = bindRowActivation(trackTable.el, (rowId) => { const trackId = Number(rowId); if (!Number.isFinite(trackId)) return; UX.openOverlay(createTrackOverlay(db, trackId)); }); loadHeader(); setActiveTab('albums'); return { kind: 'overlay', el, onShow() { $close.focus(); }, destroy() { unbindAlbumRows(); unbindTrackRows(); }, }; } function createAlbumOverlay(db, albumId) { const el = instantiateTemplate('tpl-album'); const $title = el.querySelector('[data-ref="title"]'); const $meta = el.querySelector('[data-ref="meta"]'); const $close = el.querySelector('[data-action="close"]'); const $tracks = el.querySelector('[data-ref="tracks"]'); const headerSql = ` SELECT al.title, al.year, a.name AS artist, a.id AS artist_id FROM albums al JOIN artists a ON a.id = al.artist_id WHERE al.id = ? `; const tracksSql = ` SELECT id, track_no, title, duration_sec, bitrate_kbps FROM tracks WHERE album_id = ? ORDER BY COALESCE(track_no, 999), title COLLATE NOCASE `; const trackTable = createTableRenderer({ columns: [ { header: '#', render: (row) => (row.track_no ? row.track_no : '—'), className: 'has-text-right' }, { header: 'Title', key: 'title' }, { header: 'Duration', render: (row) => formatDuration(Number(row.duration_sec)) }, { header: 'Bitrate', render: (row) => formatBitrate(Number(row.bitrate_kbps)) }, ], emptyMessage: 'No tracks found for this album.', getRowId: (row) => row.id, interactive: true, }); trackTable.el.hidden = true; $tracks.innerHTML = ''; const $status = document.createElement('p'); $status.className = 'has-text-grey'; $status.textContent = 'Loading tracks…'; $tracks.appendChild($status); $tracks.appendChild(trackTable.el); let artistId = null; function loadHeader() { try { const stmt = db.prepare(headerSql); stmt.bind([albumId]); if (stmt.step()) { const info = stmt.getAsObject(); artistId = Number(info.artist_id); $title.textContent = info.title || 'Untitled album'; const parts = []; if (info.artist) parts.push(info.artist); if (info.year) parts.push(String(info.year)); $meta.textContent = parts.join(' • '); } else { $title.textContent = 'Unknown album'; $meta.textContent = ''; } stmt.free(); } catch (err) { console.error(err); $meta.textContent = 'Failed to load album details.'; } } function loadTracks() { try { const rows = []; const stmt = db.prepare(tracksSql); stmt.bind([albumId]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); trackTable.setRows(rows); $status.hidden = true; trackTable.el.hidden = false; } catch (err) { console.error(err); $status.textContent = 'Failed to load tracks.'; } } $close.addEventListener('click', () => UX.closeTop()); let metaActions = null; function renderMetaActions() { if (!$meta) return; if (!metaActions) { metaActions = document.createElement('div'); metaActions.className = 'mt-2'; } metaActions.innerHTML = ''; if (Number.isFinite(artistId) && artistId) { const artistBtn = document.createElement('button'); artistBtn.type = 'button'; artistBtn.className = 'button is-small is-link'; artistBtn.textContent = 'View artist'; artistBtn.addEventListener('click', () => { UX.openOverlay(createArtistOverlay(db, artistId)); }); metaActions.appendChild(artistBtn); } if (metaActions.children.length && !metaActions.parentNode) { $tracks.parentNode.insertBefore(metaActions, $tracks); } if (!metaActions.children.length && metaActions.parentNode) { metaActions.parentNode.removeChild(metaActions); } } const unbindRows = bindRowActivation(trackTable.el, (rowId) => { const trackId = Number(rowId); if (!Number.isFinite(trackId)) return; UX.openOverlay(createTrackOverlay(db, trackId)); }); loadHeader(); renderMetaActions(); loadTracks(); return { kind: 'overlay', el, onShow() { $close.focus(); }, destroy() { unbindRows(); }, }; } function createTrackOverlay(db, trackId) { const el = instantiateTemplate('tpl-track'); const $title = el.querySelector('[data-ref="title"]'); const $meta = el.querySelector('[data-ref="meta"]'); const $details = el.querySelector('[data-ref="details"]'); const $close = el.querySelector('[data-action="close"]'); const $copy = el.querySelector('[data-action="copy-path"]'); const sql = ` SELECT t.id, t.title, t.year, t.genre, t.duration_sec, t.bitrate_kbps, t.samplerate_hz, t.channels, t.filesize_bytes, t.sha1, t.relpath, a.id AS artist_id, a.name AS artist, al.id AS album_id, al.title AS album FROM tracks t JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id WHERE t.id = ? `; let track = null; function loadTrack() { try { const stmt = db.prepare(sql); stmt.bind([trackId]); if (stmt.step()) { track = stmt.getAsObject(); $title.textContent = track.title || 'Untitled track'; const metaParts = []; if (track.artist) metaParts.push(track.artist); if (track.album) metaParts.push(track.album); if (track.year) metaParts.push(String(track.year)); $meta.textContent = metaParts.join(' • '); renderDetails(); } else { $title.textContent = 'Track not found'; $meta.textContent = ''; } stmt.free(); } catch (err) { console.error(err); $meta.textContent = 'Failed to load track details.'; } } function renderDetails() { if (!track) return; $details.innerHTML = ''; addDetail('Artist', track.artist || '—', track.artist_id ? () => UX.openOverlay(createArtistOverlay(db, Number(track.artist_id))) : null); if (track.album) { addDetail('Album', track.album, track.album_id ? () => UX.openOverlay(createAlbumOverlay(db, Number(track.album_id))) : null); } addDetail('Year', track.year || '—'); addDetail('Genre', track.genre || '—'); addDetail('Duration', formatDuration(Number(track.duration_sec))); addDetail('Bitrate', formatBitrate(Number(track.bitrate_kbps))); addDetail('Sample rate', formatSamplerate(Number(track.samplerate_hz))); addDetail('Channels', track.channels ? `${track.channels}` : '—'); addDetail('File size', Number(track.filesize_bytes) ? formatBytes(Number(track.filesize_bytes)) : '—'); if (track.sha1) addDetail('SHA-1', track.sha1); addDetail('Path', track.relpath || '—'); } function addDetail(label, value, action) { const term = document.createElement('dt'); term.className = 'column is-one-quarter-tablet is-one-third-desktop has-text-weight-semibold'; term.textContent = label; const def = document.createElement('dd'); def.className = 'column is-three-quarters-tablet is-two-thirds-desktop'; if (action) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'button is-small is-text'; btn.textContent = value; btn.addEventListener('click', action); def.appendChild(btn); } else { def.textContent = value; } $details.appendChild(term); $details.appendChild(def); } async function handleCopy() { if (!track || !track.relpath) return; const text = track.relpath; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); } else { const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); area.style.position = 'absolute'; area.style.left = '-9999px'; document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area); } $copy.textContent = 'Copied!'; setTimeout(() => { $copy.textContent = 'Copy Path'; }, 1600); } catch (err) { console.error(err); $copy.textContent = 'Copy failed'; setTimeout(() => { $copy.textContent = 'Copy Path'; }, 1600); } } $close.addEventListener('click', () => UX.closeTop()); $copy.addEventListener('click', handleCopy); loadTrack(); return { kind: 'overlay', el, onShow() { $close.focus(); }, destroy() {}, }; } const viewFactories = { nav: createNavView, search: createSearchView, browseArtists: createBrowseArtistsView, browseAlbums: createBrowseAlbumsView, browseYears: createBrowseYearsView, browseGenres: createBrowseGenresView, stats: createStatsView, }; let activeDb = null; function navigateTo(view, params) { if (!activeDb) throw new Error('Database not ready'); const factory = viewFactories[view]; if (!factory) throw new Error(`Unknown view: ${view}`); const instance = factory(activeDb, params || {}); return UX.replace(instance); } // --- 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); SQL.ensureFts5Available(db); activeDb = db; window.__db = db; window.__navigateTo = navigateTo; await navigateTo('nav'); } 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, '''); } })();