// The song library: the grid, the artist tree, the A-Z rail, filters, pagination,
// selection, favourites, the scan banner, and the library-provider plumbing.
//
// The single biggest slice of the carve — 145 declarations, and by a wide margin the last
// large coherent thing left in app.js.
//
// A LOW module: it imports only leaves (./dom.js, ./format.js, ./library-state.js,
// ./tuning-display.js — all four import nothing themselves) and needs NO host hooks at
// all. It calls nothing in app.js. That is not luck; it is why this cluster was picked.
// Two entry points that WOULD have dragged the playback core in here were deliberately
// left behind in app.js:
//
// * syncLibrarySong reaches showScreen/playSong
// * _handleLibArrowNav Enter on a selected row plays the song
//
// Both are one hop from the library and app.js is the root, so it imports from both sides
// for free. Pulling them in would have swallowed playSong, showScreen and the whole
// remaining core — I measured it: the closure jumps from 145 declarations to 189.
//
// ─── ON THE EXPORT LIST ──────────────────────────────────────────────────────
//
// 60 exports, and 43 of them CANNOT be found by a call-graph scan. They are referenced
// only from app.js's TOP-LEVEL statements — the Object.assign(window, {...}) contract and
// the scattered window.X = X lines — which live outside every function, so a closure walk
// over declarations never sees them. Among them are the four handler names that app.js
// composes AT RUNTIME into onclick="" strings (filterTreeLetter, filterFavTreeLetter,
// goTreePage, goFavTreePage): the library A-Z rail and its pagination. No static tool can
// see those at all. Miss them and the rail silently stops working on click, with nothing
// failing in CI.
import { _escAttr, _isElementVisible, esc } from './dom.js';
import { formatTime } from './format.js';
import { L } from './library-state.js';
import { displayTuningName } from './tuning-display.js';
// `_libNavItems` is consulted on every arrow / Enter / Space / Home /
// End / activation press, including during autorepeat. Re-running
// `querySelectorAll` + visibility filtering on every keypress is the
// dominant cost on large libraries (hundreds of nodes × per-keypress
// layout reads), so the result is memoised against a generation
// counter that's bumped only when the underlying DOM actually
// changes shape: render functions and `_toggleHeader` bump
// `_libNavGeneration`. Cache misses fall through to a fresh query.
let _libNavGeneration = 0;
let _libNavItemsCache = { gen: -1, items: [], container: null, mode: null, scope: null };
export function _bumpLibNavGeneration() { _libNavGeneration++; }
export function _libNavItems() {
const active = document.querySelector('.screen.active');
if (!active) return { items: [], container: null, mode: null };
let tree, grid;
if (active.id === 'home') {
tree = document.getElementById('lib-tree');
grid = document.getElementById('lib-grid');
} else if (active.id === 'favorites') {
tree = document.getElementById('fav-tree');
grid = document.getElementById('fav-grid');
} else {
return { items: [], container: null, mode: null };
}
const treeMode = tree && !tree.classList.contains('hidden');
const scope = treeMode ? tree : grid;
// Cache key includes the active container — switching grid↔tree or
// home↔favorites must miss even if the generation hasn't ticked.
if (
_libNavItemsCache.gen === _libNavGeneration &&
_libNavItemsCache.scope === scope &&
scope && document.body.contains(scope)
) {
return {
items: _libNavItemsCache.items,
container: _libNavItemsCache.container,
mode: _libNavItemsCache.mode,
};
}
let items, container, mode;
if (treeMode) {
// List mode — include artist headers, album headers, and song
// rows so arrow nav still works when artists/albums are
// collapsed (only the headers are visible then). Filter to
// the currently-displayed nodes so collapsed children don't
// count as targets the keyboard can land on.
const all = Array.from(tree.querySelectorAll(
'.artist-header, .album-header, .song-row[data-play], .song-row[data-library-song][tabindex="0"]'
));
items = all.filter(_isElementVisible);
container = tree;
mode = 'list';
} else {
items = Array.from((grid || document).querySelectorAll('.song-card[data-play], .song-card[data-library-song][tabindex="0"]'));
container = grid;
mode = 'grid';
}
_libNavItemsCache = { gen: _libNavGeneration, items, container, mode, scope };
return { items, container, mode };
}
// Tracked separately from `document.activeElement` so the persistent
// `.selected` highlight survives focus drifting elsewhere (clicks
// outside the grid, drawer opening, etc). Also lets us avoid a global
// `querySelectorAll('.selected')` on every arrow press — large
// libraries make that a noticeable hot path.
export let _lastLibSelected = null;
// One-shot flag set in `showScreen` when the user enters Home or
// Favorites. Consumed by the very next library render so the
// restored selection scrolls into view exactly once on screen entry
// (player → home, hard reload). Routine re-renders driven by
// search / sort / filter changes leave the user's scroll position
// alone — the highlight still re-applies, but they aren't yanked.
export const _libScrollOnNextRender = { home: false, favorites: false };
// localStorage keys for "remember the last selection across reloads
// and after returning from the player". One key per screen so the
// Library and Favorites trees don't fight over the same slot. Only
// song-row / song-card selections are persisted — header selections
// in the tree are ephemeral by design (re-derived from arrow nav).
const _LIB_SELECTED_KEY = 'feedBack.libLastSelected';
const _FAV_SELECTED_KEY = 'feedBack.favLastSelected';
function _selectedKeyForActiveScreen() {
const active = document.querySelector('.screen.active');
if (!active) return null;
if (active.id === 'home') return _LIB_SELECTED_KEY;
if (active.id === 'favorites') return _FAV_SELECTED_KEY;
return null;
}
function _persistLibSelection(el) {
if (!el || !el.dataset) return;
// Both local entries (data-play) and remote entries (data-library-song,
// no data-play yet) are persisted so the selection highlight survives a
// library re-render after sync or provider switch.
const isLocal = !!el.dataset.play;
const isRemote = !isLocal && !!el.dataset.librarySong;
if (!isLocal && !isRemote) return;
const key = _selectedKeyForActiveScreen();
if (!key) return;
// Stored as JSON `{f, a, p, s}`:
// f — encoded filename (local entries); drives data-play restore.
// a — artist, for future cross-page restore.
// p — encoded provider id; prevents cross-provider collisions.
// s — encoded song id (remote entries); drives data-library-song restore.
// Older bare-string and {f,a}/{f,a,p} formats are still tolerated in
// `_loadPersistedLibSelection`.
const artist = el.dataset.artist || '';
const provider = el.dataset.libraryProvider || '';
// For synced provider entries (data-play + data-library-song both present),
// persist both f and s so _restoreLibSelection can match the card by either
// attribute after a post-sync re-render.
const payload = isLocal
? { f: el.dataset.play, a: artist, p: provider, s: el.dataset.librarySong || '' }
: { f: '', a: artist, p: provider, s: el.dataset.librarySong };
try {
localStorage.setItem(key, JSON.stringify(payload));
} catch { /* private mode / quota */ }
}
function _loadPersistedLibSelection(key) {
let raw = null;
try { raw = localStorage.getItem(key); } catch { return null; }
if (!raw) return null;
// Tolerate the older bare-string format (just the encoded
// filename) — older builds wrote that and we'd rather upgrade
// silently than orphan the user's saved selection.
if (raw[0] !== '{') return { f: raw, a: '', p: '', s: '' };
try {
const o = JSON.parse(raw);
return (o && typeof o === 'object') ? { f: o.f || '', a: o.a || '', p: o.p || '', s: o.s || '' } : null;
} catch { return null; }
}
export function _setLibSelection(el, { focus = true } = {}) {
if (!el) return;
// Only the previously-tracked element needs its `.selected` class
// cleared. classList.remove on an element that no longer carries
// the class is a no-op, so a stale `_lastLibSelected` from a
// re-render is harmless. Avoids the global `querySelectorAll`
// pass that the earlier implementation ran on every keypress.
if (_lastLibSelected && _lastLibSelected !== el) {
_lastLibSelected.classList.remove('selected');
}
el.classList.add('selected');
_lastLibSelected = el;
// Save song selections to localStorage so a reload (or returning
// from the player) can restore the highlight. Headers don't get
// persisted — they don't carry a stable id and the tree's auto-
// open heuristic re-derives them on each render anyway.
_persistLibSelection(el);
if (focus) {
// `preventScroll: true` skips the browser's native focus-scroll,
// then we run a single `scrollIntoView` so we don't double-jank
// when the element is partially in view. The browser's default
// focus scroll uses `block: 'nearest'` too but isn't smoothable
// and can interact poorly with sticky headers.
el.focus({ preventScroll: true });
}
_scrollSelectionIntoView(el);
}
// Scroll the selected element to keep it inside a margin from the
// viewport edges. Plain `scrollIntoView({block:'nearest'})` only
// reacts when the element is fully off-screen, so during arrow nav
// the selection drifts to the edge and stays partially visible
// until it falls off — feels laggy. Centering when the row enters
// the buffer zone keeps it comfortably on-screen as the user holds
// the arrow keys.
const _SCROLL_EDGE_MARGIN = 96;
function _scrollSelectionIntoView(el) {
if (!el) return;
const r = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < _SCROLL_EDGE_MARGIN || r.bottom > vh - _SCROLL_EDGE_MARGIN) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
}
}
function _restoreLibSelection(scopeEl, screen, { scroll = true } = {}) {
// Re-apply the persistent `.selected` class to whichever song
// matches the saved filename. For the tree we also walk up and
// open every collapsed ancestor so the restored row is actually
// visible — the user shouldn't have to hunt for their place
// inside a collapsed artist node.
if (!scopeEl) return null;
const key = screen === 'favorites' ? _FAV_SELECTED_KEY : _LIB_SELECTED_KEY;
const saved = _loadPersistedLibSelection(key);
if (!saved || (!saved.f && !saved.s)) return null;
// Match by dataset values — both stored and DOM values are in the
// encoded form, so no decoding is needed. Avoid interpolating persisted
// data into CSS selectors so malformed localStorage can't make
// querySelector throw and break rendering.
//
// Local entries: match data-play (f) + data-library-provider (p) when p
// is present to avoid cross-provider collisions on the same filename.
// Remote entries: match data-library-song (s) + data-library-provider (p).
// When f is present but no data-play card matches (e.g. the file has not
// been downloaded on this load), fall back to the s (provider song-id) so
// a previously-synced remote selection can still be restored.
let el = null;
if (saved.f) {
const candidates = scopeEl.querySelectorAll('.song-card[data-play], .song-row[data-play]');
el = Array.from(candidates).find((node) => {
if (node.dataset.play !== saved.f) return false;
if (saved.p && node.dataset.libraryProvider !== saved.p) return false;
return true;
});
}
if (!el && saved.s) {
const candidates = scopeEl.querySelectorAll('.song-card[data-library-song], .song-row[data-library-song]');
el = Array.from(candidates).find((node) => {
if (node.dataset.librarySong !== saved.s) return false;
if (saved.p && node.dataset.libraryProvider !== saved.p) return false;
return true;
});
}
if (!el) return null;
// Open every collapsed ancestor in the tree so the restored row
// is on-screen; harmless on the grid since cards have no such
// ancestors. Sync `aria-expanded` on the matching header inside
// each ancestor too — bypassing `_toggleHeader` here would leave
// assistive tech reporting "collapsed" while the visual is open.
let n = el.parentElement;
while (n && n !== scopeEl) {
if (n.classList.contains('artist-row') || n.classList.contains('album-group')) {
n.classList.add('open');
const header = Array.from(n.children).find(c => c.classList.contains('artist-header') || c.classList.contains('album-header'));
if (header) header.setAttribute('aria-expanded', 'true');
}
n = n.parentElement;
}
if (_lastLibSelected && _lastLibSelected !== el) {
_lastLibSelected.classList.remove('selected');
}
el.classList.add('selected');
_lastLibSelected = el;
// Center the restored element in the viewport so the user's eye
// lands on it instead of having to scan up from the bottom edge.
// `block: 'center'` is forgiving of items already on-screen — the
// browser only scrolls when needed to bring the requested
// alignment into view.
// Skip when the caller opts out (e.g. during search/filter/sort
// re-renders, where the user's scroll position should be left
// alone and only the `.selected` class is re-applied).
if (scroll) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
}
return el;
}
export function _moveSelectionInItems(items, deltaIdx) {
// Items are passed in by the caller so we don't re-query the DOM
// twice per keypress (handler queries `_libNavItems`, then we'd
// query it again).
if (!items.length) return false;
const current = document.activeElement && items.includes(document.activeElement)
? document.activeElement
: (_lastLibSelected && items.includes(_lastLibSelected) ? _lastLibSelected : null);
let idx = current ? items.indexOf(current) : -1;
let next;
if (idx === -1) {
// No current selection — first arrow lands on the first item
// regardless of direction. Saves a press.
next = items[0];
} else {
next = items[Math.max(0, Math.min(items.length - 1, idx + deltaIdx))];
}
_setLibSelection(next);
return true;
}
// Persist the view toggle (grid vs tree), sort selection, and format
// filter across reloads. Stored as separate keys (rather than one
// blob) so future controls can opt in independently and a corrupted
// single value doesn't wipe the rest. Validation lives at the read
// site — we coerce unknown values back to safe defaults rather than
// trusting whatever happens to be in localStorage.
const _LIB_VIEW_KEY = 'feedBack.libView';
export const _LIB_SORT_KEY = 'feedBack.libSort';
export const _LIB_FORMAT_KEY = 'feedBack.libFormat';
const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
export const _LIB_SORT_VALUES = new Set([
'artist', 'artist-desc', 'title', 'title-desc',
'recent', 'year-desc', 'year', 'tuning',
'difficulty', 'difficulty-desc',
]);
export const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
// Tree-view expand/collapse persistence. Three states per tree:
// '1' → user asked to expand all
// '0' → user asked to collapse all
// null → no explicit choice; renderTreeInto's existing heuristic
// (auto-open when search active or few artists) wins
//
// Library and Favorites are separate trees with separate
// Expand/Collapse buttons, so each gets its own key — toggling one
// must not flip the other's persisted state.
const _LIB_TREE_EXPAND_KEY = 'feedBack.libTreeExpand';
const _FAV_TREE_EXPAND_KEY = 'feedBack.favTreeExpand';
const _LIB_TREE_EXPAND_VALUES = new Set(['1', '0']);
export function _readPersistedChoice(key, allowed, fallback) {
try {
const v = localStorage.getItem(key);
return v !== null && allowed.has(v) ? v : fallback;
} catch {
return fallback;
}
}
function _writePersistedChoice(key, value) {
try { localStorage.setItem(key, value); } catch { /* private mode / quota */ }
}
export function _libraryProviderApi() {
const api = window.feedBack && window.feedBack.libraryProviders;
return api && typeof api === 'object' ? api : null;
}
function _libraryProviderSnapshot() {
const api = _libraryProviderApi();
if (api && typeof api.snapshot === 'function') return api.snapshot();
return { available: false, current: 'local', providers: [{ id: 'local', label: 'My Library', kind: 'local', capabilities: ['library.read', 'art.read', 'song.play'], default: true }] };
}
function _providerById(providerId) {
const api = _libraryProviderApi();
if (api && typeof api.providerById === 'function') return api.providerById(providerId);
return (_libraryProviderSnapshot().providers || []).find(provider => provider.id === providerId) || null;
}
function _activeLibraryProvider() {
const api = _libraryProviderApi();
if (api && typeof api.activeProvider === 'function') return api.activeProvider();
const snapshot = _libraryProviderSnapshot();
return _providerById(snapshot.current) || _providerById('local') || (snapshot.providers || [])[0];
}
export function _activeLibraryProviderId() {
const api = _libraryProviderApi();
if (api && typeof api.activeProviderId === 'function') return api.activeProviderId();
return (_activeLibraryProvider() || {}).id || 'local';
}
function _isLocalLibraryProvider(providerId) {
const api = _libraryProviderApi();
if (api && typeof api.isLocal === 'function') return api.isLocal(providerId);
const provider = _providerById(providerId);
return providerId === 'local' || (provider && provider.kind === 'local');
}
export function _providerSupports(providerId, capability) {
const api = _libraryProviderApi();
if (api && typeof api.supports === 'function') return api.supports(providerId, capability);
const provider = _providerById(providerId);
return !!provider && Array.isArray(provider.capabilities) && provider.capabilities.includes(capability);
}
function _applyLibraryProviderToParams(params) {
params.set('provider', _activeLibraryProviderId());
return params;
}
// ── Instrument-aware tuning (the bass-player tuning-filter report) ───────────
// A song's bass chart is often tuned differently from its guitar chart, so the
// tuning facet, the `tunings` filter, the tuning sort and the row's tuning
// badge must all speak for the instrument the player actually plays. Read the
// host's working-tuning capability (the live selection, seeded from
// /api/settings at boot) rather than adding another settings fetch; hosts
// without the capability keep the guitar behaviour.
const _LIB_PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
let _libSettingsProfile = '';
export function _setLibraryProfile(profileId) {
_libSettingsProfile = _LIB_PERSPECTIVES.includes(profileId) ? profileId : '';
}
export function _libraryInstrument() {
// The PROFILE is the only three-valued source (lead / rhythm / bass); the
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm,
// so it is only the fallback.
if (_libSettingsProfile) return _libSettingsProfile;
try {
const wt = window.feedBack?.workingTuning;
if (wt && typeof wt.get === 'function') {
const cur = wt.get();
if (cur?.instrument === 'bass') return 'bass';
}
} catch { /* capability absent/erroring — lead guitar is the safe default */ }
return 'guitar-lead';
}
export function _libraryInstrumentLabel() {
const p = _libraryInstrument();
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
}
// The tuning a row should SHOW: the bass chart's for a bass player, falling
// back to the song (guitar-derived) tuning when the song has no bass
// arrangement — the common case, not an edge path.
function _rowTuningRaw(song) {
const p = _libraryInstrument();
const field = p === 'bass' ? 'bass_tuning_name'
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
if (field && song[field]) return song[field];
return song.tuning || song.tuning_name || '';
}
export function _resetLibraryProviderViewState() {
L.libEpoch++;
L.currentPage = 0;
_treePage = 0;
L.treeStats = null;
L.tuningNames = null;
stopInfiniteScroll();
}
function _renderLibraryProviderSelector() {
const select = document.getElementById('lib-provider');
const title = document.getElementById('lib-title');
const activeProvider = _activeLibraryProvider();
const providers = _libraryProviderSnapshot().providers || [];
if (select) {
select.innerHTML = providers.map(provider =>
``
).join('');
select.value = activeProvider.id;
select.classList.toggle('hidden', providers.length <= 1);
}
if (title) title.textContent = activeProvider.id === 'local' ? 'Your Library' : (activeProvider.label || activeProvider.id);
}
export async function loadLibraryProviders({ restoreSaved = false, reloadOnChange = false } = {}) {
const beforeProviderId = _activeLibraryProviderId();
const api = _libraryProviderApi();
if (api && typeof api.refresh === 'function') {
await api.refresh({ restoreSaved });
}
_renderLibraryProviderSelector();
const afterProviderId = _activeLibraryProviderId();
if (reloadOnChange && afterProviderId !== beforeProviderId) {
_resetLibraryProviderViewState();
loadLibrary(0);
}
}
export async function setLibraryProvider(providerId, options = {}) {
const beforeProviderId = _activeLibraryProviderId();
try {
const capabilityApi = window.feedBack && window.feedBack.capabilities;
if (capabilityApi && typeof capabilityApi.command === 'function') {
await capabilityApi.command('library', 'select-provider', {
requester: 'app.library',
target: { providerId },
payload: options && typeof options === 'object' ? options : {},
});
} else {
_libraryProviderApi()?.select?.(String(providerId || ''));
}
} catch (err) {
// Reached from an inline onchange="setLibraryProvider(this.value)"
// handler that does not await us, so a rejection would otherwise
// surface as an unhandled promise rejection. Log and bail without a
// reload. Re-render the selector so the