diff --git a/static/app.js b/static/app.js
index 6c0ad2f..c5a016e 100644
--- a/static/app.js
+++ b/static/app.js
@@ -128,6 +128,68 @@ import {
} from './js/section-practice.js';
import { configureHost } from './js/host.js';
import { formatTime } from './js/format.js';
+import { L } from './js/library-state.js';
+import {
+ _LIB_FORMAT_KEY,
+ _LIB_FORMAT_VALUES,
+ _LIB_SORT_KEY,
+ _LIB_SORT_VALUES,
+ _activeLibraryProviderId,
+ _applyLibFiltersToParams,
+ _bumpLibNavGeneration,
+ _getArrangementNamingMode,
+ _lastLibSelected,
+ _libNavItems,
+ _libScrollOnNextRender,
+ _libraryLocalFilename,
+ _libraryProviderApi,
+ _librarySongArtUrl,
+ _librarySongId,
+ _librarySyncState,
+ _moveSelectionInItems,
+ _onHeaderClick,
+ _onNamingModeChange,
+ _pollScanAndRefresh,
+ _providerSupports,
+ _readPersistedChoice,
+ _removeLibCardsForFilename,
+ _renderLibFilterChips,
+ _resetLibraryProviderViewState,
+ _setLibSelection,
+ _setLibrarySyncState,
+ _toggleHeader,
+ _updateLibFiltersBadge,
+ checkScanAndLoad,
+ clearLibFilters,
+ editBtn,
+ filterFavTreeLetter,
+ filterFavorites,
+ filterLibrary,
+ filterTreeLetter,
+ fullRescanLibrary,
+ goFavPage,
+ goFavTreePage,
+ goTreePage,
+ hideScanBanner,
+ libView,
+ loadFavorites,
+ loadLibrary,
+ loadLibraryProviders,
+ loadTreeView,
+ renderGridCards,
+ renderTreeInto,
+ rescanLibrary,
+ setFavView,
+ setLibView,
+ setLibraryProvider,
+ sortFavorites,
+ sortLibrary,
+ stopInfiniteScroll,
+ toggleAllArtists,
+ toggleAllFavoriteArtists,
+ toggleFavorite,
+ toggleLibFilters,
+} from './js/library.js';
// The playback transport. These used to BE app.js — they are imported back now, and the
// four modules that reached for them through the host seam import them directly instead.
import {
@@ -206,68 +268,6 @@ function _activeSearchInput() {
// breakpoints (1 / 2 / 3 / 4 cols depending on viewport).
-// `_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 };
-function _bumpLibNavGeneration() { _libNavGeneration++; }
-
-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 };
-}
-
function _gridColumns(container) {
// Count columns by grouping the first row of children by their
// top coordinate. Robust against any grid-template-columns syntax
@@ -292,13 +292,6 @@ function _gridColumns(container) {
return Math.max(1, cols);
}
-// 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.
-let _lastLibSelected = null;
-
// Tracks which list screen launched the player so Esc-from-player
// returns the user to that screen instead of always defaulting to
// the Library (feedBack#126). Reset on every `playSong` call so a
@@ -307,211 +300,6 @@ let _lastLibSelected = null;
let _playerOriginScreen = 'home';
let _settingsOriginScreen = 'home';
-// 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.
-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; }
-}
-
-
-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;
-}
-
-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;
-}
-
function _isInsideInteractiveControl(el) {
// Bail when the user is interacting with anything that has its
// own keyboard semantics — form controls (checkbox / select /
@@ -1046,9 +834,9 @@ async function showScreen(id) {
if (_activeLibraryProviderId() !== beforeProviderId) {
_resetLibraryProviderViewState();
} else {
- _libEpoch++;
- currentPage = 0;
- _treeStats = null;
+ L.libEpoch++;
+ L.currentPage = 0;
+ L.treeStats = null;
stopInfiniteScroll();
}
loadLibrary(0);
@@ -1114,735 +902,12 @@ async function showScreen(id) {
// ── Library ──────────────────────────────────────────────────────────────
-// 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';
-const _LIB_SORT_KEY = 'feedBack.libSort';
-const _LIB_FORMAT_KEY = 'feedBack.libFormat';
const _LIB_PROVIDER_KEY = 'feedBack.libProvider';
-const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
-const _LIB_SORT_VALUES = new Set([
- 'artist', 'artist-desc', 'title', 'title-desc',
- 'recent', 'year-desc', 'year', 'tuning',
- 'difficulty', 'difficulty-desc',
-]);
-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']);
-
-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 */ }
-}
-
-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];
-}
-
-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');
-}
-
-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;
-}
-
-function _resetLibraryProviderViewState() {
- _libEpoch++;
- currentPage = 0;
- _treePage = 0;
- _treeStats = null;
- _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);
-}
-
-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);
- }
-}
-
-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