mp3-com-meta-browser/script.js

2412 lines
76 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 sql.js (WASM) and opens the database from the cached bytes
- Swaps interchangeable UX elements (views) in a viewport, with fade transitions
Assumptions:
- A ZIP archive is hosted at `/db.zip` containing a single `.sqlite` file.
- We use `fflate` (UMD) from a CDN to unzip after download (keeps implementation minimal).
- We use `sql.js` from a CDN; the WASM is located via `locateFile` (see initSql()).
- We store raw DB bytes in IndexedDB (localStorage is too small for large DBs).
*/
(function () {
// Use a relative URL so the app works when hosted under a subdirectory.
const DB_ZIP_URL = './db.zip';
const IDB_NAME = 'mp3com-meta-browser';
const IDB_STORE = 'files';
const IDB_KEY = 'mp3com-db-bytes-v1'; // bump if format changes
// Viewport layers
const $uxRoot = document.getElementById('ux-root');
const $uxOverlays = document.getElementById('ux-overlays');
// Fade helpers
function fadeIn(el) {
return new Promise((resolve) => {
el.classList.add('fade');
el.classList.add('fade-hidden');
requestAnimationFrame(() => {
el.classList.add('fade-visible');
el.classList.remove('fade-hidden');
const onEnd = (e) => {
if (e.propertyName === 'opacity') {
el.removeEventListener('transitionend', onEnd);
resolve();
}
};
el.addEventListener('transitionend', onEnd);
});
});
}
function fadeOut(el) {
return new Promise((resolve) => {
el.classList.add('fade');
el.classList.remove('fade-visible');
el.classList.add('fade-hidden');
const onEnd = (e) => {
if (e.propertyName === 'opacity') {
el.removeEventListener('transitionend', onEnd);
resolve();
}
};
el.addEventListener('transitionend', onEnd);
});
}
// UX Manager
const UX = (() => {
let currentBase = null; // { view, el }
const overlayStack = []; // [{ view, el, lastFocus, isHidden, isClosing }]
function ensureViewEl(view) {
if (!view || !view.el) throw new Error('Invalid view');
view.el.classList.add('ux-view');
return view.el;
}
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) {
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) {
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() {
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 (lastFocus && typeof lastFocus.focus === 'function') lastFocus.focus();
}
window.addEventListener('keydown', (e) => {
if (Keyboard.isEscapeKey(e) && overlayStack.length) {
e.preventDefault();
closeTop();
}
});
return { replace, openOverlay, closeTop };
})();
// --- IndexedDB helpers (minimal, no external deps) ---
function idbOpen() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function idbGet(key) {
const db = await idbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readonly');
const req = tx.objectStore(IDB_STORE).get(key);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
}
async function idbSet(key, value) {
const db = await idbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readwrite');
const req = tx.objectStore(IDB_STORE).put(value, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
// --- Download and unzip db.zip ---
let loader; // set after factory creation
async function fetchZipWithProgress(url) {
loader.setStep('Downloading database…');
loader.setDetail('Starting download');
const resp = await fetch(url, { cache: 'force-cache' });
if (!resp.ok || !resp.body) throw new Error('Failed to fetch DB archive');
const total = parseInt(resp.headers.get('content-length') || '0', 10) || 0;
const reader = resp.body.getReader();
const chunks = [];
let received = 0;
loader.setProgress(0, total || undefined);
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
loader.setProgress(received, total || undefined);
if (total) loader.setDetail(`${((received / total) * 100).toFixed(1)}% • ${formatBytes(received)} / ${formatBytes(total)}`);
else loader.setDetail(`${formatBytes(received)} downloaded`);
}
const zipBytes = concatUint8(chunks, received);
return zipBytes;
}
function concatUint8(chunks, totalLen) {
const out = new Uint8Array(totalLen);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let val = bytes;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
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;
}
// --- SQL.js init ---
async function initSql() {
if (typeof initSqlJs !== 'function') throw new Error('sql.js not loaded');
loader.setStep('Initializing SQLite…');
loader.setDetail('Loading WASM');
const SQL = await initSqlJs({
locateFile: (file) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}`,
});
return SQL;
}
// --- Templates & Views ---
function instantiateTemplate(id) {
const tpl = document.getElementById(id);
if (!tpl || !('content' in tpl)) throw new Error(`Missing template: ${id}`);
const frag = tpl.content.cloneNode(true);
const root = frag.firstElementChild;
return root;
}
function createTableRenderer({
columns,
emptyMessage = 'No results',
getRowId,
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"]');
// Back + sort toolbar keeps controls grouped for accessibility.
const $toolbar = document.createElement('div');
$toolbar.className = 'level mb-4 is-mobile';
const $toolbarLeft = document.createElement('div');
$toolbarLeft.className = 'level-left';
const $toolbarLeftItem = document.createElement('div');
$toolbarLeftItem.className = 'level-item';
const $backBtn = document.createElement('button');
$backBtn.type = 'button';
$backBtn.className = 'button is-small is-text';
$backBtn.textContent = 'Back to menu';
$toolbarLeftItem.appendChild($backBtn);
$toolbarLeft.appendChild($toolbarLeftItem);
const $toolbarRight = document.createElement('div');
$toolbarRight.className = 'level-right';
const $toolbarRightItem = document.createElement('div');
$toolbarRightItem.className = 'level-item';
const $sortLabel = document.createElement('span');
$sortLabel.className = 'is-size-7 has-text-grey mr-2';
$sortLabel.textContent = 'Sort';
const $sortButtons = document.createElement('div');
$sortButtons.className = 'buttons has-addons is-small';
$toolbarRightItem.appendChild($sortLabel);
$toolbarRightItem.appendChild($sortButtons);
$toolbarRight.appendChild($toolbarRightItem);
$toolbar.appendChild($toolbarLeft);
$toolbar.appendChild($toolbarRight);
$form.insertAdjacentElement('afterend', $toolbar);
const $status = document.createElement('p');
$status.className = 'has-text-grey';
$results.innerHTML = '';
$results.appendChild($status);
const columns = [
{ header: 'Artist', key: 'artist' },
{ header: 'Title', key: 'title' },
{ header: 'Album', key: 'album' },
{ header: 'Year', key: 'year', className: 'has-text-right' },
{ header: 'Genre', key: 'genre' },
];
const table = createTableRenderer({
columns,
emptyMessage: 'No matches found',
getRowId: (row) => row.id,
interactive: true,
});
table.el.hidden = true;
$results.appendChild(table.el);
const pagination = createPagination({
onChange: ({ page, pageSize }) => {
state.page = page;
state.pageSize = pageSize;
runSearch();
},
});
$results.appendChild(pagination.el);
const listState = createAsyncListState({ table, statusEl: $status, pagination });
const state = {
query: initialQuery,
page: 1,
pageSize: pagination.pageSize,
sort: 'rank',
};
const sortOptions = [
{ key: 'rank', label: 'Relevance' },
{ key: 'alpha', label: 'AZ' },
];
sortOptions.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button is-small';
btn.dataset.sort = opt.key;
btn.textContent = opt.label;
btn.addEventListener('click', () => {
if (state.sort === opt.key) return;
state.sort = opt.key;
state.page = 1;
updateSortButtons();
runSearchImmediate();
});
$sortButtons.appendChild(btn);
});
function updateSortButtons() {
[...$sortButtons.querySelectorAll('button')].forEach((btn) => {
if (btn.dataset.sort === state.sort) btn.classList.add('is-link');
else btn.classList.remove('is-link');
});
}
const searchSql = {
rank: `
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE f MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?
`,
alpha: `
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE f MATCH ?
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0)
LIMIT ? OFFSET ?
`,
};
const countSql = `
SELECT COUNT(*) AS count
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE f MATCH ?
`;
function enableSearch() {
$q.disabled = false;
$q.removeAttribute('aria-disabled');
if (state.query) {
$q.value = state.query;
}
$q.focus();
}
function runSearchImmediate() {
const term = state.query.trim();
if (!term) {
table.clear();
listState.showIdle('Type to search…');
return;
}
listState.showLoading('Searching…');
const offset = (state.page - 1) * state.pageSize;
let total = 0;
let rows = [];
try {
const countStmt = db.prepare(countSql);
countStmt.bind([term]);
if (countStmt.step()) {
const row = countStmt.getAsObject();
total = Number(row.count) || 0;
}
countStmt.free();
if (total === 0) {
table.clear();
listState.showEmpty('No matches found');
return;
}
if (offset >= total) {
state.page = Math.max(1, Math.ceil(total / state.pageSize));
}
const searchStmt = db.prepare(searchSql[state.sort] || searchSql.rank);
searchStmt.bind([term, state.pageSize, (state.page - 1) * state.pageSize]);
const nextRows = [];
while (searchStmt.step()) {
nextRows.push(searchStmt.getAsObject());
}
rows = nextRows;
searchStmt.free();
} catch (err) {
console.error(err);
listState.showError('Search failed. Check console for details.');
return;
}
listState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
});
}
const runSearch = debounce(runSearchImmediate, 250);
$form.addEventListener('submit', (e) => e.preventDefault());
$q.addEventListener('input', () => {
state.query = $q.value;
state.page = 1;
runSearch();
});
$backBtn.addEventListener('click', () => navigateTo('nav'));
const unbindRows = bindRowActivation(table.el, (rowId) => {
if (!rowId) return;
const trackId = Number(rowId);
if (!Number.isFinite(trackId)) return;
UX.openOverlay(createTrackOverlay(db, trackId));
});
updateSortButtons();
listState.showIdle('Type to search…');
return {
kind: 'base',
el,
onShow() {
enableSearch();
if (state.query.trim()) runSearchImmediate();
},
destroy() {
unbindRows();
},
};
}
function createNavView(db) {
const el = instantiateTemplate('tpl-nav');
const $backTargets = Array.from(el.querySelectorAll('button[data-action]'));
const actions = {
search: () => navigateTo('search'),
artists: () => navigateTo('browseArtists'),
albums: () => navigateTo('browseAlbums'),
years: () => navigateTo('browseYears'),
genres: () => navigateTo('browseGenres'),
stats: () => navigateTo('stats'),
};
$backTargets.forEach((btn) => {
const action = btn.dataset.action;
if (actions[action]) {
btn.addEventListener('click', () => actions[action]());
}
btn.addEventListener('keydown', (event) => {
let delta = 0;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') delta = 1;
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') delta = -1;
else return;
event.preventDefault();
const index = $backTargets.indexOf(event.currentTarget);
if (index === -1) return;
const nextIndex = (index + delta + $backTargets.length) % $backTargets.length;
$backTargets[nextIndex].focus();
});
});
return {
kind: 'base',
el,
onShow() {
if ($backTargets.length) $backTargets[0].focus();
},
destroy() {},
};
}
function createBrowseArtistsView(db, { initialPrefix = null, initialFilter = '' } = {}) {
const el = instantiateTemplate('tpl-browse-artists');
const $filter = el.querySelector('[data-ref="filter"]');
const $results = el.querySelector('[data-ref="results"]');
const $back = el.querySelector('[data-action="back"]');
const jumpButtons = Array.from(el.querySelectorAll('[data-action="jump"]'));
const $status = document.createElement('p');
$status.className = 'has-text-grey';
$results.innerHTML = '';
$results.appendChild($status);
const table = createTableRenderer({
columns: [{ header: 'Artist', key: 'name' }],
emptyMessage: 'No artists found',
getRowId: (row) => row.id,
interactive: true,
});
table.el.hidden = true;
$results.appendChild(table.el);
const pagination = createPagination({
onChange: ({ page, pageSize }) => {
state.page = page;
state.pageSize = pageSize;
loadArtists();
},
});
$results.appendChild(pagination.el);
const listState = createAsyncListState({ table, statusEl: $status, pagination });
const state = {
prefix: initialPrefix,
filter: initialFilter,
page: 1,
pageSize: pagination.pageSize,
};
function escapeLike(str) {
return String(str).replace(/[\\%_]/g, (m) => `\\${m}`);
}
function buildLikeTerm() {
const typed = state.filter.trim();
if (typed) return `%${escapeLike(typed)}%`;
if (state.prefix) return `${escapeLike(state.prefix)}%`;
return '%';
}
const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"';
const rowsSql = `
SELECT id, name
FROM artists
WHERE name LIKE ? ESCAPE "\\"
ORDER BY name COLLATE NOCASE
LIMIT ? OFFSET ?
`;
const ftsCountSql = `
SELECT COUNT(*) AS count FROM (
SELECT a.id
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
WHERE f MATCH ?
GROUP BY a.id
) AS matches
`;
const ftsRowsSql = `
SELECT a.id, a.name
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
WHERE f MATCH ?
GROUP BY a.id
ORDER BY a.name COLLATE NOCASE
LIMIT ? OFFSET ?
`;
function buildArtistFtsMatch(input) {
const tokens = String(input)
.trim()
.toLowerCase()
.split(/\s+/)
.map((token) => token.replace(/[^0-9a-z]/gi, '').slice(0, 32))
.filter((token) => token.length >= 2);
if (!tokens.length) return null;
return tokens.map((token) => `artist:${token}*`).join(' AND ');
}
function updateJumpButtons() {
jumpButtons.forEach((btn) => {
const letter = btn.dataset.letter || 'all';
const active = (!state.prefix && letter === 'all') || (state.prefix && letter.toLowerCase() === state.prefix.toLowerCase());
if (active) btn.classList.add('is-link');
else btn.classList.remove('is-link');
});
}
function loadArtistsImmediate() {
const typedFilter = state.filter.trim();
const ftsMatch = buildArtistFtsMatch(typedFilter);
const likeTerm = buildLikeTerm();
listState.showLoading('Loading…');
const offset = (state.page - 1) * state.pageSize;
let total = 0;
const rows = [];
let usedFts = false;
try {
if (ftsMatch) {
const ftsCountStmt = db.prepare(ftsCountSql);
ftsCountStmt.bind([ftsMatch]);
if (ftsCountStmt.step()) total = Number(ftsCountStmt.getAsObject().count) || 0;
ftsCountStmt.free();
if (total > 0) {
if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize));
const ftsRowsStmt = db.prepare(ftsRowsSql);
ftsRowsStmt.bind([ftsMatch, state.pageSize, (state.page - 1) * state.pageSize]);
while (ftsRowsStmt.step()) rows.push(ftsRowsStmt.getAsObject());
ftsRowsStmt.free();
usedFts = true;
}
}
if (!usedFts) {
const countStmt = db.prepare(countSql);
countStmt.bind([likeTerm]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
if (total === 0) {
table.clear();
listState.showEmpty('No artists found');
return;
}
if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize));
const rowsStmt = db.prepare(rowsSql);
rowsStmt.bind([likeTerm, state.pageSize, (state.page - 1) * state.pageSize]);
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;
}
listState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
});
}
const loadArtists = debounce(loadArtistsImmediate, 200);
$filter.value = state.filter;
$filter.addEventListener('input', () => {
state.filter = $filter.value;
state.prefix = null;
state.page = 1;
updateJumpButtons();
loadArtists();
});
jumpButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const letter = btn.dataset.letter || 'all';
state.prefix = letter === 'all' ? null : letter;
state.filter = '';
$filter.value = '';
state.page = 1;
updateJumpButtons();
loadArtistsImmediate();
});
});
$back.addEventListener('click', () => navigateTo('nav'));
const unbindRows = bindRowActivation(table.el, (rowId) => {
const artistId = Number(rowId);
if (!Number.isFinite(artistId)) return;
UX.openOverlay(createArtistOverlay(db, artistId));
});
updateJumpButtons();
listState.showLoading('Loading…');
loadArtistsImmediate();
return {
kind: 'base',
el,
onShow() {
$filter.focus();
},
destroy() {
unbindRows();
},
};
}
function createBrowseAlbumsView(db, { initialSort = 'artist' } = {}) {
const el = instantiateTemplate('tpl-browse-albums');
const $results = el.querySelector('[data-ref="results"]');
const $back = el.querySelector('[data-action="back"]');
const $sortSelect = el.querySelector('[data-ref="sort"]');
const $status = document.createElement('p');
$status.className = 'has-text-grey';
$results.innerHTML = '';
$results.appendChild($status);
const table = createTableRenderer({
columns: [
{ header: 'Album', key: 'title' },
{ header: 'Artist', key: 'artist' },
{ header: 'Year', key: 'year', className: 'has-text-right' },
],
emptyMessage: 'No albums found',
getRowId: (row) => row.id,
interactive: true,
});
table.el.hidden = true;
$results.appendChild(table.el);
const pagination = createPagination({
onChange: ({ page, pageSize }) => {
state.page = page;
state.pageSize = pageSize;
loadAlbums();
},
});
$results.appendChild(pagination.el);
const listState = createAsyncListState({ table, statusEl: $status, pagination });
const state = {
sort: initialSort,
page: 1,
pageSize: pagination.pageSize,
};
const orderMap = {
artist: 'a.name COLLATE NOCASE, COALESCE(al.year, 0), al.title COLLATE NOCASE',
year: 'COALESCE(al.year, 0), a.name COLLATE NOCASE, al.title COLLATE NOCASE',
title: 'al.title COLLATE NOCASE, a.name COLLATE NOCASE, COALESCE(al.year, 0)',
};
const countSql = 'SELECT COUNT(*) AS count FROM albums';
const baseSql = `
SELECT al.id, al.title, al.year, a.name AS artist
FROM albums al
JOIN artists a ON a.id = al.artist_id
ORDER BY %ORDER%
LIMIT ? OFFSET ?
`;
function loadAlbumsImmediate() {
listState.showLoading('Loading…');
let total = 0;
const rows = [];
try {
const countStmt = db.prepare(countSql);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
if (total === 0) {
table.clear();
listState.showEmpty('No albums found');
return;
}
const maxPage = Math.max(1, Math.ceil(total / state.pageSize));
if (state.page > maxPage) state.page = maxPage;
const order = orderMap[state.sort] || orderMap.artist;
const stmt = db.prepare(baseSql.replace('%ORDER%', order));
stmt.bind([state.pageSize, (state.page - 1) * state.pageSize]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
} catch (err) {
console.error(err);
listState.showError('Failed to load albums.');
return;
}
listState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
});
}
const loadAlbums = debounce(loadAlbumsImmediate, 200);
$sortSelect.value = state.sort;
$sortSelect.addEventListener('change', () => {
const next = $sortSelect.value;
if (!orderMap[next]) return;
state.sort = next;
state.page = 1;
loadAlbumsImmediate();
});
$back.addEventListener('click', () => navigateTo('nav'));
const unbindRows = bindRowActivation(table.el, (rowId) => {
const albumId = Number(rowId);
if (!Number.isFinite(albumId)) return;
UX.openOverlay(createAlbumOverlay(db, albumId));
});
listState.showLoading('Loading…');
loadAlbumsImmediate();
return {
kind: 'base',
el,
onShow() {
$sortSelect.focus();
},
destroy() {
unbindRows();
},
};
}
function createBrowseYearsView(db, { presetYear = null } = {}) {
const el = instantiateTemplate('tpl-browse-years');
const $back = el.querySelector('[data-action="back"]');
const $yearsCol = el.querySelector('[data-ref="years"]');
const $tracksCol = el.querySelector('[data-ref="tracks"]');
const $yearStatus = document.createElement('p');
$yearStatus.className = 'has-text-grey';
$yearStatus.textContent = 'Loading years…';
$yearsCol.innerHTML = '';
$yearsCol.appendChild($yearStatus);
const $tracksStatus = document.createElement('p');
$tracksStatus.className = 'has-text-grey';
$tracksStatus.textContent = 'Select a year to view tracks.';
$tracksCol.innerHTML = '';
$tracksCol.appendChild($tracksStatus);
const trackTable = createTableRenderer({
columns: [
{ header: 'Track', key: 'title' },
{ header: 'Artist', key: 'artist' },
{ header: 'Album', key: 'album' },
{ header: 'Genre', key: 'genre' },
],
emptyMessage: 'No tracks for this year',
getRowId: (row) => row.id,
interactive: true,
});
trackTable.el.hidden = true;
$tracksCol.appendChild(trackTable.el);
const pagination = createPagination({
onChange: ({ page, pageSize }) => {
state.page = page;
state.pageSize = pageSize;
loadTracksImmediate();
},
});
$tracksCol.appendChild(pagination.el);
const trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination });
const state = {
years: [],
selectedYear: presetYear,
page: 1,
pageSize: pagination.pageSize,
};
let yearButtons = [];
const yearsSql = `
SELECT year, COUNT(*) AS cnt
FROM tracks
WHERE year IS NOT NULL
GROUP BY year
ORDER BY year
`;
const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE year = ?';
const tracksRowsSql = `
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.genre
FROM tracks t
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.year = ?
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE
LIMIT ? OFFSET ?
`;
function updateYearButtons() {
yearButtons.forEach((btn) => {
const btnYear = Number(btn.dataset.year);
if (state.selectedYear !== null && btnYear === state.selectedYear) btn.classList.add('is-link');
else btn.classList.remove('is-link');
});
}
function buildYearList() {
$yearsCol.innerHTML = '';
if (!state.years.length) {
const empty = document.createElement('p');
empty.className = 'has-text-grey';
empty.textContent = 'No year data available.';
$yearsCol.appendChild(empty);
return;
}
const menu = document.createElement('aside');
menu.className = 'menu';
const list = document.createElement('ul');
list.className = 'menu-list browse-years-list';
menu.appendChild(list);
yearButtons = [];
state.years.forEach((entry) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button is-small is-fullwidth is-light has-text-left';
btn.dataset.year = entry.year;
btn.innerHTML = `${escapeHtml(String(entry.year))} <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 = db.prepare(yearsSql);
const data = [];
while (stmt.step()) data.push(stmt.getAsObject());
stmt.free();
state.years = data.map((row) => ({ year: Number(row.year), cnt: Number(row.cnt) }));
buildYearList();
} catch (err) {
console.error(err);
$yearsCol.innerHTML = '<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 total = 0;
const rows = [];
try {
const countStmt = db.prepare(tracksCountSql);
countStmt.bind([state.selectedYear]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
if (total === 0) {
trackTable.clear();
trackListState.showEmpty('No tracks recorded for this year.');
return;
}
const maxPage = Math.max(1, Math.ceil(total / state.pageSize));
if (state.page > maxPage) state.page = maxPage;
const stmt = db.prepare(tracksRowsSql);
stmt.bind([state.selectedYear, state.pageSize, (state.page - 1) * state.pageSize]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
} catch (err) {
console.error(err);
trackListState.showError('Failed to load tracks.');
return;
}
trackListState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
});
}
const unbindRows = bindRowActivation(trackTable.el, (rowId) => {
const trackId = Number(rowId);
if (!Number.isFinite(trackId)) return;
UX.openOverlay(createTrackOverlay(db, trackId));
});
$back.addEventListener('click', () => navigateTo('nav'));
loadYears();
if (Number.isFinite(state.selectedYear)) loadTracksImmediate();
return {
kind: 'base',
el,
onShow() {
if (yearButtons.length) {
const target = yearButtons.find((btn) => Number(btn.dataset.year) === state.selectedYear) || yearButtons[0];
if (target) target.focus();
}
},
destroy() {
unbindRows();
},
};
}
function createBrowseGenresView(db, { presetGenre = null } = {}) {
const el = instantiateTemplate('tpl-browse-genres');
const $back = el.querySelector('[data-action="back"]');
const $genres = el.querySelector('[data-ref="genres"]');
const $tracksSection = el.querySelector('[data-ref="tracks"]');
const $selectedGenre = el.querySelector('[data-ref="selected-genre"]');
$tracksSection.hidden = true;
const $genreStatus = document.createElement('p');
$genreStatus.className = 'has-text-grey';
$genreStatus.textContent = 'Loading genres…';
$genres.innerHTML = '';
$genres.appendChild($genreStatus);
const $tracksStatus = document.createElement('p');
$tracksStatus.className = 'has-text-grey';
$tracksStatus.textContent = 'Select a genre to view tracks.';
$tracksSection.innerHTML = '';
$tracksSection.appendChild($selectedGenre);
$tracksSection.appendChild($tracksStatus);
const trackTable = createTableRenderer({
columns: [
{ header: 'Track', key: 'title' },
{ header: 'Artist', key: 'artist' },
{ header: 'Album', key: 'album' },
{ header: 'Year', key: 'year', className: 'has-text-right' },
],
emptyMessage: 'No tracks for this genre',
getRowId: (row) => row.id,
interactive: true,
});
trackTable.el.hidden = true;
$tracksSection.appendChild(trackTable.el);
const pagination = createPagination({
onChange: ({ page, pageSize }) => {
state.page = page;
state.pageSize = pageSize;
loadTracksImmediate();
},
});
$tracksSection.appendChild(pagination.el);
const trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination });
const state = {
genres: [],
selectedGenre: presetGenre,
page: 1,
pageSize: pagination.pageSize,
};
let genreButtons = [];
const genresSql = `
SELECT genre, COUNT(*) AS cnt
FROM tracks
WHERE genre IS NOT NULL AND genre != ''
GROUP BY genre
ORDER BY cnt DESC, genre COLLATE NOCASE
`;
const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE genre = ?';
const tracksRowsSql = `
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.year
FROM tracks t
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.genre = ?
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE
LIMIT ? OFFSET ?
`;
function renderGenres() {
$genres.innerHTML = '';
if (!state.genres.length) {
const empty = document.createElement('p');
empty.className = 'has-text-grey';
empty.textContent = 'No genres recorded.';
$genres.appendChild(empty);
return;
}
const list = document.createElement('div');
list.className = 'buttons are-small is-flex is-flex-wrap-wrap';
genreButtons = [];
state.genres.forEach((entry) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button is-light';
btn.dataset.genre = entry.genre;
btn.innerHTML = `${escapeHtml(entry.genre)} <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 = db.prepare(genresSql);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
state.genres = rows.map((row) => ({ genre: row.genre, cnt: Number(row.cnt) }));
renderGenres();
} catch (err) {
console.error(err);
$genres.innerHTML = '<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 total = 0;
const rows = [];
try {
const countStmt = db.prepare(tracksCountSql);
countStmt.bind([state.selectedGenre]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
if (total === 0) {
trackTable.clear();
trackListState.showEmpty('No tracks found for this genre.');
return;
}
const maxPage = Math.max(1, Math.ceil(total / state.pageSize));
if (state.page > maxPage) state.page = maxPage;
const stmt = db.prepare(tracksRowsSql);
stmt.bind([state.selectedGenre, state.pageSize, (state.page - 1) * state.pageSize]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
} catch (err) {
console.error(err);
trackListState.showError('Failed to load tracks.');
return;
}
trackListState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
});
}
const unbindRows = bindRowActivation(trackTable.el, (rowId) => {
const trackId = Number(rowId);
if (!Number.isFinite(trackId)) return;
UX.openOverlay(createTrackOverlay(db, trackId));
});
$back.addEventListener('click', () => navigateTo('nav'));
loadGenres();
if (state.selectedGenre) loadTracksImmediate();
return {
kind: 'base',
el,
onShow() {
if (genreButtons.length) {
const target = genreButtons.find((btn) => btn.dataset.genre === state.selectedGenre) || genreButtons[0];
if (target) target.focus();
}
},
destroy() {
unbindRows();
},
};
}
function createStatsView(db) {
const el = instantiateTemplate('tpl-stats');
const $back = el.querySelector('[data-action="back"]');
const $cards = el.querySelector('[data-ref="cards"]');
const $lists = el.querySelector('[data-ref="lists"]');
const 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 renderTotals() {
try {
const stmt = db.prepare(totalsSql);
stmt.step();
const totals = stmt.getAsObject();
stmt.free();
const metrics = [
{ label: 'Artists', value: Number(totals.artists), action: () => navigateTo('browseArtists') },
{ label: 'Albums', value: Number(totals.albums), action: () => navigateTo('browseAlbums') },
{ label: 'Tracks', value: Number(totals.tracks), action: () => navigateTo('search') },
];
$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);
});
} catch (err) {
console.error(err);
$cards.innerHTML = '<div class="column"><div class="notification is-danger">Failed to load stats.</div></div>';
}
}
function renderTopLists() {
let topArtists = [];
let topYears = [];
let topGenres = [];
try {
const artistStmt = db.prepare(topArtistsSql);
while (artistStmt.step()) topArtists.push(artistStmt.getAsObject());
artistStmt.free();
const yearStmt = db.prepare(topYearsSql);
while (yearStmt.step()) topYears.push(yearStmt.getAsObject());
yearStmt.free();
const genreStmt = db.prepare(topGenresSql);
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 = `
SELECT a.name,
(SELECT COUNT(*) FROM albums WHERE artist_id = a.id) AS album_count,
(SELECT COUNT(*) FROM tracks WHERE artist_id = a.id) AS track_count
FROM artists a
WHERE a.id = ?
`;
const albumsSql = `
SELECT id, title, year
FROM albums
WHERE artist_id = ?
ORDER BY COALESCE(year, 0), title COLLATE NOCASE
`;
const tracksSql = `
SELECT id, title, year, genre
FROM tracks
WHERE artist_id = ?
ORDER BY COALESCE(year, 0), title COLLATE NOCASE
LIMIT 100
`;
const albumStatus = document.createElement('p');
albumStatus.className = 'has-text-grey';
albumStatus.textContent = 'Loading albums…';
panels.albums.appendChild(albumStatus);
const albumTable = createTableRenderer({
columns: [
{ header: 'Year', key: 'year', className: 'has-text-right' },
{ header: 'Album', key: 'title' },
],
emptyMessage: 'No albums recorded.',
getRowId: (row) => row.id,
interactive: true,
});
albumTable.el.hidden = true;
panels.albums.appendChild(albumTable.el);
const trackStatus = document.createElement('p');
trackStatus.className = 'has-text-grey';
trackStatus.textContent = 'Loading tracks…';
panels.tracks.appendChild(trackStatus);
const trackTable = createTableRenderer({
columns: [
{ header: 'Title', key: 'title' },
{ header: 'Year', key: 'year', className: 'has-text-right' },
{ header: 'Genre', key: 'genre' },
],
emptyMessage: 'No tracks recorded.',
getRowId: (row) => row.id,
interactive: true,
});
trackTable.el.hidden = true;
panels.tracks.appendChild(trackTable.el);
const loaded = new Set();
function loadHeader() {
try {
const stmt = db.prepare(headerSql);
stmt.bind([artistId]);
if (stmt.step()) {
artistInfo = stmt.getAsObject();
$name.textContent = artistInfo.name;
const albumCount = formatNumber(artistInfo.album_count);
const trackCount = formatNumber(artistInfo.track_count);
$meta.textContent = `${albumCount} album${artistInfo.album_count === 1 ? '' : 's'}${trackCount} track${artistInfo.track_count === 1 ? '' : 's'}`;
} else {
$name.textContent = 'Unknown artist';
$meta.textContent = '';
}
stmt.free();
} catch (err) {
console.error(err);
$meta.textContent = 'Failed to load artist details.';
}
}
function loadAlbums() {
if (loaded.has('albums')) return;
loaded.add('albums');
try {
const rows = [];
const stmt = db.prepare(albumsSql);
stmt.bind([artistId]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
albumTable.setRows(rows);
albumStatus.hidden = true;
albumTable.el.hidden = false;
} catch (err) {
console.error(err);
albumStatus.textContent = 'Failed to load albums.';
}
}
function loadTracks() {
if (loaded.has('tracks')) return;
loaded.add('tracks');
try {
const rows = [];
const stmt = db.prepare(tracksSql);
stmt.bind([artistId]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
trackTable.setRows(rows);
trackStatus.hidden = true;
trackTable.el.hidden = false;
} catch (err) {
console.error(err);
trackStatus.textContent = 'Failed to load tracks.';
}
}
function setActiveTab(tab) {
Object.keys(panels).forEach((key, index) => {
const panel = panels[key];
const li = tabListItems[index];
const link = tabLinks[index];
const isActive = key === tab;
if (panel) panel.hidden = !isActive;
if (li) {
if (isActive) li.classList.add('is-active');
else li.classList.remove('is-active');
}
if (link) link.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
if (tab === 'albums') loadAlbums();
else if (tab === 'tracks') loadTracks();
}
tabLinks.forEach((link) => {
link.addEventListener('click', (event) => {
event.preventDefault();
const tab = link.dataset.tab;
if (!tab) return;
setActiveTab(tab);
});
});
$close.addEventListener('click', () => UX.closeTop());
const unbindAlbumRows = bindRowActivation(albumTable.el, (rowId) => {
const albumId = Number(rowId);
if (!Number.isFinite(albumId)) return;
UX.openOverlay(createAlbumOverlay(db, albumId));
});
const unbindTrackRows = bindRowActivation(trackTable.el, (rowId) => {
const trackId = Number(rowId);
if (!Number.isFinite(trackId)) return;
UX.openOverlay(createTrackOverlay(db, trackId));
});
loadHeader();
setActiveTab('albums');
return {
kind: 'overlay',
el,
onShow() {
$close.focus();
},
destroy() {
unbindAlbumRows();
unbindTrackRows();
},
};
}
function createAlbumOverlay(db, albumId) {
const el = instantiateTemplate('tpl-album');
const $title = el.querySelector('[data-ref="title"]');
const $meta = el.querySelector('[data-ref="meta"]');
const $close = el.querySelector('[data-action="close"]');
const $tracks = el.querySelector('[data-ref="tracks"]');
const headerSql = `
SELECT al.title, al.year, a.name AS artist, a.id AS artist_id
FROM albums al
JOIN artists a ON a.id = al.artist_id
WHERE al.id = ?
`;
const tracksSql = `
SELECT id, track_no, title, duration_sec, bitrate_kbps
FROM tracks
WHERE album_id = ?
ORDER BY COALESCE(track_no, 999), title COLLATE NOCASE
`;
const trackTable = createTableRenderer({
columns: [
{ header: '#', render: (row) => (row.track_no ? row.track_no : '—'), className: 'has-text-right' },
{ header: 'Title', key: 'title' },
{ header: 'Duration', render: (row) => formatDuration(Number(row.duration_sec)) },
{ header: 'Bitrate', render: (row) => formatBitrate(Number(row.bitrate_kbps)) },
],
emptyMessage: 'No tracks found for this album.',
getRowId: (row) => row.id,
interactive: true,
});
trackTable.el.hidden = true;
$tracks.innerHTML = '';
const $status = document.createElement('p');
$status.className = 'has-text-grey';
$status.textContent = 'Loading tracks…';
$tracks.appendChild($status);
$tracks.appendChild(trackTable.el);
let artistId = null;
function loadHeader() {
try {
const stmt = db.prepare(headerSql);
stmt.bind([albumId]);
if (stmt.step()) {
const info = stmt.getAsObject();
artistId = Number(info.artist_id);
$title.textContent = info.title || 'Untitled album';
const parts = [];
if (info.artist) parts.push(info.artist);
if (info.year) parts.push(String(info.year));
$meta.textContent = parts.join(' • ');
} else {
$title.textContent = 'Unknown album';
$meta.textContent = '';
}
stmt.free();
} catch (err) {
console.error(err);
$meta.textContent = 'Failed to load album details.';
}
}
function loadTracks() {
try {
const rows = [];
const stmt = db.prepare(tracksSql);
stmt.bind([albumId]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
trackTable.setRows(rows);
$status.hidden = true;
trackTable.el.hidden = false;
} catch (err) {
console.error(err);
$status.textContent = 'Failed to load tracks.';
}
}
$close.addEventListener('click', () => UX.closeTop());
let metaActions = null;
function renderMetaActions() {
if (!$meta) return;
if (!metaActions) {
metaActions = document.createElement('div');
metaActions.className = 'mt-2';
}
metaActions.innerHTML = '';
if (Number.isFinite(artistId) && artistId) {
const artistBtn = document.createElement('button');
artistBtn.type = 'button';
artistBtn.className = 'button is-small is-link';
artistBtn.textContent = 'View artist';
artistBtn.addEventListener('click', () => {
UX.openOverlay(createArtistOverlay(db, artistId));
});
metaActions.appendChild(artistBtn);
}
if (metaActions.children.length && !metaActions.parentNode) {
$tracks.parentNode.insertBefore(metaActions, $tracks);
}
if (!metaActions.children.length && metaActions.parentNode) {
metaActions.parentNode.removeChild(metaActions);
}
}
const unbindRows = bindRowActivation(trackTable.el, (rowId) => {
const trackId = Number(rowId);
if (!Number.isFinite(trackId)) return;
UX.openOverlay(createTrackOverlay(db, trackId));
});
loadHeader();
renderMetaActions();
loadTracks();
return {
kind: 'overlay',
el,
onShow() {
$close.focus();
},
destroy() {
unbindRows();
},
};
}
function createTrackOverlay(db, trackId) {
const el = instantiateTemplate('tpl-track');
const $title = el.querySelector('[data-ref="title"]');
const $meta = el.querySelector('[data-ref="meta"]');
const $details = el.querySelector('[data-ref="details"]');
const $close = el.querySelector('[data-action="close"]');
const $copy = el.querySelector('[data-action="copy-path"]');
const sql = `
SELECT t.id, t.title, t.year, t.genre, t.duration_sec, t.bitrate_kbps, t.samplerate_hz,
t.channels, t.filesize_bytes, t.sha1, t.relpath,
a.id AS artist_id, a.name AS artist,
al.id AS album_id, al.title AS album
FROM tracks t
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.id = ?
`;
let track = null;
function loadTrack() {
try {
const stmt = db.prepare(sql);
stmt.bind([trackId]);
if (stmt.step()) {
track = stmt.getAsObject();
$title.textContent = track.title || 'Untitled track';
const metaParts = [];
if (track.artist) metaParts.push(track.artist);
if (track.album) metaParts.push(track.album);
if (track.year) metaParts.push(String(track.year));
$meta.textContent = metaParts.join(' • ');
renderDetails();
} else {
$title.textContent = 'Track not found';
$meta.textContent = '';
}
stmt.free();
} catch (err) {
console.error(err);
$meta.textContent = 'Failed to load track details.';
}
}
function renderDetails() {
if (!track) return;
$details.innerHTML = '';
addDetail('Artist', track.artist || '—', track.artist_id ? () => UX.openOverlay(createArtistOverlay(db, Number(track.artist_id))) : null);
if (track.album) {
addDetail('Album', track.album, track.album_id ? () => UX.openOverlay(createAlbumOverlay(db, Number(track.album_id))) : null);
}
addDetail('Year', track.year || '—');
addDetail('Genre', track.genre || '—');
addDetail('Duration', formatDuration(Number(track.duration_sec)));
addDetail('Bitrate', formatBitrate(Number(track.bitrate_kbps)));
addDetail('Sample rate', formatSamplerate(Number(track.samplerate_hz)));
addDetail('Channels', track.channels ? `${track.channels}` : '—');
addDetail('File size', Number(track.filesize_bytes) ? formatBytes(Number(track.filesize_bytes)) : '—');
if (track.sha1) addDetail('SHA-1', track.sha1);
addDetail('Path', track.relpath || '—');
}
function addDetail(label, value, action) {
const term = document.createElement('dt');
term.className = 'column is-one-quarter-tablet is-one-third-desktop has-text-weight-semibold';
term.textContent = label;
const def = document.createElement('dd');
def.className = 'column is-three-quarters-tablet is-two-thirds-desktop';
if (action) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button is-small is-text';
btn.textContent = value;
btn.addEventListener('click', action);
def.appendChild(btn);
} else {
def.textContent = value;
}
$details.appendChild(term);
$details.appendChild(def);
}
async function handleCopy() {
if (!track || !track.relpath) return;
const text = track.relpath;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'absolute';
area.style.left = '-9999px';
document.body.appendChild(area);
area.select();
document.execCommand('copy');
document.body.removeChild(area);
}
$copy.textContent = 'Copied!';
setTimeout(() => { $copy.textContent = 'Copy Path'; }, 1600);
} catch (err) {
console.error(err);
$copy.textContent = 'Copy failed';
setTimeout(() => { $copy.textContent = 'Copy Path'; }, 1600);
}
}
$close.addEventListener('click', () => UX.closeTop());
$copy.addEventListener('click', handleCopy);
loadTrack();
return {
kind: 'overlay',
el,
onShow() {
$close.focus();
},
destroy() {},
};
}
const viewFactories = {
nav: createNavView,
search: createSearchView,
browseArtists: createBrowseArtistsView,
browseAlbums: createBrowseAlbumsView,
browseYears: createBrowseYearsView,
browseGenres: createBrowseGenresView,
stats: createStatsView,
};
let activeDb = null;
function navigateTo(view, params) {
if (!activeDb) throw new Error('Database not ready');
const factory = viewFactories[view];
if (!factory) throw new Error(`Unknown view: ${view}`);
const instance = factory(activeDb, params || {});
return UX.replace(instance);
}
// --- Main bootstrap ---
loader = createLoaderView();
UX.replace(loader);
(async function main() {
try {
loader.setStep('Checking cache…');
loader.setDetail('Looking for cached database');
let dbBytes = await idbGet(IDB_KEY);
if (dbBytes) {
loader.setStep('Using cached database');
loader.setDetail(`Found ${formatBytes(dbBytes.byteLength)} in IndexedDB`);
loader.setProgress(100, 100);
} else {
const zipBytes = await fetchZipWithProgress(DB_ZIP_URL);
dbBytes = await unzipSqlite(zipBytes);
loader.setDetail('Caching database for future loads');
await idbSet(IDB_KEY, dbBytes);
}
const SQL = await initSql();
loader.setDetail('Opening database');
const db = new SQL.Database(dbBytes);
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;');
}
})();