mp3-com-meta-browser/script.js

3085 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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 `<ArrayBuffer byteLength=${value.byteLength}>`;
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: 'AZ' },
];
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))} <span class="has-text-grey is-size-7">(${formatNumber(entry.cnt)})</span>`;
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 = '<p class="has-text-danger">Failed to load years.</p>';
}
}
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)} <span class="has-text-grey is-size-7">(${formatNumber(entry.cnt)})</span>`;
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 = '<p class="has-text-danger">Failed to load genres.</p>';
}
}
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 = '<div class="column"><div class="notification is-danger">Failed to load stats.</div></div>';
}
}
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 = '<p class="has-text-danger">Failed to load ranking lists.</p>';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
})();