2407 lines
76 KiB
JavaScript
2407 lines
76 KiB
JavaScript
/*
|
||
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');
|
||
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;
|
||
}
|
||
|
||
// --- 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"]');
|
||
const $backBtn = el.querySelector('[data-action="back"]');
|
||
const $sortButtons = el.querySelector('[data-ref="sort-buttons"]');
|
||
|
||
const $status = document.createElement('p');
|
||
$status.className = 'has-text-grey';
|
||
$results.innerHTML = '';
|
||
$results.appendChild($status);
|
||
|
||
const columns = [
|
||
{ header: 'Artist', key: 'artist' },
|
||
{ header: 'Title', key: 'title' },
|
||
{ header: 'Album', key: 'album' },
|
||
{ header: 'Year', key: 'year', className: 'has-text-right' },
|
||
{ header: 'Genre', key: 'genre' },
|
||
];
|
||
const table = createTableRenderer({
|
||
columns,
|
||
emptyMessage: 'No matches found',
|
||
getRowId: (row) => row.id,
|
||
interactive: true,
|
||
});
|
||
table.el.hidden = true;
|
||
$results.appendChild(table.el);
|
||
|
||
const pagination = createPagination({
|
||
onChange: ({ page, pageSize }) => {
|
||
state.page = page;
|
||
state.pageSize = pageSize;
|
||
runSearch();
|
||
},
|
||
});
|
||
$results.appendChild(pagination.el);
|
||
|
||
const listState = createAsyncListState({ table, statusEl: $status, pagination });
|
||
|
||
const state = {
|
||
query: initialQuery,
|
||
page: 1,
|
||
pageSize: pagination.pageSize,
|
||
sort: 'rank',
|
||
};
|
||
|
||
const sortOptions = [
|
||
{ key: 'rank', label: 'Relevance' },
|
||
{ key: 'alpha', label: 'A–Z' },
|
||
];
|
||
|
||
if (!$backBtn) throw new Error('Missing back button');
|
||
if (!$sortButtons) throw new Error('Missing sort container');
|
||
sortOptions.forEach((opt) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'button is-small';
|
||
btn.dataset.sort = opt.key;
|
||
btn.textContent = opt.label;
|
||
btn.addEventListener('click', () => {
|
||
if (state.sort === opt.key) return;
|
||
state.sort = opt.key;
|
||
state.page = 1;
|
||
updateSortButtons();
|
||
runSearchImmediate();
|
||
});
|
||
$sortButtons.appendChild(btn);
|
||
});
|
||
|
||
function updateSortButtons() {
|
||
[...$sortButtons.querySelectorAll('button')].forEach((btn) => {
|
||
if (btn.dataset.sort === state.sort) btn.classList.add('is-link');
|
||
else btn.classList.remove('is-link');
|
||
});
|
||
}
|
||
|
||
const searchSql = {
|
||
rank: `
|
||
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
|
||
FROM fts_tracks 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/\"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
})();
|