/* 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 const DEBUG_STORAGE_KEY = 'mp3com.debug'; const DEBUG = (() => { try { const value = localStorage.getItem(DEBUG_STORAGE_KEY); if (!value) return false; const normalized = value.toLowerCase(); return normalized === '1' || normalized === 'true' || normalized === 'on'; } catch (_) { return false; } })(); const now = typeof performance !== 'undefined' && performance && typeof performance.now === 'function' ? () => performance.now() : () => Date.now(); function formatSql(sql) { if (!sql) return ''; return String(sql).replace(/\s+/g, ' ').trim(); } function normalizeParamValue(value) { if (value === null || value === undefined) return value; if (typeof value === 'string') { const limit = 200; if (value.length <= limit) return value; return `${value.slice(0, limit)}…`; } if (value instanceof Date) return value.toISOString(); if (ArrayBuffer.isView(value)) { const ctor = value.constructor && value.constructor.name ? value.constructor.name : 'TypedArray'; return `<${ctor} length=${value.length}>`; } if (value instanceof ArrayBuffer) return ``; if (typeof value === 'object') { const entries = Object.entries(value); const limit = 20; const result = {}; entries.slice(0, limit).forEach(([key, val]) => { result[key] = normalizeParamValue(val); }); if (entries.length > limit) result.__truncated = entries.length - limit; return result; } return value; } function cloneBoundParams(values) { if (values === undefined) return undefined; if (Array.isArray(values)) return values.slice(); if (values && typeof values === 'object') return { ...values }; return values; } function formatParamsForLog(params) { if (params === undefined) return undefined; if (Array.isArray(params)) return params.map((value) => normalizeParamValue(value)); if (params && typeof params === 'object') { const result = {}; Object.keys(params).forEach((key) => { result[key] = normalizeParamValue(params[key]); }); return result; } return [normalizeParamValue(params)]; } function logDebug(event, payload) { if (!DEBUG) return; console.debug(event, payload); } function logSqlError({ view, sql, params, error, phase }) { if (!DEBUG) return; console.error('[SQLite error]', { view: view || 'unknown', phase: phase || 'unknown', sql: formatSql(sql), params: formatParamsForLog(params), message: error && error.message ? error.message : String(error), stack: error && error.stack ? error.stack : undefined, }); } function logQueryBegin({ view, sql, params, label }) { logDebug('[Query begin]', { view: view || 'unknown', label: label || undefined, sql: formatSql(sql), params: formatParamsForLog(params), }); } function logQueryEnd({ view, sql, params, rowCount, durationMs, label, errored }) { logDebug('[Query end]', { view: view || 'unknown', label: label || undefined, sql: formatSql(sql), params: formatParamsForLog(params), rows: rowCount, durationMs: Math.round(durationMs * 1000) / 1000, errored: !!errored, }); } const SQL_FRAGMENT = Symbol('sqlFragment'); function createSqlFragment(text, raw) { return { text, raw, [SQL_FRAGMENT]: true, }; } function isSqlFragment(value) { return Boolean(value && value[SQL_FRAGMENT]); } function quoteSqlString(value) { return `'${String(value).replace(/'/g, "''")}'`; } function sqlValue(value) { if (value === null || value === undefined) { return createSqlFragment('NULL', value); } const type = typeof value; if (type === 'number') { if (!Number.isFinite(value)) throw new Error(`Invalid numeric SQL value: ${value}`); return createSqlFragment(String(value), value); } if (type === 'boolean') { return createSqlFragment(value ? '1' : '0', value); } if (type === 'string') { return createSqlFragment(quoteSqlString(value), value); } throw new Error(`Unsupported SQL value type: ${type}`); } function sqlLike(value, { escapeChar = '\\' } = {}) { if (escapeChar === null || escapeChar === undefined) throw new Error('escapeChar required'); if (typeof escapeChar !== 'string' || escapeChar.length !== 1) throw new Error('escapeChar must be a single character'); if (value === null || value === undefined) { return createSqlFragment('NULL', { value, escapeChar }); } const raw = String(value); const regex = new RegExp(`[${escapeChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}%_]`, 'g'); const escaped = raw.replace(regex, (match) => escapeChar + match); return createSqlFragment(quoteSqlString(escaped), { value, escapeChar }); } function sqlIdentifier(name, { allowlist } = {}) { if (typeof name !== 'string' || !name) throw new Error('Identifier must be a non-empty string'); if (!Array.isArray(allowlist) || allowlist.length === 0) throw new Error('allowlist required for sqlIdentifier'); if (!allowlist.includes(name)) { throw new Error(`Identifier not in allowlist: ${name}`); } return createSqlFragment(name, name); } function buildSql(strings, ...values) { if (!Array.isArray(strings) || strings.length === 0) throw new Error('sql tag requires template literals'); let sqlText = ''; const params = []; for (let i = 0; i < strings.length; i += 1) { sqlText += strings[i]; if (i >= values.length) continue; const part = values[i]; if (isSqlFragment(part)) { sqlText += part.text; params.push(part.raw); continue; } if (part && typeof part === 'object' && typeof part.sql === 'string') { sqlText += part.sql; if (Array.isArray(part.params)) params.push(...part.params); else if (part.params !== undefined) params.push(part.params); continue; } throw new Error('sql template values must be created with sqlValue/sqlLike/sqlIdentifier or other sql fragments'); } return { sql: sqlText, params }; } const sql = buildSql; function logPaginationState({ view, rawPage, rawPageSize, page, pageSize, offset, flags }) { logDebug('[Pagination]', { view: view || 'unknown', rawPage, rawPageSize, page, pageSize, offset, flags: flags || undefined, }); } function normalizeSqlInput(sqlInput) { if (typeof sqlInput === 'string') { return { sql: sqlInput, params: undefined }; } if (sqlInput && typeof sqlInput === 'object') { const sqlText = sqlInput.sql; if (typeof sqlText !== 'string' || !sqlText) { throw new Error('Invalid SQL input: missing sql string'); } return { sql: sqlText, params: cloneBoundParams(sqlInput.params) }; } throw new Error('Invalid SQL input'); } function normalizePagination({ page, pageSize, defaultPageSize, view }) { const safeDefault = Number.isFinite(defaultPageSize) && defaultPageSize > 0 ? Math.max(1, Math.floor(defaultPageSize)) : 25; const rawPage = page; const rawPageSize = pageSize; const numericPage = Number(page); const numericPageSize = Number(pageSize); const flags = { invalidPage: !Number.isFinite(numericPage) || numericPage <= 0, invalidPageSize: !Number.isFinite(numericPageSize) || numericPageSize <= 0, nonIntegerPage: !Number.isInteger(numericPage), nonIntegerPageSize: !Number.isInteger(numericPageSize), }; let normalizedPageSize = flags.invalidPageSize ? safeDefault : Math.max(1, Math.floor(numericPageSize)); if (normalizedPageSize !== numericPageSize) flags.clampedPageSize = true; let normalizedPage = flags.invalidPage ? 1 : Math.max(1, Math.floor(numericPage)); if (normalizedPage !== numericPage) flags.clampedPage = true; const offset = Math.max(0, (normalizedPage - 1) * normalizedPageSize); logPaginationState({ view, rawPage, rawPageSize, page: normalizedPage, pageSize: normalizedPageSize, offset, flags, }); return { page: normalizedPage, pageSize: normalizedPageSize, offset, flags, }; } function normalizePaginationState({ state, defaultPageSize, view }) { const result = normalizePagination({ page: state.page, pageSize: state.pageSize, defaultPageSize, view, }); if (state) { state.page = result.page; state.pageSize = result.pageSize; } return result; } function clampPaginationToTotal({ state, total, pageSize, view }) { if (!state || !Number.isFinite(total) || total < 0) return false; const numericPage = Number(state.page); if (!Number.isFinite(numericPage) || numericPage < 1) return false; const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 1; const maxPage = Math.max(1, Math.ceil(total / safePageSize)); if (numericPage <= maxPage) return false; const clampedPage = maxPage; const offset = Math.max(0, (clampedPage - 1) * safePageSize); logPaginationState({ view, rawPage: state.page, rawPageSize: state.pageSize, page: clampedPage, pageSize: safePageSize, offset, flags: { clampedToTotal: true }, }); state.page = clampedPage; return true; } const VIEW_NAMES = { nav: 'nav', search: 'search', browseArtists: 'browseArtists', browseAlbums: 'browseAlbums', browseYears: 'browseYears', browseGenres: 'browseGenres', stats: 'stats', artistOverlay: 'artistOverlay', albumOverlay: 'albumOverlay', trackOverlay: 'trackOverlay', }; function prepareForView(db, view, sql, label) { const context = label ? { view, label } : { view }; return db.prepare(sql, context); } // 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`; } 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, meta = {}) { this._stmt = stmt; this._columnNames = null; this._meta = { sql: meta.sql || '', view: meta.view || 'unknown', label: meta.label || undefined, }; this._paramsForLog = cloneBoundParams(meta.params); this._rowCount = 0; this._startedAt = 0; this._errored = false; } setContext(meta = {}) { if (!meta) return this; if (meta.sql) this._meta.sql = meta.sql; if (meta.view) this._meta.view = meta.view; if (Object.prototype.hasOwnProperty.call(meta, 'label')) this._meta.label = meta.label; if (Object.prototype.hasOwnProperty.call(meta, 'params')) { this._paramsForLog = cloneBoundParams(meta.params); } return this; } step() { try { this._ensureBegun(); const hasRow = this._stmt.step(); if (hasRow) this._rowCount += 1; return !!hasRow; } catch (error) { this._recordError(error, 'step'); throw error; } } getAsObject() { try { if (!this._columnNames) this._columnNames = this._stmt.getColumnNames(); const values = this._stmt.get(); const row = Object.create(null); const count = this._columnNames.length; for (let i = 0; i < count; i += 1) { row[this._columnNames[i]] = values ? values[i] : undefined; } return row; } catch (error) { this._recordError(error, 'getAsObject'); throw error; } } free() { try { this._stmt.free(); } catch (error) { this._recordError(error, 'free'); throw error; } finally { this._finalizeLifecycle(); this._columnNames = null; } } _ensureBegun() { if (this._startedAt) return; this._startedAt = now(); logQueryBegin({ view: this._meta.view, sql: this._meta.sql, params: this._paramsForLog, label: this._meta.label, }); } _finalizeLifecycle() { if (!this._startedAt) return; const duration = Math.max(0, now() - this._startedAt); logQueryEnd({ view: this._meta.view, sql: this._meta.sql, params: this._paramsForLog, rowCount: this._rowCount, durationMs: duration, label: this._meta.label, errored: this._errored, }); this._startedAt = 0; this._rowCount = 0; this._errored = false; } _recordError(error, phase) { this._errored = true; logSqlError({ view: this._meta.view, sql: this._meta.sql, params: this._paramsForLog, error, phase, }); } } 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(sqlInput, context) { const { sql: sqlText, params } = normalizeSqlInput(sqlInput); const meta = context ? { ...context, sql: sqlText, params } : { sql: sqlText, params }; return new Statement(this._db.prepare(sqlText), meta); } 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)', { view: 'bootstrap', label: 'fts5-check-create', }); 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 defaultPageSize = Math.max(1, Number(pagination.pageSize) || 25); const state = { query: initialQuery, page: 1, pageSize: defaultPageSize, 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({ matchExpr, pageSize, offset }) { return sql` 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 ${sqlValue(matchExpr)} ORDER BY rank LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; }, alpha({ matchExpr, pageSize, offset }) { return sql` 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 ${sqlValue(matchExpr)} ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0) LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; }, }; const countSql = ({ matchExpr }) => sql` 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 ${sqlValue(matchExpr)} `; 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…'); let { page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.search, }); const effectiveSearchBuilder = searchSql[state.sort] || searchSql.rank; let total = 0; let rows = []; try { const countQuery = countSql({ matchExpr }); logDebug('[Search] count SQL', { matchExpr, sql: formatSql(countQuery.sql), params: formatParamsForLog(countQuery.params), }); const countStmt = prepareForView(db, VIEW_NAMES.search, countQuery, 'count'); 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 (clampPaginationToTotal({ state, total, pageSize, view: VIEW_NAMES.search, })) { ({ page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.search, })); } const rowsQuery = effectiveSearchBuilder({ matchExpr, pageSize, offset }); logDebug('[Search] row SQL', { matchExpr, sql: formatSql(rowsQuery.sql), params: formatParamsForLog(rowsQuery.params), }); const searchStmt = prepareForView(db, VIEW_NAMES.search, rowsQuery, 'rows'); 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, 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() { return normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.browseArtists, }); } function buildLikeTerm() { const typed = state.filter.trim(); if (typed) return `%${typed}%`; if (state.prefix) return `${state.prefix}%`; return '%'; } const baseCountSql = sql` SELECT COUNT(*) AS count FROM artists `; const buildBaseRowsSql = (pageSize, offset) => sql` SELECT id, name FROM artists ORDER BY name COLLATE NOCASE LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; const buildFilteredCountSql = (likeValue) => sql` SELECT COUNT(*) AS count FROM artists WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\' `; const buildFilteredRowsSql = (likeValue, pageSize, offset) => sql` SELECT id, name FROM artists WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\' ORDER BY name COLLATE NOCASE LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; 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 likeTerm = buildLikeTerm(); const useUnfilteredQuery = !typedFilter && !state.prefix; listState.showLoading('Loading…'); function sanitizeLimitAndOffset(rawPageSize, rawOffset) { const numericPageSize = Number(rawPageSize); const numericOffset = Number(rawOffset); const pageSizeInt = Number.isFinite(numericPageSize) ? Math.floor(numericPageSize) : NaN; const offsetInt = Number.isFinite(numericOffset) ? Math.floor(numericOffset) : NaN; if (!Number.isFinite(pageSizeInt) || pageSizeInt < 1 || !Number.isFinite(offsetInt) || offsetInt < 0) { console.error('Invalid pagination values for artists query', { pageSize: rawPageSize, offset: rawOffset, }); listState.showError('Failed to load artists.'); return null; } return { pageSize: pageSizeInt, offset: offsetInt }; } let { page, pageSize, offset } = getNormalizedPagination(); const initialSanitized = sanitizeLimitAndOffset(pageSize, offset); if (!initialSanitized) return; ({ pageSize, offset } = initialSanitized); let total = 0; const rows = []; try { const countQuery = useUnfilteredQuery ? baseCountSql : buildFilteredCountSql(likeTerm); const countStmt = prepareForView( db, VIEW_NAMES.browseArtists, countQuery, useUnfilteredQuery ? 'count' : 'count-filtered', ); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); if (total === 0) { table.clear(); listState.showEmpty('No artists found'); return; } if (clampPaginationToTotal({ state, total, pageSize, view: VIEW_NAMES.browseArtists, })) { ({ page, pageSize, offset } = getNormalizedPagination()); const sanitized = sanitizeLimitAndOffset(pageSize, offset); if (!sanitized) return; ({ pageSize, offset } = sanitized); } const rowsQuery = useUnfilteredQuery ? buildBaseRowsSql(pageSize, offset) : buildFilteredRowsSql(likeTerm, pageSize, offset); const rowsStmt = prepareForView( db, VIEW_NAMES.browseArtists, rowsQuery, useUnfilteredQuery ? 'rows' : 'rows-filtered', ); 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; } const finalPagination = getNormalizedPagination(); const finalSanitized = sanitizeLimitAndOffset(finalPagination.pageSize, finalPagination.offset); if (!finalSanitized) return; listState.showRows({ rows, total, page: finalPagination.page, pageSize: finalSanitized.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 defaultPageSize = Math.max(1, Number(pagination.pageSize) || 25); const state = { sort: initialSort, page: 1, pageSize: defaultPageSize, }; 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 = sql` SELECT COUNT(*) AS count FROM albums `; const buildRowsSql = (orderExpr, pageSize, offset) => sql` 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 ${sqlIdentifier(orderExpr, { allowlist: Object.values(orderMap) })} LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; function loadAlbumsImmediate() { listState.showLoading('Loading…'); let { page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.browseAlbums, }); let total = 0; const rows = []; try { const countStmt = prepareForView(db, VIEW_NAMES.browseAlbums, countSql, 'count'); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); if (total === 0) { table.clear(); listState.showEmpty('No albums found'); return; } if (clampPaginationToTotal({ state, total, pageSize, view: VIEW_NAMES.browseAlbums, })) { ({ page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.browseAlbums, })); } const order = orderMap[state.sort] || orderMap.artist; const rowsQuery = buildRowsSql(order, pageSize, offset); const stmt = prepareForView( db, VIEW_NAMES.browseAlbums, rowsQuery, 'rows', ); 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, 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 defaultPageSize = Math.max(1, Number(pagination.pageSize) || 25); const state = { years: [], selectedYear: presetYear, page: 1, pageSize: defaultPageSize, }; let yearButtons = []; const yearsSql = sql` SELECT year, COUNT(*) AS cnt FROM tracks WHERE year IS NOT NULL GROUP BY year ORDER BY year `; const tracksCountSql = (year) => sql` SELECT COUNT(*) AS count FROM tracks WHERE year = ${sqlValue(year)} `; const tracksRowsSql = (year, pageSize, offset) => sql` 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 = ${sqlValue(year)} ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(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 = prepareForView(db, VIEW_NAMES.browseYears, yearsSql, 'year-list'); 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 { page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.browseYears, }); let total = 0; const rows = []; try { const countStmt = prepareForView( db, VIEW_NAMES.browseYears, tracksCountSql(state.selectedYear), 'tracks-count', ); 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; } if (clampPaginationToTotal({ state, total, pageSize, view: VIEW_NAMES.browseYears, })) { ({ page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.browseYears, })); } const stmt = prepareForView( db, VIEW_NAMES.browseYears, tracksRowsSql(state.selectedYear, pageSize, offset), 'tracks-rows', ); 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, 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 defaultPageSize = Math.max(1, Number(pagination.pageSize) || 25); const state = { genres: [], selectedGenre: presetGenre, page: 1, pageSize: defaultPageSize, }; let genreButtons = []; const genresSql = sql` 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 = (genre) => sql` SELECT COUNT(*) AS count FROM tracks WHERE genre = ${sqlValue(genre)} `; const tracksRowsSql = (genre, pageSize, offset) => sql` 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 = ${sqlValue(genre)} ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(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 = prepareForView(db, VIEW_NAMES.browseGenres, genresSql, 'genre-list'); 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 { page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.browseGenres, }); let total = 0; const rows = []; try { const countStmt = prepareForView( db, VIEW_NAMES.browseGenres, tracksCountSql(state.selectedGenre), 'tracks-count', ); 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; } if (clampPaginationToTotal({ state, total, pageSize, view: VIEW_NAMES.browseGenres, })) { ({ page, pageSize, offset } = normalizePaginationState({ state, defaultPageSize, view: VIEW_NAMES.browseGenres, })); } const stmt = prepareForView( db, VIEW_NAMES.browseGenres, tracksRowsSql(state.selectedGenre, pageSize, offset), 'tracks-rows', ); 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, 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 = prepareForView(db, VIEW_NAMES.stats, siteStatsSql, 'site-stats'); 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 = prepareForView(db, VIEW_NAMES.stats, totalsSql, 'totals'); 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 = prepareForView(db, VIEW_NAMES.stats, topArtistsSql, 'top-artists'); while (artistStmt.step()) topArtists.push(artistStmt.getAsObject()); artistStmt.free(); } if (!precomputedYears) { const yearStmt = prepareForView(db, VIEW_NAMES.stats, topYearsSql, 'top-years'); while (yearStmt.step()) topYears.push(yearStmt.getAsObject()); yearStmt.free(); } if (!precomputedGenres) { const genreStmt = prepareForView(db, VIEW_NAMES.stats, topGenresSql, 'top-genres'); 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 = (id) => sql` 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 = ${sqlValue(id)} `; const albumsSql = (id) => sql` SELECT id, title, year FROM albums WHERE artist_id = ${sqlValue(id)} ORDER BY COALESCE(year, 0), title COLLATE NOCASE `; const tracksSql = (id) => sql` SELECT id, title, year, genre FROM tracks WHERE artist_id = ${sqlValue(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 = prepareForView(db, VIEW_NAMES.artistOverlay, headerSql(artistId), 'artist-header'); 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 = prepareForView(db, VIEW_NAMES.artistOverlay, albumsSql(artistId), 'artist-albums'); 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 = prepareForView(db, VIEW_NAMES.artistOverlay, tracksSql(artistId), 'artist-tracks'); 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 = (id) => sql` 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 = ${sqlValue(id)} `; const tracksSql = (id) => sql` SELECT id, track_no, title, duration_sec, bitrate_kbps FROM tracks WHERE album_id = ${sqlValue(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 = prepareForView(db, VIEW_NAMES.albumOverlay, headerSql(albumId), 'album-header'); 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 = prepareForView(db, VIEW_NAMES.albumOverlay, tracksSql(albumId), 'album-tracks'); 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 trackSql = (id) => 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 = ${sqlValue(id)} `; let track = null; function loadTrack() { try { const stmt = prepareForView(db, VIEW_NAMES.trackOverlay, trackSql(trackId), 'track-detail'); 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, '''); } })();