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 's displayed value, so - // re-render to snap it back to the provider that is actually active. - _renderLibraryProviderSelector(); - return; - } - _renderLibraryProviderSelector(); - _resetLibraryProviderViewState(); - loadLibrary(0); -} - -function _libraryProviderIdForSong(song, fallbackProviderId) { - return String( - song.provider_id || song.providerId || song.library_provider_id || - song.libraryProviderId || song.provider || fallbackProviderId || 'local' - ); -} - -function _librarySongId(song) { - const songId = song.song_id || song.songId || song.remote_id || song.remoteId || song.id || song.filename || ''; - return String(songId || ''); -} - -function _libraryLocalFilename(song, providerId) { - if (_isLocalLibraryProvider(providerId)) return song.filename ? String(song.filename) : ''; - const filename = song.local_filename || song.localFilename || song.synced_filename || - song.syncedFilename || song.play_filename || song.playFilename || ''; - if (filename) return String(filename); - const state = _librarySyncState(providerId, _librarySongId(song)); - return state && state.status === 'synced' && state.localFilename ? String(state.localFilename) : ''; -} - -function _libraryDisplayFilename(song, providerId) { - return _libraryLocalFilename(song, providerId) || _librarySongId(song) || 'Unknown song'; -} - -function _librarySongTitle(song, providerId) { - const fallback = _libraryDisplayFilename(song, providerId); - return song.title || fallback.replace(/_p\.archive$/i, '').replace(/_/g, ' '); -} - -function _librarySongArtUrl(song, providerId) { - const explicitArt = song.art_url || song.artUrl || song.cover_url || song.coverUrl; - if (explicitArt) return _safeImageUrl(explicitArt); - const version = song.mtime ? `?v=${Math.floor(song.mtime)}` : ''; - const localFilename = _libraryLocalFilename(song, providerId); - if (localFilename) return `/api/song/${encodeURIComponent(localFilename)}/art${version}`; - if (_isLocalLibraryProvider(providerId)) return ''; - if (!_providerSupports(providerId, 'art.read')) return ''; - const songId = _librarySongId(song); - return songId ? `/api/library/providers/${encodeURIComponent(providerId)}/songs/${encodeURIComponent(songId)}/art${version}` : ''; -} - -function _safeImageUrl(value) { - const raw = String(value || '').trim(); - if (!raw) return ''; - try { - const parsed = new URL(raw, window.location.origin); - return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : ''; - } catch { - return ''; - } -} - -const _librarySyncStates = new Map(); - -function _librarySyncKey(providerId, songId) { - // JSON.stringify avoids delimiter collision: a newline in either value - // would make "${p}\n${s}" ambiguous, but JSON-serialised arrays are - // always distinct for distinct (providerId, songId) pairs. - return JSON.stringify([providerId, songId]); -} - -function _librarySyncState(providerId, songId) { - return _librarySyncStates.get(_librarySyncKey(providerId, songId)) || null; -} - -function _librarySyncStatusText(state) { - if (!state) return ''; - if (state.status === 'syncing') return 'Loading package...'; - if (state.status === 'synced') return state.message || 'Ready to play'; - if (state.status === 'error') return state.message ? `Load failed: ${state.message}` : 'Load failed'; - return ''; -} - -function _librarySyncStatusClass(state, layout) { - const base = layout === 'inline' - ? 'library-sync-status inline-block text-[11px] ml-1' - : 'library-sync-status block mt-1 text-[11px] leading-snug'; - if (!state) return `${base} hidden text-gray-500`; - if (state.status === 'error') return `${base} text-red-300`; - if (state.status === 'synced') return `${base} text-green-300`; - return `${base} text-gray-400`; -} - -function _librarySyncStatusMarkup(providerId, songId, layout = 'block') { - const state = _librarySyncState(providerId, songId); - return `${esc(_librarySyncStatusText(state))}`; -} - -let libView = _readPersistedChoice(_LIB_VIEW_KEY, _LIB_VIEW_VALUES, 'grid'); -let currentPage = 0; -const PAGE_SIZE = 24; -// Tree letter selection persists across reloads / coming back from -// the player so the user lands on the same alphabet group they -// picked. Validation: any single uppercase letter, or `#` for -// non-alphabetical artists, or `''` for the All bucket. -const _LIB_TREE_LETTER_KEY = 'feedBack.libTreeLetter'; -const _FAV_TREE_LETTER_KEY = 'feedBack.favTreeLetter'; -function _readPersistedLetter(key) { - let v = null; - try { v = localStorage.getItem(key); } catch { return ''; } - if (v === null) return ''; - return (v === '' || v === '#' || /^[A-Z]$/.test(v)) ? v : ''; -} -function _writePersistedLetter(key, value) { - try { localStorage.setItem(key, value || ''); } catch { /* private mode / quota */ } -} -let _treeLetter = _readPersistedLetter(_LIB_TREE_LETTER_KEY); -let _treeStats = null; -let _debounceTimer = null; -let _loadingMore = false; -let _hasMore = true; -let _gridObserver = null; // Bumped on filter/sort/view changes so in-flight page fetches can detect // they've been superseded and skip rendering stale results. -let _libEpoch = 0; -// ── Library filters (feedBack#129/#69) ──────────────────────────────── -// -// Filter state lives in a single object so the active set can be -// serialized to localStorage as one key. Each axis is OR-within (Lead -// + Rhythm = "has Lead OR Rhythm"); cross-axis is AND. Tri-state pills -// translate to `_has` / `_lacks` lists on the wire so the server's -// SQL doesn't have to encode the third "any" state. -// In smart mode Combo is subsumed into Lead; only show Lead/Rhythm/Bass. -// In legacy mode keep the original four values. -// In-memory cache so a localStorage.setItem failure (private mode / quota / -// disabled storage) still keeps the chosen mode for the rest of the session. -// Initialised lazily from localStorage on first read. -let _arrangementNamingMode = null; -function _getArrangementNamingMode() { - if (_arrangementNamingMode === 'smart' || _arrangementNamingMode === 'legacy') { - return _arrangementNamingMode; - } - try { - _arrangementNamingMode = localStorage.getItem('arrangementNamingMode') === 'legacy' ? 'legacy' : 'smart'; - } catch (_) { - _arrangementNamingMode = 'smart'; - } - return _arrangementNamingMode; -} -// In smart mode 'Combo' is subsumed into 'Lead' (_ensure_smart_names maps it -// the same way). Normalize any persisted 'Combo' tokens before querying or -// rendering so the UI and the server stay in sync. -function _toSmartArrs(arr) { - return arr.map(a => a === 'Combo' ? 'Lead' : a); -} -function _onNamingModeChange(value) { - const mode = value === 'legacy' ? 'legacy' : 'smart'; - _arrangementNamingMode = mode; - try { localStorage.setItem('arrangementNamingMode', mode); } catch (_) {} - if (mode === 'smart') { - _libFilters.arrHas = _toSmartArrs(_libFilters.arrHas); - _libFilters.arrLacks = _toSmartArrs(_libFilters.arrLacks); - _saveLibFilters(); - } - _renderLibFilterDrawer(); - _renderLibFilterChips(); - _libEpoch++; - currentPage = 0; - _treeStats = null; - loadLibrary(0); -} -function _getArrangements() { - return _getArrangementNamingMode() === 'smart' - ? ['Lead', 'Rhythm', 'Bass'] - : ['Lead', 'Rhythm', 'Bass', 'Combo']; -} -function _arrangementBadgeHtml(arrangement, nm) { - const label = (nm === 'smart' && arrangement.smart_name) ? arrangement.smart_name : arrangement.name; - const cls = label.includes('Lead') ? 'bg-red-900/40 text-red-300' : - label.includes('Rhythm') ? 'bg-blue-900/40 text-blue-300' : - label.includes('Bass') ? 'bg-green-900/40 text-green-300' : - 'bg-dark-600 text-gray-400'; - return `${esc(label)}`; -} -// Stem ids match the bare strings sloppak manifests use ("drums", -// "bass", etc.). `full` is intentionally omitted from the filter UI: -// it's the fallback mix every sloppak ships with, so filtering by it -// would match all sloppaks and confuse users. -const _STEM_DEFS = [ - { id: 'drums', label: 'Drums' }, - { id: 'bass', label: 'Bass' }, - { id: 'vocals', label: 'Vocals' }, - { id: 'guitar', label: 'Guitar' }, - { id: 'piano', label: 'Piano' }, - { id: 'other', label: 'Other' }, -]; -const _LIB_FILTERS_KEY = 'feedBack.libFilters'; -let _libFilters = _loadLibFilters(); -let _tuningNames = null; // cached from /api/library/tuning-names -function _defaultLibFilters() { - return { - arrHas: [], arrLacks: [], - stemsHas: [], stemsLacks: [], - lyrics: null, // null | 1 | 0 - tunings: [], - }; -} - -function _normalizeStringArray(v) { - return Array.isArray(v) ? v.filter(x => typeof x === 'string' && x) : []; -} - -function _normalizeLibFilters(parsed) { - // Defensive: a stale or hand-edited localStorage payload could have - // any shape. Without normalization a later `.join` or `.includes` - // on a non-array would throw at filter-apply time. Coerce each - // field back to its expected type, dropping anything we don't - // recognize. FeedBack#134 review. - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return _defaultLibFilters(); - } - const lyrics = parsed.lyrics; - return { - arrHas: _normalizeStringArray(parsed.arrHas), - arrLacks: _normalizeStringArray(parsed.arrLacks), - stemsHas: _normalizeStringArray(parsed.stemsHas), - stemsLacks: _normalizeStringArray(parsed.stemsLacks), - lyrics: lyrics === 0 || lyrics === 1 ? lyrics : null, - tunings: _normalizeStringArray(parsed.tunings), - }; -} - -function _loadLibFilters() { - try { - const raw = localStorage.getItem(_LIB_FILTERS_KEY); - if (!raw) return _defaultLibFilters(); - const filters = _normalizeLibFilters(JSON.parse(raw)); - // Normalize any stale 'Combo' tokens left from legacy-mode sessions. - if (_getArrangementNamingMode() === 'smart') { - filters.arrHas = _toSmartArrs(filters.arrHas); - filters.arrLacks = _toSmartArrs(filters.arrLacks); - } - return filters; - } catch { - return _defaultLibFilters(); - } -} - -function _saveLibFilters() { - try { localStorage.setItem(_LIB_FILTERS_KEY, JSON.stringify(_libFilters)); } - catch { /* private mode / quota — ignore, in-memory state still works */ } -} - -function _libActiveCount() { - let n = 0; - if (_libFilters.arrHas.length) n++; - if (_libFilters.arrLacks.length) n++; - if (_libFilters.stemsHas.length) n++; - if (_libFilters.stemsLacks.length) n++; - if (_libFilters.lyrics !== null) n++; - if (_libFilters.tunings.length) n++; - return n; -} - -function _applyLibFiltersToParams(params) { - const nm = _getArrangementNamingMode(); - params.set('naming_mode', nm); - const arrHas = nm === 'smart' ? _toSmartArrs(_libFilters.arrHas) : _libFilters.arrHas; - const arrLacks = nm === 'smart' ? _toSmartArrs(_libFilters.arrLacks) : _libFilters.arrLacks; - if (arrHas.length) params.set('arrangements_has', arrHas.join(',')); - if (arrLacks.length) params.set('arrangements_lacks', arrLacks.join(',')); - if (_libFilters.stemsHas.length) params.set('stems_has', _libFilters.stemsHas.join(',')); - if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(',')); - if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics)); - if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(',')); - return params; -} - -function _pillState(item, hasList, lacksList) { - if (hasList.includes(item)) return 'require'; - if (lacksList.includes(item)) return 'exclude'; - return 'any'; -} - -function _cyclePill(item, hasKey, lacksKey) { - // Cycle: any -> require -> exclude -> any. Mutates _libFilters in place. - const hasList = _libFilters[hasKey]; - const lacksList = _libFilters[lacksKey]; - const inHas = hasList.indexOf(item); - const inLacks = lacksList.indexOf(item); - if (inHas === -1 && inLacks === -1) { - hasList.push(item); - } else if (inHas !== -1) { - hasList.splice(inHas, 1); - lacksList.push(item); - } else { - lacksList.splice(inLacks, 1); - } - _saveLibFilters(); - _renderLibFilterDrawer(); - _renderLibFilterChips(); - _libEpoch++; - currentPage = 0; - _treeStats = null; // letter bar counts depend on filters now - loadLibrary(0); -} - -function _renderPillRow(containerId, items, hasKey, lacksKey, labelFor) { - const c = document.getElementById(containerId); - if (!c) return; - c.innerHTML = ''; - for (const it of items) { - const id = typeof it === 'string' ? it : it.id; - const label = labelFor ? labelFor(it) : id; - const state = _pillState(id, _libFilters[hasKey], _libFilters[lacksKey]); - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = `filter-pill state-${state}`; - btn.textContent = label; - btn.onclick = () => _cyclePill(id, hasKey, lacksKey); - c.appendChild(btn); - } -} - -function _renderLyricsPill() { - // Single tri-state pill matching the arrangement / stem pattern. - // Cycle: any (null) -> require (1) -> exclude (0) -> any. - const c = document.getElementById('filter-lyrics'); - if (!c) return; - c.innerHTML = ''; - const v = _libFilters.lyrics; - const state = v === 1 ? 'require' : v === 0 ? 'exclude' : 'any'; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = `filter-pill state-${state}`; - btn.textContent = 'Lyrics'; - btn.onclick = () => { - _libFilters.lyrics = v === null ? 1 : v === 1 ? 0 : null; - _saveLibFilters(); - _renderLyricsPill(); - _renderLibFilterChips(); - _libEpoch++; - currentPage = 0; - _treeStats = null; - loadLibrary(0); - }; - c.appendChild(btn); -} - -async function _renderTuningList() { - const c = document.getElementById('filter-tunings'); - if (!c) return; - let fetchError = null; - if (!_tuningNames) { - const myEpoch = _libEpoch; - c.innerHTML = '
Loading...
'; - try { - const params = _applyLibraryProviderToParams(new URLSearchParams()); - const resp = await fetch(`/api/library/tuning-names?${params}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - // Guard against a provider switch that invalidated _tuningNames - // while this request was in flight — discard a stale result. - if (myEpoch !== _libEpoch) return; - _tuningNames = Array.isArray(data.tunings) ? data.tunings : []; - } catch (e) { - if (myEpoch !== _libEpoch) return; - // Distinguish a server / network failure from "the DB - // genuinely has no tunings indexed". The latter wants a - // Full Rescan; the former just wants a retry. Don't cache - // the failure — leave _tuningNames null so reopening the - // drawer triggers a fresh attempt. - _tuningNames = null; - fetchError = e.message || 'request failed'; - } - } - c.innerHTML = ''; - if (fetchError) { - c.innerHTML = `
Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.
`; - return; - } - if (!_tuningNames.length) { - c.innerHTML = '
No tunings indexed yet — try Full Rescan.
'; - return; - } - for (const t of _tuningNames) { - // Filter on the server grouping key (offsets for customs, name for named - // tunings); label custom pills with their target notes so two "Custom - // Tuning" entries are distinguishable. See tuning_names() in server.py. - const val = t.key || t.name; - let label = t.name; - if (t.name === 'Custom Tuning' && t.offsets - && typeof window.parseRawTuningOffsets === 'function' - && typeof window.displayTuningTargets === 'function') { - const offs = window.parseRawTuningOffsets(t.offsets); - const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : ''; - if (notes) label = 'Custom · ' + notes; - } - const checked = _libFilters.tunings.includes(val); - const row = document.createElement('label'); - row.className = 'tuning-row'; - row.innerHTML = - `` + - `${esc(label)}` + - `${t.count}`; - const cb = row.querySelector('input'); - cb.onchange = () => { - const i = _libFilters.tunings.indexOf(val); - if (cb.checked && i === -1) _libFilters.tunings.push(val); - else if (!cb.checked && i !== -1) _libFilters.tunings.splice(i, 1); - _saveLibFilters(); - _updateLibFiltersBadge(); - _renderLibFilterChips(); - _renderTuningSummary(); - _libEpoch++; - currentPage = 0; - _treeStats = null; - loadLibrary(0); - }; - c.appendChild(row); - } - _renderTuningSummary(); -} - -function _renderTuningSummary() { - const s = document.getElementById('filter-tunings-summary'); - if (!s) return; - if (!_libFilters.tunings.length) { s.textContent = 'All tunings'; return; } - if (_libFilters.tunings.length === 1) { s.textContent = _libFilters.tunings[0]; return; } - s.textContent = `${_libFilters.tunings[0]} +${_libFilters.tunings.length - 1}`; -} - -function _updateLibFiltersBadge() { - const badge = document.getElementById('lib-filters-count'); - if (!badge) return; - const n = _libActiveCount(); - badge.textContent = String(n); - badge.classList.toggle('hidden', n === 0); -} - -function _renderLibFilterDrawer() { - _renderPillRow('filter-arrangements', _getArrangements(), 'arrHas', 'arrLacks'); - _renderPillRow('filter-stems', _STEM_DEFS, 'stemsHas', 'stemsLacks', s => s.label); - _renderLyricsPill(); - _updateLibFiltersBadge(); -} - -function _renderLibFilterChips() { - const row = document.getElementById('lib-filter-chips'); - if (!row) return; - const chips = []; - for (const a of _libFilters.arrHas) chips.push({ label: a, kind: 'require', remove: () => _libFilters.arrHas = _libFilters.arrHas.filter(x => x !== a) }); - for (const a of _libFilters.arrLacks) chips.push({ label: `no ${a}`, kind: 'exclude', remove: () => _libFilters.arrLacks = _libFilters.arrLacks.filter(x => x !== a) }); - for (const s of _libFilters.stemsHas) { - const def = _STEM_DEFS.find(d => d.id === s); - chips.push({ label: def ? def.label : s, kind: 'require', remove: () => _libFilters.stemsHas = _libFilters.stemsHas.filter(x => x !== s) }); - } - for (const s of _libFilters.stemsLacks) { - const def = _STEM_DEFS.find(d => d.id === s); - chips.push({ label: `no ${def ? def.label : s}`, kind: 'exclude', remove: () => _libFilters.stemsLacks = _libFilters.stemsLacks.filter(x => x !== s) }); - } - if (_libFilters.lyrics === 1) chips.push({ label: 'has lyrics', kind: 'require', remove: () => _libFilters.lyrics = null }); - if (_libFilters.lyrics === 0) chips.push({ label: 'no lyrics', kind: 'exclude', remove: () => _libFilters.lyrics = null }); - for (const t of _libFilters.tunings) chips.push({ label: t, kind: 'require', remove: () => _libFilters.tunings = _libFilters.tunings.filter(x => x !== t) }); - - row.innerHTML = ''; - if (!chips.length) { - row.classList.add('hidden'); - return; - } - row.classList.remove('hidden'); - for (const c of chips) { - const el = document.createElement('span'); - el.className = `chip ${c.kind === 'exclude' ? 'chip-exclude' : ''}`; - // The "×" glyph isn't a reliable accessible name; assistive tech - // also can't depend on `title` alone. Spell out the action plus - // the chip's label in `aria-label` so screen-reader users hear - // "Remove filter: Lead" instead of "button" or just "×". - const ariaLabel = `Remove filter: ${c.label}`; - el.innerHTML = - `${esc(c.label)}`; - el.querySelector('button').onclick = () => { - c.remove(); - _saveLibFilters(); - _renderLibFilterDrawer(); - _renderLibFilterChips(); - _libEpoch++; - currentPage = 0; - _treeStats = null; - loadLibrary(0); - }; - row.appendChild(el); - } -} - -function toggleLibFilters(force) { - const drawer = document.getElementById('lib-filter-drawer'); - const overlay = document.getElementById('lib-filter-overlay'); - if (!drawer) return; - const open = force === undefined ? !drawer.classList.contains('open') : !!force; - drawer.classList.toggle('open', open); - overlay.classList.toggle('hidden', !open); - if (open) { - _renderLibFilterDrawer(); - _renderTuningList(); - } -} - -function clearLibFilters() { - _libFilters = _defaultLibFilters(); - _saveLibFilters(); - _renderLibFilterDrawer(); - _renderTuningList(); - _renderLibFilterChips(); - _libEpoch++; - currentPage = 0; - _treeStats = null; - loadLibrary(0); -} - -function setLibView(view) { - libView = view; - if (_LIB_VIEW_VALUES.has(view)) _writePersistedChoice(_LIB_VIEW_KEY, view); - document.getElementById('lib-grid').classList.toggle('hidden', view !== 'grid'); - document.getElementById('lib-tree').classList.toggle('hidden', view !== 'tree'); - document.querySelectorAll('.lib-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid')); - document.querySelectorAll('.lib-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree')); - document.querySelectorAll('.lib-nontree-ctrl').forEach(el => el.classList.toggle('hidden', view === 'tree')); - document.getElementById('view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; - document.getElementById('view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; - // Folder view - const folderTreeEl = document.getElementById('lib-folder-tree'); - if (folderTreeEl) folderTreeEl.classList.toggle('hidden', view !== 'folder'); - const folderCtrlEl = document.getElementById('lib-folder-controls'); - if (folderCtrlEl) folderCtrlEl.classList.toggle('hidden', view !== 'folder'); - // The folder-view toolbar button only exists in the classic (v2) markup; - // setLibView also runs at v3 startup where it's absent, so guard it (the - // grid/tree buttons above predate this and exist on both paths). - const folderBtnEl = document.getElementById('view-folder-btn'); - if (folderBtnEl) folderBtnEl.className = `px-3 py-2.5 text-sm transition ${view === 'folder' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; - if (libView === 'folder' && view !== 'folder') window.folderLibrary?.unload?.(); - if (view !== 'grid') stopInfiniteScroll(); - _libEpoch++; - // View toggle changes which container `_libNavItems` resolves - // to (tree vs grid) — drop the cache so the next keypress - // re-derives. - _bumpLibNavGeneration(); - loadLibrary(); -} - -async function loadLibrary(page) { - if (libView === 'grid') { - await loadGridPage(page !== undefined ? page : currentPage); - } else if (libView === 'tree') { - await loadTreeView(); - } else if (libView === 'folder') { - if (window.folderLibrary) await window.folderLibrary.load(); - } - // v3 Songs page manages its own view state independently of libView — if - // lib-folder-tree is visible, the folder library must also react to filter changes. - if (libView !== 'folder' && window.folderLibrary) { - const treeEl = document.getElementById('lib-folder-tree'); - if (treeEl && !treeEl.classList.contains('hidden')) { - await window.folderLibrary.load(); - } - } -} + // cached from /api/library/tuning-names // ── Folder Library: filter bridge ───────────────────────────────────────── // Serialises the active lib filter state as URL params so the plugin can pass @@ -1855,542 +920,10 @@ window.feedBackLibFilterParams = function() { }; -async function _fetchJsonOrThrow(url) { - const resp = await fetch(url); - const raw = await resp.text(); - let data = {}; - let parseError = null; - if (raw) { - try { - data = JSON.parse(raw); - } catch (error) { - parseError = error; - } - } - if (!resp.ok) { - const detail = String(data.detail || data.error || data.message || '').trim(); - throw new Error(detail || `HTTP ${resp.status}`); - } - if (parseError) throw new Error('Malformed JSON response'); - return data; -} - -function _setLibraryOfflineMessage(containerId, countId, message) { - const container = document.getElementById(containerId); - const count = document.getElementById(countId); - if (count) count.textContent = 'Source appears offline'; - if (container) { - container.innerHTML = `
${esc(message || 'This source appears to be offline.')}
`; - } -} - -function _setLibraryLoadingMessage(containerId, countId, message) { - const container = document.getElementById(containerId); - const count = document.getElementById(countId); - if (count) count.textContent = 'Loading source...'; - if (container) { - container.innerHTML = `
${esc(message || 'Loading library...')}
`; - } -} - -function _libraryLoadingText() { - const provider = _activeLibraryProvider(); - if (!provider || provider.id === 'local' || provider.kind === 'local') { - return 'Loading library...'; - } - return `Connecting to ${provider.label || provider.id}...`; -} - -function filterLibrary() { - clearTimeout(_debounceTimer); - _debounceTimer = setTimeout(() => { - _libEpoch++; - currentPage = 0; - _treeLetter = ''; - // Letter-bar counts depend on `q` and the active filter set — - // any change to those must invalidate the tree-view stats - // cache or the next switch to tree view will render stale - // letter counts (feedBack#134 review). - _treeStats = null; - loadLibrary(0); - }, 250); -} - -function sortLibrary() { - // Persist whichever of the two dropdowns just changed so the next - // page load can restore both. Both selects route through this - // handler today; reading both is cheap and keeps the function - // single-purpose. - const sortEl = document.getElementById('lib-sort'); - if (sortEl && _LIB_SORT_VALUES.has(sortEl.value)) { - _writePersistedChoice(_LIB_SORT_KEY, sortEl.value); - } - const fmtEl = document.getElementById('lib-format'); - if (fmtEl && _LIB_FORMAT_VALUES.has(fmtEl.value)) { - _writePersistedChoice(_LIB_FORMAT_KEY, fmtEl.value); - } - _libEpoch++; - currentPage = 0; - // Same reason as filterLibrary: format dropdown changes the stats - // payload, so the cache must drop too. - _treeStats = null; - loadLibrary(0); -} - // ── Grid View (server-side pagination, infinite scroll) ──────────────── -async function loadGridPage(page = 0) { - const myEpoch = _libEpoch; - const q = document.getElementById('lib-filter').value.trim(); - const sort = document.getElementById('lib-sort').value; - const format = (document.getElementById('lib-format') || {}).value || ''; - const params = new URLSearchParams({ q, page, size: PAGE_SIZE, sort }); - if (format) params.set('format', format); - _applyLibraryProviderToParams(params); - _applyLibFiltersToParams(params); - if (page === 0) { - _setLibraryLoadingMessage('lib-grid', 'lib-count', _libraryLoadingText()); - } - let data; - try { - data = await _fetchJsonOrThrow(`/api/library?${params}`); - } catch (error) { - if (myEpoch !== _libEpoch) return; - currentPage = 0; - _hasMore = false; - stopInfiniteScroll(); - _setLibraryOfflineMessage('lib-grid', 'lib-count', error.message || 'This source appears to be offline.'); - return; - } - if (myEpoch !== _libEpoch) return; // filter/sort/view changed mid-fetch - - currentPage = page; - const total = data.total || 0; - const songs = data.songs || []; - document.getElementById('lib-count').textContent = `${total} songs`; - - renderGridCards(songs, 'lib-grid', page === 0 ? 'replace' : 'append'); - - _hasMore = (page + 1) * PAGE_SIZE < total; - setupInfiniteScroll(); -} - -function setupInfiniteScroll() { - let sentinel = document.getElementById('lib-grid-sentinel'); - if (!sentinel) { - sentinel = document.createElement('div'); - sentinel.id = 'lib-grid-sentinel'; - sentinel.style.height = '1px'; - document.getElementById('lib-grid').after(sentinel); - } - stopInfiniteScroll(); - if (!_hasMore) return; - _gridObserver = new IntersectionObserver(async (entries) => { - if (entries[0].isIntersecting && !_loadingMore && _hasMore) { - _loadingMore = true; - try { await loadGridPage(currentPage + 1); } - finally { _loadingMore = false; } - } - }, { rootMargin: '400px' }); - _gridObserver.observe(sentinel); -} - -function stopInfiniteScroll() { - if (_gridObserver) { - _gridObserver.disconnect(); - _gridObserver = null; - } -} - -function formatBadge(fmt, stemCount) { - if (fmt === 'sloppak' && (stemCount || 0) > 1) { - return `STEMS`; - } - if (fmt === 'sloppak') { - return `FEEDPAK`; - } - if (fmt === 'loose') { - return `FOLDER`; - } - return ''; -} - -function formatBadgeInline(fmt, stemCount) { - if (fmt === 'sloppak' && (stemCount || 0) > 1) { - return `STEMS`; - } - if (fmt === 'sloppak') { - return `FEEDPAK`; - } - if (fmt === 'loose') { - return `FOLDER`; - } - return ''; -} - -function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') { - const grid = document.getElementById(containerId); - const screenProviderId = containerId.startsWith('fav') ? 'local' : _activeLibraryProviderId(); - const html = songs.map(song => { - const providerId = _libraryProviderIdForSong(song, screenProviderId); - const localFilename = _libraryLocalFilename(song, providerId); - const songId = _librarySongId(song); - const title = _librarySongTitle(song, providerId); - const artist = song.artist || ''; - const duration = song.duration ? formatTime(song.duration) : ''; - const tuningRaw = song.tuning || song.tuning_name || ''; - const tuning = displayTuningName(tuningRaw); - const artUrl = _librarySongArtUrl(song, providerId); - const isLocalProvider = _isLocalLibraryProvider(providerId); - const isSloppak = song.format === 'sloppak'; - // Use the canonical display label (displayTuningName names raw offset - // strings too), not the raw token, so a row whose tuning is stored as - // offsets still qualifies for the "Convert to E Standard" button. - const stdRetune = isLocalProvider && localFilename && !isSloppak && tuning && !song.has_estd && - ['Eb Standard', 'D Standard', 'C# Standard', 'C Standard'].includes(tuning); - const retuneBtn = stdRetune - ? `` - : ''; - const fmtBadge = formatBadge(song.format, song.stem_count); - const syncStatus = !localFilename ? _librarySyncStatusMarkup(providerId, songId) : ''; - const actionButtons = isLocalProvider && localFilename - ? `${editBtn(song)}${heartBtn(localFilename, song.favorite)}` - : ''; - const canSync = !localFilename && _providerSupports(providerId, 'song.sync'); - const isInteractive = !!localFilename || canSync; - const providerAttr = `data-library-provider="${encodeURIComponent(providerId)}"`; - // For provider-backed entries, keep data-library-song alongside - // data-play once the song is synced so _restoreLibSelection can - // still match the persisted remote selection after a re-render. - const songAttr = !isLocalProvider ? ` data-library-song="${encodeURIComponent(songId)}"` : ''; - const entryAttrs = localFilename - ? `data-play="${encodeURIComponent(localFilename)}" ${providerAttr}${songAttr}` - : `data-library-provider="${encodeURIComponent(providerId)}" data-library-song="${encodeURIComponent(songId)}"`; - const ariaAction = localFilename ? 'Play' : 'Load and play'; - const ariaLabel = `${ariaAction} ${title || _libraryDisplayFilename(song, providerId)}${artist ? ' by ' + artist : ''}`; - const displayLabel = `${title || _libraryDisplayFilename(song, providerId)}${artist ? ' by ' + artist : ''}`; - const interactiveAttrs = isInteractive - ? `tabindex="0" role="button" aria-label="${_escAttr(ariaLabel)}"` - : `role="listitem" aria-label="${_escAttr(displayLabel)}"`; - const artHtml = artUrl - ? ` - ` - : `🎸`; - return `
-
- ${artHtml} - ${fmtBadge} -
-
-
-
-

${esc(title)}

-

${esc(artist)}

-
-
- ${actionButtons} -
-
-
- ${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()} - ${tuning ? `${esc(tuning)}` : ''} - ${song.has_lyrics ? `Lyrics` : ''} - ${song.user_difficulty != null ? `◆${esc(song.user_difficulty)}` : ''} - ${duration ? `${duration}` : ''} -
- ${retuneBtn} - ${syncStatus} -
-
`; - }).join(''); - if (mode === 'append') { - grid.insertAdjacentHTML('beforeend', html); - } else { - grid.innerHTML = html; - } - // Items list invalidation: any DOM mutation to the grid changes - // the result of the next `_libNavItems` call. - _bumpLibNavGeneration(); - // Re-apply the persistent selection after a fresh render so the - // user's last picked card stays highlighted across reloads / a - // round-trip through the player. Skip this during `append` mode - // (infinite scroll) so restoring selection can't re-center the - // viewport and yank the user away from the newly loaded page. - // When a search input is focused the user is actively filtering — - // re-apply the highlight but don't move the viewport (they didn't - // leave the page and their scroll position should be preserved). - if (mode !== 'append') { - const screen = containerId.startsWith('fav') ? 'favorites' : 'home'; - // Scroll only on the first render after a screen entry — - // routine search / sort / filter renders re-apply the - // highlight without moving the viewport. The flag is - // one-shot and consumed here. - const scroll = _libScrollOnNextRender[screen]; - if (scroll) _libScrollOnNextRender[screen] = false; - _restoreLibSelection(grid, screen, { scroll }); - } -} - // ── Tree View (server-side) ───────────────────────────────────────────── -async function loadTreeView() { - const myEpoch = _libEpoch; - if (!_treeStats) { - _setLibraryLoadingMessage('lib-tree', 'lib-count', _libraryLoadingText()); - const q = document.getElementById('lib-filter').value.trim(); - const format = (document.getElementById('lib-format') || {}).value || ''; - const sp = new URLSearchParams(); - if (q) sp.set('q', q); - if (format) sp.set('format', format); - _applyLibraryProviderToParams(sp); - _applyLibFiltersToParams(sp); - const qs = sp.toString(); - try { - _treeStats = await _fetchJsonOrThrow(`/api/library/stats${qs ? '?' + qs : ''}`); - } catch (error) { - if (myEpoch !== _libEpoch) return; - _treeStats = null; - _setLibraryOfflineMessage('lib-tree', 'lib-count', error.message || 'This source appears to be offline.'); - return; - } - if (myEpoch !== _libEpoch) return; - } - const q = document.getElementById('lib-filter').value.trim(); - await renderTreeInto('lib-tree', 'lib-count', _treeStats, _treeLetter, q, false, undefined, myEpoch); -} - -let _treePage = 0; -const TREE_PAGE_SIZE = 50; - -async function renderTreeInto(containerId, countId, stats, letter, q, favoritesOnly, page, expectedEpoch = _libEpoch) { - if (page === undefined) page = favoritesOnly ? _favTreePage || 0 : _treePage; - const container = document.getElementById(containerId); - const screenProviderId = favoritesOnly ? 'local' : _activeLibraryProviderId(); - const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split(''); - const chevron = ``; - - const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter'; - const pageFn = favoritesOnly ? 'goFavTreePage' : 'goTreePage'; - let html = '
'; - html += ``; - for (const l of letters) { - const count = stats.letters[l] || 0; - const active = letter === l; - html += ``; - } - html += '
'; - - // Fetch artists for the selected letter/all - const params = new URLSearchParams(); - if (letter) params.set('letter', letter); - if (q) params.set('q', q); - if (favoritesOnly) params.set('favorites', '1'); - else _applyLibraryProviderToParams(params); - const format = (document.getElementById('lib-format') || {}).value || ''; - if (format) params.set('format', format); - if (!favoritesOnly) _applyLibFiltersToParams(params); - params.set('page', page); - params.set('size', TREE_PAGE_SIZE); - let data; - try { - data = await _fetchJsonOrThrow(`/api/library/artists?${params}`); - } catch (error) { - if (expectedEpoch !== _libEpoch) return; - _setLibraryOfflineMessage(containerId, countId, error.message || 'This source appears to be offline.'); - return; - } - if (expectedEpoch !== _libEpoch) return; - const artists = data.artists || []; - const totalArtists = data.total_artists || 0; - const totalPages = Math.ceil(totalArtists / TREE_PAGE_SIZE); - - let songCount = 0, artistCount = artists.length; - for (const a of artists) songCount += a.song_count; - const pageInfo = totalPages > 1 ? ` · Page ${page + 1} of ${totalPages}` : ''; - document.getElementById(countId).textContent = - `${totalArtists} artists (${songCount} songs on this page)${pageInfo}`; - - // A previous Expand/Collapse-All click is persisted as '1'/'0' and - // overrides the auto-open heuristic for both artists and albums. - // Library and Favorites have independent buttons and independent - // keys (feedBack.libTreeExpand vs feedBack.favTreeExpand) — fed - // off the favoritesOnly flag — so toggling one doesn't flip the - // other's state. Falsy / unset key → fall back to the existing - // heuristic (open when there's an active search or few rows). - const expandKey = favoritesOnly ? _FAV_TREE_EXPAND_KEY : _LIB_TREE_EXPAND_KEY; - const savedExpand = _readPersistedChoice(expandKey, _LIB_TREE_EXPAND_VALUES, null); - const forceArtistOpen = savedExpand === '1'; - const forceArtistClosed = savedExpand === '0'; - - for (const artist of artists) { - const heuristicOpen = q || artists.length <= 5; - const isOpen = forceArtistOpen ? true : forceArtistClosed ? false : heuristicOpen; - const openClass = isOpen ? ' open' : ''; - const artistAria = _escAttr(`Toggle artist ${artist.name}`); - html += `
`; - html += `
`; - html += chevron; - html += `${esc(artist.name)}`; - html += `${artist.song_count} song${artist.song_count !== 1 ? 's' : ''} · ${artist.album_count} album${artist.album_count !== 1 ? 's' : ''}`; - html += `
`; - - for (const album of artist.albums) { - const albumSongs = Array.isArray(album.songs) ? album.songs : []; - const artSong = albumSongs[0] || {}; - const artProviderId = _libraryProviderIdForSong(artSong, screenProviderId); - const artUrl = _librarySongArtUrl(artSong, artProviderId); - const albumHeuristicOpen = q || artist.albums.length === 1; - const albumIsOpen = forceArtistOpen ? true : forceArtistClosed ? false : albumHeuristicOpen; - const albumOpen = albumIsOpen ? ' open' : ''; - const albumAria = _escAttr(`Toggle album ${album.name}`); - html += `
`; - html += `
`; - html += chevron; - if (artUrl) html += ``; - html += `${esc(album.name)}`; - html += `${albumSongs.length}`; - html += `
`; - - for (const song of albumSongs) { - const providerId = _libraryProviderIdForSong(song, screenProviderId); - const localFilename = _libraryLocalFilename(song, providerId); - const songId = _librarySongId(song); - const title = _librarySongTitle(song, providerId); - const duration = song.duration ? formatTime(song.duration) : ''; - const tuningRaw = song.tuning || song.tuning_name || ''; - const tuning = displayTuningName(tuningRaw); - const isLocalProvider = _isLocalLibraryProvider(providerId); - const isSloppak = song.format === 'sloppak'; - const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd && - ['Eb Standard', 'D Standard', 'C# Standard', 'C Standard'].includes(tuningRaw); - const canSyncRow = !localFilename && _providerSupports(providerId, 'song.sync'); - const isInteractiveRow = !!localFilename || canSyncRow; - const providerAttr = `data-library-provider="${encodeURIComponent(providerId)}"`; - // Keep data-library-song alongside data-play for provider-backed - // entries once synced so _restoreLibSelection can still find the - // card after a post-sync re-render. - const rowSongAttr = !isLocalProvider ? ` data-library-song="${encodeURIComponent(songId)}"` : ''; - const rowAttrs = localFilename - ? `data-play="${encodeURIComponent(localFilename)}" ${providerAttr}${rowSongAttr}` - : `data-library-provider="${encodeURIComponent(providerId)}" data-library-song="${encodeURIComponent(songId)}"`; - const ariaAction = localFilename ? 'Play' : 'Load and play'; - const rowAria = _escAttr(`${ariaAction} ${title}${artist.name ? ' by ' + artist.name : ''}`); - const rowDisplayLabel = `${title}${artist.name ? ' by ' + artist.name : ''}`; - const rowInteractiveAttrs = isInteractiveRow - ? `tabindex="0" role="button" aria-label="${rowAria}"` - : `role="listitem" aria-label="${_escAttr(rowDisplayLabel)}"`; - html += `
`; - html += `
${esc(title)}${formatBadgeInline(song.format, song.stem_count)}
`; - html += `
`; - { const _nm = _getArrangementNamingMode(); - for (const arrangement of (song.arrangements || [])) - html += _arrangementBadgeHtml(arrangement, _nm); } - if (tuning) - html += `${esc(tuning)}`; - if (song.has_lyrics) - html += `Lyrics`; - if (song.user_difficulty != null) - html += `◆${esc(song.user_difficulty)}`; - if (duration) - html += `${duration}`; - if (stdRetune) - html += ``; - if (isLocalProvider && localFilename) { - html += editBtn(song); - html += heartBtn(localFilename, song.favorite); - } else if (!localFilename) { - html += _librarySyncStatusMarkup(providerId, songId, 'inline'); - } - html += `
`; - } - html += `
`; - } - html += `
`; - } - - // Pagination - if (totalPages > 1) { - html += '
'; - html += ``; - html += ``; - const start = Math.max(0, page - 2); - const end = Math.min(totalPages, start + 5); - for (let i = start; i < end; i++) { - html += ``; - } - html += ``; - html += ``; - html += '
'; - } - - container.innerHTML = html; - // Items list invalidation — see grid render counterpart. - _bumpLibNavGeneration(); - // Re-apply the persisted selection. For the tree we also expand - // every collapsed ancestor of the saved row so the highlight is - // actually visible — see _restoreLibSelection. Scroll only on - // the first render after a screen entry (one-shot flag set in - // showScreen) so routine renders don't yank the viewport. - const screen = favoritesOnly ? 'favorites' : 'home'; - const scroll = _libScrollOnNextRender[screen]; - if (scroll) _libScrollOnNextRender[screen] = false; - _restoreLibSelection(container, screen, { scroll }); -} - -function goTreePage(p) { - _treePage = Math.max(0, p); - loadTreeView(); - document.getElementById('library-section').scrollIntoView({ behavior: 'smooth' }); -} - -function filterTreeLetter(letter) { - _treeLetter = (_treeLetter === letter) ? '' : letter; - _treePage = 0; - _writePersistedLetter(_LIB_TREE_LETTER_KEY, _treeLetter); - loadTreeView(); -} - -function _toggleAllInTree(containerId, expand, persistKey) { - // Scope the open/close to the named tree's container so toggling - // Library doesn't flip the (offscreen) Favorites DOM and vice - // versa — they share `.artist-row` / `.album-group` classes. - const container = document.getElementById(containerId); - if (!container) return; - container.querySelectorAll('.artist-row').forEach(el => el.classList.toggle('open', expand)); - container.querySelectorAll('.album-group').forEach(el => el.classList.toggle('open', expand)); - // Bulk open/close changes which song-rows pass the visibility - // filter in `_libNavItems` — same reason `_toggleHeader` bumps - // the generation. Without this, a stale cached items list from - // before the toggle would let arrow nav step into now-hidden - // rows. - _bumpLibNavGeneration(); - // Persist the explicit choice so the next page reload (or letter - // change, which re-runs renderTreeInto) honors it instead of - // falling back to the auto-open heuristic. Stored as '1'/'0' so a - // missing key reliably means "no explicit choice". - _writePersistedChoice(persistKey, expand ? '1' : '0'); -} - -function toggleAllArtists(expand) { - _toggleAllInTree('lib-tree', expand, _LIB_TREE_EXPAND_KEY); -} - -function toggleAllFavoriteArtists(expand) { - _toggleAllInTree('fav-tree', expand, _FAV_TREE_EXPAND_KEY); -} - - - - window.displayTuningName = displayTuningName; window.feedBack = window.feedBack || {}; window.slopsmith = window.feedBack; @@ -2423,150 +956,6 @@ window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets; -// Toggle an artist/album header's parent `.open` state and keep -// `aria-expanded` on the header itself in sync so screen readers -// announce the collapsed/expanded transition correctly. Used by -// both the inline onclick (mouse) and the keyboard handlers. -function _toggleHeader(headerEl) { - if (!headerEl) return; - const parent = headerEl.parentElement; - if (!parent) return; - parent.classList.toggle('open'); - headerEl.setAttribute('aria-expanded', parent.classList.contains('open') ? 'true' : 'false'); - // Toggling open/closed changes which song-rows pass the - // visibility filter in `_libNavItems`, so the cached items list - // is now stale. - _bumpLibNavGeneration(); -} - -// Called by the inline onclick on artist- and album-headers so the -// mouse-click path also syncs the persistent `.selected` state — -// keeps arrow-nav resuming from the last-clicked header rather than -// from a stale highlight on a different element. -function _onHeaderClick(el) { - _toggleHeader(el); - _setLibSelection(el, { focus: false }); -} - -// ── Favorites ──────────────────────────────────────────────────────────── -let favView = 'grid'; -let favPage = 0; -let _favTreeLetter = _readPersistedLetter(_FAV_TREE_LETTER_KEY); -let _favTreePage = 0; -let _favTreeStats = null; -let _favDebounce = null; - -function heartBtn(filename, isFav) { - return ``; -} - -function editBtn(song) { - return ``; -} - -async function toggleFavorite(filename) { - const resp = await fetch('/api/favorites/toggle', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename }), - }); - const data = await resp.json(); - // Refresh whichever view is active - const activeScreen = document.querySelector('.screen.active'); - if (activeScreen?.id === 'favorites') loadFavorites(); - else loadLibrary(); - return data.favorite; -} - -function setFavView(view) { - favView = view; - document.getElementById('fav-grid').classList.toggle('hidden', view !== 'grid'); - document.getElementById('fav-tree').classList.toggle('hidden', view !== 'tree'); - document.querySelectorAll('.fav-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid')); - document.querySelectorAll('.fav-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree')); - document.getElementById('fav-view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; - document.getElementById('fav-view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; - const pag = document.getElementById('fav-pagination'); - if (pag && view !== 'grid') pag.innerHTML = ''; - // Same reason as setLibView: dropping the items cache so the - // next keypress re-derives against the now-active container. - _bumpLibNavGeneration(); - loadFavorites(); -} - -async function loadFavorites() { - if (favView === 'grid') await loadFavGridPage(favPage); - else await loadFavTreeView(); -} - -function filterFavorites() { - clearTimeout(_favDebounce); - _favDebounce = setTimeout(() => { favPage = 0; _favTreeLetter = ''; loadFavorites(); }, 250); -} - -function sortFavorites() { favPage = 0; loadFavorites(); } - -async function loadFavGridPage(page = 0) { - const q = document.getElementById('fav-filter').value.trim(); - const sort = document.getElementById('fav-sort').value; - favPage = page; - const params = new URLSearchParams({ q, page, size: PAGE_SIZE, sort, favorites: 1 }); - const resp = await fetch(`/api/library?${params}`); - const data = await resp.json(); - const totalPages = Math.ceil((data.total || 0) / PAGE_SIZE); - document.getElementById('fav-count').textContent = - `${data.total || 0} favorites · Page ${favPage + 1} of ${Math.max(1, totalPages)}`; - renderGridCards(data.songs || [], 'fav-grid'); - renderFavPagination(totalPages); -} - -function renderFavPagination(totalPages) { - let pag = document.getElementById('fav-pagination'); - if (!pag) { - pag = document.createElement('div'); - pag.id = 'fav-pagination'; - pag.className = 'flex items-center justify-center gap-2 py-6'; - document.getElementById('fav-grid').after(pag); - } - if (totalPages <= 1) { pag.innerHTML = ''; return; } - let html = ''; - html += ``; - html += ``; - const start = Math.max(0, favPage - 2); - const end = Math.min(totalPages, start + 5); - for (let i = start; i < end; i++) { - html += ``; - } - html += ``; - html += ``; - pag.innerHTML = html; -} - -function goFavPage(p) { loadFavGridPage(Math.max(0, p)); } - -async function loadFavTreeView() { - if (!_favTreeStats) { - const resp = await fetch('/api/library/stats?favorites=1'); - _favTreeStats = await resp.json(); - } - const q = document.getElementById('fav-filter').value.trim(); - const letter = _favTreeLetter; - // Reuse the tree renderer with fav-tree container and fav-count - await renderTreeInto('fav-tree', 'fav-count', _favTreeStats, letter, q, true); -} - -function filterFavTreeLetter(letter) { - _favTreeLetter = (_favTreeLetter === letter) ? '' : letter; - _favTreePage = 0; - _writePersistedLetter(_FAV_TREE_LETTER_KEY, _favTreeLetter); - loadFavTreeView(); -} - -function goFavTreePage(p) { - _favTreePage = Math.max(0, p); - loadFavTreeView(); -} - // ── Settings ───────────────────────────────────────────────────────────── let _defaultArrangement = ''; @@ -3305,126 +1694,6 @@ async function uploadSongs(fileList) { } } -let _uploadScanPoller = null; - -function _pollScanAndRefresh(statusEl) { - const setStatus = (s) => { if (statusEl) statusEl.textContent = s; }; - if (_uploadScanPoller) _uploadScanPoller.stop(); - - const MAX_FAILURES = 5; - const INTERVAL_MS = 1000; - let stopped = false; - let timerId = null; - let failures = 0; - const stop = () => { - stopped = true; - if (timerId) { clearTimeout(timerId); timerId = null; } - if (_uploadScanPoller && _uploadScanPoller.stop === stop) _uploadScanPoller = null; - }; - _uploadScanPoller = { stop }; - - const tick = async () => { - timerId = null; - try { - const sr = await fetch('/api/scan-status'); - if (!sr.ok) throw new Error(`HTTP ${sr.status}`); - const sd = await sr.json(); - if (stopped) return; - failures = 0; - if (sd.running) { - const cur = sd.current ? ` · ${sd.current}` : ''; - setStatus(`${sd.done} / ${sd.total} scanned${cur}...`); - } else { - stop(); - if (sd.error) setStatus(`Error: ${sd.error}`); - else setStatus('Done!'); - _treeStats = null; - _tuningNames = null; - // Mirror the delete path: refresh whichever collection is - // currently visible. Overwriting a favorited song while - // viewing Favorites otherwise leaves a stale entry. - const activeScreen = document.querySelector('.screen.active'); - if (activeScreen?.id === 'favorites') loadFavorites(); - else loadLibrary(); - return; - } - } catch (e) { - if (stopped) return; - failures++; - if (failures >= MAX_FAILURES) { - stop(); - setStatus(`Scan status unavailable: ${e.message || e}`); - return; - } - } - if (!stopped) timerId = setTimeout(tick, INTERVAL_MS); - }; - timerId = setTimeout(tick, INTERVAL_MS); -} - -async function rescanLibrary() { - const btn = document.getElementById('btn-rescan'); - const status = document.getElementById('rescan-status'); - btn.disabled = true; - btn.textContent = 'Scanning...'; - status.textContent = ''; - const resp = await fetch('/api/rescan', { method: 'POST' }); - const data = await resp.json(); - status.textContent = data.message; - // Poll until done - const poll = setInterval(async () => { - const sr = await fetch('/api/scan-status'); - const sd = await sr.json(); - if (sd.running) { - const cur = sd.current ? ` · ${sd.current}` : ''; - status.textContent = `${sd.done} / ${sd.total} scanned${cur}...`; - } else { - clearInterval(poll); - btn.disabled = false; - btn.textContent = 'Rescan Library'; - status.textContent = sd.error ? `Error: ${sd.error}` : 'Done!'; - _treeStats = null; - _tuningNames = null; // re-fetch on next drawer open - loadLibrary(); - // Tell the v3 Songs grid the library changed so it reloads instead of - // keeping a cached (e.g. pre-DLC, empty) grid until an app restart. - if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' }); - } - }, 1000); -} - -async function fullRescanLibrary() { - if (!confirm('This will clear the entire library cache and re-scan all songs. This can take a long time with large libraries. Continue?')) return; - const btn = document.getElementById('btn-full-rescan'); - const status = document.getElementById('rescan-status'); - btn.disabled = true; - btn.textContent = 'Clearing...'; - const resp = await fetch('/api/rescan/full', { method: 'POST' }); - const data = await resp.json(); - btn.textContent = 'Scanning...'; - status.textContent = data.message; - const poll = setInterval(async () => { - const sr = await fetch('/api/scan-status'); - const sd = await sr.json(); - if (sd.running) { - const cur = sd.current ? ` · ${sd.current}` : ''; - status.textContent = `${sd.done} / ${sd.total} scanned${cur}...`; - } else { - clearInterval(poll); - btn.disabled = false; - btn.textContent = 'Full Rescan'; - status.textContent = sd.error ? `Error: ${sd.error}` : 'Done!'; - _treeStats = null; - _tuningNames = null; // re-fetch on next drawer open - loadLibrary(); - // Tell the v3 Songs grid the library changed so it reloads instead of - // keeping a cached (e.g. pre-DLC, empty) grid until an app restart. - if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' }); - } - }, 1000); -} - - // ── Plugin functions loaded dynamically from plugin screen.js files ────── // (searchCF, installCF, loginCF, searchUG, buildFromUG, etc.) @@ -5832,9 +4101,9 @@ async function deleteSongFromModal(filename) { } const modal = document.getElementById('edit-modal'); if (modal) modal.remove(); - _treeStats = null; - _favTreeStats = null; - _tuningNames = null; + L.treeStats = null; + L.favTreeStats = null; + L.tuningNames = null; // Remove the deleted song's card from any currently-rendered grid/tree // so the user sees it disappear without waiting for a refetch. A full @@ -5859,33 +4128,6 @@ async function deleteSongFromModal(filename) { // Main library grid view: DOM removal above is sufficient. } -function _removeLibCardsForFilename(filename) { - // The grid uses data-play="" on each card; the - // tree's song rows use the same attribute. encodeURIComponent - // matches what renderGridCards / the tree renderer emit. - const encoded = encodeURIComponent(filename); - const selector = `[data-play="${CSS.escape(encoded)}"]`; - let removed = 0; - for (const el of document.querySelectorAll(selector)) { - el.remove(); - removed++; - } - if (removed === 0) return; - // Decrement the visible count badges that loadGridPage / loadTreeView - // populated. Counts come from the server's `total` so this is a - // best-effort estimate until the next refetch, but it keeps the - // displayed number consistent with what's on screen right now. - for (const id of ['lib-count', 'fav-count']) { - const el = document.getElementById(id); - if (!el) continue; - const m = (el.textContent || '').match(/^(\d+)/); - if (!m) continue; - const next = Math.max(0, parseInt(m[1], 10) - removed); - el.textContent = (el.textContent || '').replace(/^\d+/, String(next)); - } - _bumpLibNavGeneration(); -} - async function syncLibrarySong(providerId, songId, options = {}) { const opts = options && typeof options === 'object' ? options : {}; const { playWhenReady = false } = opts; @@ -5917,10 +4159,10 @@ async function syncLibrarySong(providerId, songId, options = {}) { ? 'Ready to play' : (data.cachedPath ? 'Loaded to local cache' : 'Loaded'); _setLibrarySyncState(providerId, songId, { status: 'synced', message, localFilename, result: data }); - _treeStats = null; - _favTreeStats = null; - _tuningNames = null; - _libEpoch++; + L.treeStats = null; + L.favTreeStats = null; + L.tuningNames = null; + L.libEpoch++; await loadLibrary(0); if (playWhenReady && localFilename) playSong(encodeURIComponent(localFilename), undefined, { bridge: false }); return data; @@ -5931,27 +4173,6 @@ async function syncLibrarySong(providerId, songId, options = {}) { } } -function _setLibrarySyncState(providerId, songId, state) { - _librarySyncStates.set(_librarySyncKey(providerId, songId), state); - _renderLibrarySyncState(providerId, songId); -} - -function _renderLibrarySyncState(providerId, songId) { - const state = _librarySyncState(providerId, songId); - // Filter via dataset rather than building a CSS attribute selector — - // CSS.escape is absent in some test environments and older runtimes, - // and provider/song IDs are not constrained to CSS-safe strings. - const encodedProvider = encodeURIComponent(providerId); - const encodedSong = encodeURIComponent(songId); - for (const status of document.querySelectorAll('[data-library-sync-status]')) { - if (status.dataset.librarySyncProvider !== encodedProvider) continue; - if (status.dataset.librarySyncSong !== encodedSong) continue; - const layout = status.classList.contains('ml-1') ? 'inline' : 'block'; - status.className = _librarySyncStatusClass(state, layout); - status.textContent = _librarySyncStatusText(state); - } -} - // Delegated click handlers document.addEventListener('click', e => { // Edit button @@ -6005,89 +4226,6 @@ document.addEventListener('click', e => { } }); -// ── Scan banner (non-blocking) ────────────────────────────────────────── -function showScanBanner() { - if (document.getElementById('scan-banner')) return; - const el = document.createElement('div'); - el.id = 'scan-banner'; - el.className = 'fixed bottom-0 left-0 right-0 z-50 bg-dark-700/95 backdrop-blur border-t border-gray-700 px-6 py-3 flex items-center gap-4'; - el.innerHTML = ` -
-
- Importing Library - 0 / 0 -
-
-

Starting...

- -
- `; - document.body.appendChild(el); -} - -function hideScanBanner() { - const el = document.getElementById('scan-banner'); - if (el) el.remove(); -} - -let _scanPollId = null; - -async function pollScanStatus() { - try { - const resp = await fetch('/api/scan-status'); - const data = await resp.json(); - if (data.stage === 'error' && data.error) { - // Surface the error in the banner and stop polling. - showScanBanner(); - const file = document.getElementById('scan-file'); - const prog = document.getElementById('scan-progress'); - const firstNote = document.getElementById('scan-first-note'); - if (file) { file.textContent = 'Scan failed: ' + data.error; file.classList.add('text-red-400'); } - if (prog) prog.textContent = 'Error'; - if (firstNote) firstNote.classList.add('hidden'); - clearInterval(_scanPollId); - _scanPollId = null; - return; - } - if (data.running) { - showScanBanner(); - const pct = data.total > 0 ? Math.round(data.done / data.total * 100) : 0; - const bar = document.getElementById('scan-bar'); - const prog = document.getElementById('scan-progress'); - const file = document.getElementById('scan-file'); - const firstNote = document.getElementById('scan-first-note'); - if (bar) bar.style.width = pct + '%'; - if (prog) prog.textContent = `${data.done} / ${data.total} (${pct}%)`; - if (file) { - const name = (data.current || '').replace(/_p\.archive$/i, '').replace(/_/g, ' '); - file.textContent = name || (data.stage === 'listing' ? 'Listing DLC folder...' : 'Processing...'); - } - if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan); - } else { - if (document.getElementById('scan-banner')) { - hideScanBanner(); - _treeStats = null; // Refresh stats - loadLibrary(); - } - clearInterval(_scanPollId); - _scanPollId = null; - } - } catch (e) { /* ignore */ } -} - -async function checkScanAndLoad() { - const resp = await fetch('/api/scan-status'); - const data = await resp.json(); - if (data.running) { - showScanBanner(); - const firstNote = document.getElementById('scan-first-note'); - if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan); - _scanPollId = setInterval(pollScanStatus, 1000); - } - loadLibrary(); -} - - // Load library on start. loadSettings is awaited alongside so persisted // values (A/V offset, mastery, etc.) are applied to the highway + HUD // before any playSong runs — otherwise a fast click could start diff --git a/static/js/library-state.js b/static/js/library-state.js new file mode 100644 index 0000000..ae122c3 --- /dev/null +++ b/static/js/library-state.js @@ -0,0 +1,29 @@ +// Shared, MUTABLE library state. +// +// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is READ-ONLY: +// `import { _treeStats }; _treeStats = x` throws. Of the library module's 28 outward +// bindings, 23 are only ever READ from outside, so they stay plain exports. These five +// are genuinely WRITTEN from outside — by showScreen (session teardown bumps the epoch, +// resets the page), deleteSongFromModal, and syncLibrarySong, none of which can move into +// the library module because they reach the playSong/showScreen core. +// +// So exactly these five move onto an object, and no more. `L.treeStats = x` is a property +// write, which works from any module holding the same `L`. Same shape as ./player-state.js. +// +// Add to it when a carve actually needs it, not before — a container is a shared mutable +// global with better manners, and every field on it is a coupling you have to keep true. +export const L = { + /** Library tree stats (artist -> counts), cached from /api/library/tree-stats. */ + treeStats: null, + /** Same, for the favourites tree. */ + favTreeStats: null, + /** Tuning names, cached from /api/library/tuning-names. */ + tuningNames: null, + /** + * Session generation for the library. Bumped on teardown so an in-flight page fetch + * that resolves against a stale library can't render into the new one. + */ + libEpoch: 0, + /** Current grid page (0-based). */ + currentPage: 0, +}; diff --git a/static/js/library.js b/static/js/library.js new file mode 100644 index 0000000..1d358e5 --- /dev/null +++ b/static/js/library.js @@ -0,0 +1,1988 @@ +// 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; +} + +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 's displayed value, so + // re-render to snap it back to the provider that is actually active. + _renderLibraryProviderSelector(); + return; + } + _renderLibraryProviderSelector(); + _resetLibraryProviderViewState(); + loadLibrary(0); +} + +function _libraryProviderIdForSong(song, fallbackProviderId) { + return String( + song.provider_id || song.providerId || song.library_provider_id || + song.libraryProviderId || song.provider || fallbackProviderId || 'local' + ); +} + +export function _librarySongId(song) { + const songId = song.song_id || song.songId || song.remote_id || song.remoteId || song.id || song.filename || ''; + return String(songId || ''); +} + +export function _libraryLocalFilename(song, providerId) { + if (_isLocalLibraryProvider(providerId)) return song.filename ? String(song.filename) : ''; + const filename = song.local_filename || song.localFilename || song.synced_filename || + song.syncedFilename || song.play_filename || song.playFilename || ''; + if (filename) return String(filename); + const state = _librarySyncState(providerId, _librarySongId(song)); + return state && state.status === 'synced' && state.localFilename ? String(state.localFilename) : ''; +} + +function _libraryDisplayFilename(song, providerId) { + return _libraryLocalFilename(song, providerId) || _librarySongId(song) || 'Unknown song'; +} + +function _librarySongTitle(song, providerId) { + const fallback = _libraryDisplayFilename(song, providerId); + return song.title || fallback.replace(/_p\.archive$/i, '').replace(/_/g, ' '); +} + +export function _librarySongArtUrl(song, providerId) { + const explicitArt = song.art_url || song.artUrl || song.cover_url || song.coverUrl; + if (explicitArt) return _safeImageUrl(explicitArt); + const version = song.mtime ? `?v=${Math.floor(song.mtime)}` : ''; + const localFilename = _libraryLocalFilename(song, providerId); + if (localFilename) return `/api/song/${encodeURIComponent(localFilename)}/art${version}`; + if (_isLocalLibraryProvider(providerId)) return ''; + if (!_providerSupports(providerId, 'art.read')) return ''; + const songId = _librarySongId(song); + return songId ? `/api/library/providers/${encodeURIComponent(providerId)}/songs/${encodeURIComponent(songId)}/art${version}` : ''; +} + +function _safeImageUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + try { + const parsed = new URL(raw, window.location.origin); + return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : ''; + } catch { + return ''; + } +} + +const _librarySyncStates = new Map(); + +function _librarySyncKey(providerId, songId) { + // JSON.stringify avoids delimiter collision: a newline in either value + // would make "${p}\n${s}" ambiguous, but JSON-serialised arrays are + // always distinct for distinct (providerId, songId) pairs. + return JSON.stringify([providerId, songId]); +} + +export function _librarySyncState(providerId, songId) { + return _librarySyncStates.get(_librarySyncKey(providerId, songId)) || null; +} + +function _librarySyncStatusText(state) { + if (!state) return ''; + if (state.status === 'syncing') return 'Loading package...'; + if (state.status === 'synced') return state.message || 'Ready to play'; + if (state.status === 'error') return state.message ? `Load failed: ${state.message}` : 'Load failed'; + return ''; +} + +function _librarySyncStatusClass(state, layout) { + const base = layout === 'inline' + ? 'library-sync-status inline-block text-[11px] ml-1' + : 'library-sync-status block mt-1 text-[11px] leading-snug'; + if (!state) return `${base} hidden text-gray-500`; + if (state.status === 'error') return `${base} text-red-300`; + if (state.status === 'synced') return `${base} text-green-300`; + return `${base} text-gray-400`; +} + +function _librarySyncStatusMarkup(providerId, songId, layout = 'block') { + const state = _librarySyncState(providerId, songId); + return `${esc(_librarySyncStatusText(state))}`; +} + +export let libView = _readPersistedChoice(_LIB_VIEW_KEY, _LIB_VIEW_VALUES, 'grid'); + +const PAGE_SIZE = 24; + +// Tree letter selection persists across reloads / coming back from +// the player so the user lands on the same alphabet group they +// picked. Validation: any single uppercase letter, or `#` for +// non-alphabetical artists, or `''` for the All bucket. +const _LIB_TREE_LETTER_KEY = 'feedBack.libTreeLetter'; + +const _FAV_TREE_LETTER_KEY = 'feedBack.favTreeLetter'; + +function _readPersistedLetter(key) { + let v = null; + try { v = localStorage.getItem(key); } catch { return ''; } + if (v === null) return ''; + return (v === '' || v === '#' || /^[A-Z]$/.test(v)) ? v : ''; +} + +function _writePersistedLetter(key, value) { + try { localStorage.setItem(key, value || ''); } catch { /* private mode / quota */ } +} + +let _treeLetter = _readPersistedLetter(_LIB_TREE_LETTER_KEY); + +let _debounceTimer = null; + +let _loadingMore = false; + +let _hasMore = true; + +let _gridObserver = null; + +// ── Library filters (feedBack#129/#69) ──────────────────────────────── +// +// Filter state lives in a single object so the active set can be +// serialized to localStorage as one key. Each axis is OR-within (Lead +// + Rhythm = "has Lead OR Rhythm"); cross-axis is AND. Tri-state pills +// translate to `_has` / `_lacks` lists on the wire so the server's +// SQL doesn't have to encode the third "any" state. +// In smart mode Combo is subsumed into Lead; only show Lead/Rhythm/Bass. +// In legacy mode keep the original four values. +// In-memory cache so a localStorage.setItem failure (private mode / quota / +// disabled storage) still keeps the chosen mode for the rest of the session. +// Initialised lazily from localStorage on first read. +let _arrangementNamingMode = null; + +export function _getArrangementNamingMode() { + if (_arrangementNamingMode === 'smart' || _arrangementNamingMode === 'legacy') { + return _arrangementNamingMode; + } + try { + _arrangementNamingMode = localStorage.getItem('arrangementNamingMode') === 'legacy' ? 'legacy' : 'smart'; + } catch (_) { + _arrangementNamingMode = 'smart'; + } + return _arrangementNamingMode; +} + +// In smart mode 'Combo' is subsumed into 'Lead' (_ensure_smart_names maps it +// the same way). Normalize any persisted 'Combo' tokens before querying or +// rendering so the UI and the server stay in sync. +function _toSmartArrs(arr) { + return arr.map(a => a === 'Combo' ? 'Lead' : a); +} + +export function _onNamingModeChange(value) { + const mode = value === 'legacy' ? 'legacy' : 'smart'; + _arrangementNamingMode = mode; + try { localStorage.setItem('arrangementNamingMode', mode); } catch (_) {} + if (mode === 'smart') { + _libFilters.arrHas = _toSmartArrs(_libFilters.arrHas); + _libFilters.arrLacks = _toSmartArrs(_libFilters.arrLacks); + _saveLibFilters(); + } + _renderLibFilterDrawer(); + _renderLibFilterChips(); + L.libEpoch++; + L.currentPage = 0; + L.treeStats = null; + loadLibrary(0); +} + +function _getArrangements() { + return _getArrangementNamingMode() === 'smart' + ? ['Lead', 'Rhythm', 'Bass'] + : ['Lead', 'Rhythm', 'Bass', 'Combo']; +} + +function _arrangementBadgeHtml(arrangement, nm) { + const label = (nm === 'smart' && arrangement.smart_name) ? arrangement.smart_name : arrangement.name; + const cls = label.includes('Lead') ? 'bg-red-900/40 text-red-300' : + label.includes('Rhythm') ? 'bg-blue-900/40 text-blue-300' : + label.includes('Bass') ? 'bg-green-900/40 text-green-300' : + 'bg-dark-600 text-gray-400'; + return `${esc(label)}`; +} + +// Stem ids match the bare strings sloppak manifests use ("drums", +// "bass", etc.). `full` is intentionally omitted from the filter UI: +// it's the fallback mix every sloppak ships with, so filtering by it +// would match all sloppaks and confuse users. +const _STEM_DEFS = [ + { id: 'drums', label: 'Drums' }, + { id: 'bass', label: 'Bass' }, + { id: 'vocals', label: 'Vocals' }, + { id: 'guitar', label: 'Guitar' }, + { id: 'piano', label: 'Piano' }, + { id: 'other', label: 'Other' }, +]; + +const _LIB_FILTERS_KEY = 'feedBack.libFilters'; + +let _libFilters = _loadLibFilters(); + +function _defaultLibFilters() { + return { + arrHas: [], arrLacks: [], + stemsHas: [], stemsLacks: [], + lyrics: null, // null | 1 | 0 + tunings: [], + }; +} + +function _normalizeStringArray(v) { + return Array.isArray(v) ? v.filter(x => typeof x === 'string' && x) : []; +} + +function _normalizeLibFilters(parsed) { + // Defensive: a stale or hand-edited localStorage payload could have + // any shape. Without normalization a later `.join` or `.includes` + // on a non-array would throw at filter-apply time. Coerce each + // field back to its expected type, dropping anything we don't + // recognize. FeedBack#134 review. + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return _defaultLibFilters(); + } + const lyrics = parsed.lyrics; + return { + arrHas: _normalizeStringArray(parsed.arrHas), + arrLacks: _normalizeStringArray(parsed.arrLacks), + stemsHas: _normalizeStringArray(parsed.stemsHas), + stemsLacks: _normalizeStringArray(parsed.stemsLacks), + lyrics: lyrics === 0 || lyrics === 1 ? lyrics : null, + tunings: _normalizeStringArray(parsed.tunings), + }; +} + +function _loadLibFilters() { + try { + const raw = localStorage.getItem(_LIB_FILTERS_KEY); + if (!raw) return _defaultLibFilters(); + const filters = _normalizeLibFilters(JSON.parse(raw)); + // Normalize any stale 'Combo' tokens left from legacy-mode sessions. + if (_getArrangementNamingMode() === 'smart') { + filters.arrHas = _toSmartArrs(filters.arrHas); + filters.arrLacks = _toSmartArrs(filters.arrLacks); + } + return filters; + } catch { + return _defaultLibFilters(); + } +} + +function _saveLibFilters() { + try { localStorage.setItem(_LIB_FILTERS_KEY, JSON.stringify(_libFilters)); } + catch { /* private mode / quota — ignore, in-memory state still works */ } +} + +function _libActiveCount() { + let n = 0; + if (_libFilters.arrHas.length) n++; + if (_libFilters.arrLacks.length) n++; + if (_libFilters.stemsHas.length) n++; + if (_libFilters.stemsLacks.length) n++; + if (_libFilters.lyrics !== null) n++; + if (_libFilters.tunings.length) n++; + return n; +} + +export function _applyLibFiltersToParams(params) { + const nm = _getArrangementNamingMode(); + params.set('naming_mode', nm); + const arrHas = nm === 'smart' ? _toSmartArrs(_libFilters.arrHas) : _libFilters.arrHas; + const arrLacks = nm === 'smart' ? _toSmartArrs(_libFilters.arrLacks) : _libFilters.arrLacks; + if (arrHas.length) params.set('arrangements_has', arrHas.join(',')); + if (arrLacks.length) params.set('arrangements_lacks', arrLacks.join(',')); + if (_libFilters.stemsHas.length) params.set('stems_has', _libFilters.stemsHas.join(',')); + if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(',')); + if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics)); + if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(',')); + return params; +} + +function _pillState(item, hasList, lacksList) { + if (hasList.includes(item)) return 'require'; + if (lacksList.includes(item)) return 'exclude'; + return 'any'; +} + +function _cyclePill(item, hasKey, lacksKey) { + // Cycle: any -> require -> exclude -> any. Mutates _libFilters in place. + const hasList = _libFilters[hasKey]; + const lacksList = _libFilters[lacksKey]; + const inHas = hasList.indexOf(item); + const inLacks = lacksList.indexOf(item); + if (inHas === -1 && inLacks === -1) { + hasList.push(item); + } else if (inHas !== -1) { + hasList.splice(inHas, 1); + lacksList.push(item); + } else { + lacksList.splice(inLacks, 1); + } + _saveLibFilters(); + _renderLibFilterDrawer(); + _renderLibFilterChips(); + L.libEpoch++; + L.currentPage = 0; + L.treeStats = null; // letter bar counts depend on filters now + loadLibrary(0); +} + +function _renderPillRow(containerId, items, hasKey, lacksKey, labelFor) { + const c = document.getElementById(containerId); + if (!c) return; + c.innerHTML = ''; + for (const it of items) { + const id = typeof it === 'string' ? it : it.id; + const label = labelFor ? labelFor(it) : id; + const state = _pillState(id, _libFilters[hasKey], _libFilters[lacksKey]); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `filter-pill state-${state}`; + btn.textContent = label; + btn.onclick = () => _cyclePill(id, hasKey, lacksKey); + c.appendChild(btn); + } +} + +function _renderLyricsPill() { + // Single tri-state pill matching the arrangement / stem pattern. + // Cycle: any (null) -> require (1) -> exclude (0) -> any. + const c = document.getElementById('filter-lyrics'); + if (!c) return; + c.innerHTML = ''; + const v = _libFilters.lyrics; + const state = v === 1 ? 'require' : v === 0 ? 'exclude' : 'any'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `filter-pill state-${state}`; + btn.textContent = 'Lyrics'; + btn.onclick = () => { + _libFilters.lyrics = v === null ? 1 : v === 1 ? 0 : null; + _saveLibFilters(); + _renderLyricsPill(); + _renderLibFilterChips(); + L.libEpoch++; + L.currentPage = 0; + L.treeStats = null; + loadLibrary(0); + }; + c.appendChild(btn); +} + +async function _renderTuningList() { + const c = document.getElementById('filter-tunings'); + if (!c) return; + let fetchError = null; + if (!L.tuningNames) { + const myEpoch = L.libEpoch; + c.innerHTML = '
Loading...
'; + try { + const params = _applyLibraryProviderToParams(new URLSearchParams()); + const resp = await fetch(`/api/library/tuning-names?${params}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + // Guard against a provider switch that invalidated _tuningNames + // while this request was in flight — discard a stale result. + if (myEpoch !== L.libEpoch) return; + L.tuningNames = Array.isArray(data.tunings) ? data.tunings : []; + } catch (e) { + if (myEpoch !== L.libEpoch) return; + // Distinguish a server / network failure from "the DB + // genuinely has no tunings indexed". The latter wants a + // Full Rescan; the former just wants a retry. Don't cache + // the failure — leave _tuningNames null so reopening the + // drawer triggers a fresh attempt. + L.tuningNames = null; + fetchError = e.message || 'request failed'; + } + } + c.innerHTML = ''; + if (fetchError) { + c.innerHTML = `
Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.
`; + return; + } + if (!L.tuningNames.length) { + c.innerHTML = '
No tunings indexed yet — try Full Rescan.
'; + return; + } + for (const t of L.tuningNames) { + // Filter on the server grouping key (offsets for customs, name for named + // tunings); label custom pills with their target notes so two "Custom + // Tuning" entries are distinguishable. See tuning_names() in server.py. + const val = t.key || t.name; + let label = t.name; + if (t.name === 'Custom Tuning' && t.offsets + && typeof window.parseRawTuningOffsets === 'function' + && typeof window.displayTuningTargets === 'function') { + const offs = window.parseRawTuningOffsets(t.offsets); + const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : ''; + if (notes) label = 'Custom · ' + notes; + } + const checked = _libFilters.tunings.includes(val); + const row = document.createElement('label'); + row.className = 'tuning-row'; + row.innerHTML = + `` + + `${esc(label)}` + + `${t.count}`; + const cb = row.querySelector('input'); + cb.onchange = () => { + const i = _libFilters.tunings.indexOf(val); + if (cb.checked && i === -1) _libFilters.tunings.push(val); + else if (!cb.checked && i !== -1) _libFilters.tunings.splice(i, 1); + _saveLibFilters(); + _updateLibFiltersBadge(); + _renderLibFilterChips(); + _renderTuningSummary(); + L.libEpoch++; + L.currentPage = 0; + L.treeStats = null; + loadLibrary(0); + }; + c.appendChild(row); + } + _renderTuningSummary(); +} + +function _renderTuningSummary() { + const s = document.getElementById('filter-tunings-summary'); + if (!s) return; + if (!_libFilters.tunings.length) { s.textContent = 'All tunings'; return; } + if (_libFilters.tunings.length === 1) { s.textContent = _libFilters.tunings[0]; return; } + s.textContent = `${_libFilters.tunings[0]} +${_libFilters.tunings.length - 1}`; +} + +export function _updateLibFiltersBadge() { + const badge = document.getElementById('lib-filters-count'); + if (!badge) return; + const n = _libActiveCount(); + badge.textContent = String(n); + badge.classList.toggle('hidden', n === 0); +} + +function _renderLibFilterDrawer() { + _renderPillRow('filter-arrangements', _getArrangements(), 'arrHas', 'arrLacks'); + _renderPillRow('filter-stems', _STEM_DEFS, 'stemsHas', 'stemsLacks', s => s.label); + _renderLyricsPill(); + _updateLibFiltersBadge(); +} + +export function _renderLibFilterChips() { + const row = document.getElementById('lib-filter-chips'); + if (!row) return; + const chips = []; + for (const a of _libFilters.arrHas) chips.push({ label: a, kind: 'require', remove: () => _libFilters.arrHas = _libFilters.arrHas.filter(x => x !== a) }); + for (const a of _libFilters.arrLacks) chips.push({ label: `no ${a}`, kind: 'exclude', remove: () => _libFilters.arrLacks = _libFilters.arrLacks.filter(x => x !== a) }); + for (const s of _libFilters.stemsHas) { + const def = _STEM_DEFS.find(d => d.id === s); + chips.push({ label: def ? def.label : s, kind: 'require', remove: () => _libFilters.stemsHas = _libFilters.stemsHas.filter(x => x !== s) }); + } + for (const s of _libFilters.stemsLacks) { + const def = _STEM_DEFS.find(d => d.id === s); + chips.push({ label: `no ${def ? def.label : s}`, kind: 'exclude', remove: () => _libFilters.stemsLacks = _libFilters.stemsLacks.filter(x => x !== s) }); + } + if (_libFilters.lyrics === 1) chips.push({ label: 'has lyrics', kind: 'require', remove: () => _libFilters.lyrics = null }); + if (_libFilters.lyrics === 0) chips.push({ label: 'no lyrics', kind: 'exclude', remove: () => _libFilters.lyrics = null }); + for (const t of _libFilters.tunings) chips.push({ label: t, kind: 'require', remove: () => _libFilters.tunings = _libFilters.tunings.filter(x => x !== t) }); + + row.innerHTML = ''; + if (!chips.length) { + row.classList.add('hidden'); + return; + } + row.classList.remove('hidden'); + for (const c of chips) { + const el = document.createElement('span'); + el.className = `chip ${c.kind === 'exclude' ? 'chip-exclude' : ''}`; + // The "×" glyph isn't a reliable accessible name; assistive tech + // also can't depend on `title` alone. Spell out the action plus + // the chip's label in `aria-label` so screen-reader users hear + // "Remove filter: Lead" instead of "button" or just "×". + const ariaLabel = `Remove filter: ${c.label}`; + el.innerHTML = + `${esc(c.label)}`; + el.querySelector('button').onclick = () => { + c.remove(); + _saveLibFilters(); + _renderLibFilterDrawer(); + _renderLibFilterChips(); + L.libEpoch++; + L.currentPage = 0; + L.treeStats = null; + loadLibrary(0); + }; + row.appendChild(el); + } +} + +export function toggleLibFilters(force) { + const drawer = document.getElementById('lib-filter-drawer'); + const overlay = document.getElementById('lib-filter-overlay'); + if (!drawer) return; + const open = force === undefined ? !drawer.classList.contains('open') : !!force; + drawer.classList.toggle('open', open); + overlay.classList.toggle('hidden', !open); + if (open) { + _renderLibFilterDrawer(); + _renderTuningList(); + } +} + +export function clearLibFilters() { + _libFilters = _defaultLibFilters(); + _saveLibFilters(); + _renderLibFilterDrawer(); + _renderTuningList(); + _renderLibFilterChips(); + L.libEpoch++; + L.currentPage = 0; + L.treeStats = null; + loadLibrary(0); +} + +export function setLibView(view) { + libView = view; + if (_LIB_VIEW_VALUES.has(view)) _writePersistedChoice(_LIB_VIEW_KEY, view); + document.getElementById('lib-grid').classList.toggle('hidden', view !== 'grid'); + document.getElementById('lib-tree').classList.toggle('hidden', view !== 'tree'); + document.querySelectorAll('.lib-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid')); + document.querySelectorAll('.lib-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree')); + document.querySelectorAll('.lib-nontree-ctrl').forEach(el => el.classList.toggle('hidden', view === 'tree')); + document.getElementById('view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; + document.getElementById('view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; + // Folder view + const folderTreeEl = document.getElementById('lib-folder-tree'); + if (folderTreeEl) folderTreeEl.classList.toggle('hidden', view !== 'folder'); + const folderCtrlEl = document.getElementById('lib-folder-controls'); + if (folderCtrlEl) folderCtrlEl.classList.toggle('hidden', view !== 'folder'); + // The folder-view toolbar button only exists in the classic (v2) markup; + // setLibView also runs at v3 startup where it's absent, so guard it (the + // grid/tree buttons above predate this and exist on both paths). + const folderBtnEl = document.getElementById('view-folder-btn'); + if (folderBtnEl) folderBtnEl.className = `px-3 py-2.5 text-sm transition ${view === 'folder' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; + if (libView === 'folder' && view !== 'folder') window.folderLibrary?.unload?.(); + if (view !== 'grid') stopInfiniteScroll(); + L.libEpoch++; + // View toggle changes which container `_libNavItems` resolves + // to (tree vs grid) — drop the cache so the next keypress + // re-derives. + _bumpLibNavGeneration(); + loadLibrary(); +} + +export async function loadLibrary(page) { + if (libView === 'grid') { + await loadGridPage(page !== undefined ? page : L.currentPage); + } else if (libView === 'tree') { + await loadTreeView(); + } else if (libView === 'folder') { + if (window.folderLibrary) await window.folderLibrary.load(); + } + // v3 Songs page manages its own view state independently of libView — if + // lib-folder-tree is visible, the folder library must also react to filter changes. + if (libView !== 'folder' && window.folderLibrary) { + const treeEl = document.getElementById('lib-folder-tree'); + if (treeEl && !treeEl.classList.contains('hidden')) { + await window.folderLibrary.load(); + } + } +} + +async function _fetchJsonOrThrow(url) { + const resp = await fetch(url); + const raw = await resp.text(); + let data = {}; + let parseError = null; + if (raw) { + try { + data = JSON.parse(raw); + } catch (error) { + parseError = error; + } + } + if (!resp.ok) { + const detail = String(data.detail || data.error || data.message || '').trim(); + throw new Error(detail || `HTTP ${resp.status}`); + } + if (parseError) throw new Error('Malformed JSON response'); + return data; +} + +function _setLibraryOfflineMessage(containerId, countId, message) { + const container = document.getElementById(containerId); + const count = document.getElementById(countId); + if (count) count.textContent = 'Source appears offline'; + if (container) { + container.innerHTML = `
${esc(message || 'This source appears to be offline.')}
`; + } +} + +function _setLibraryLoadingMessage(containerId, countId, message) { + const container = document.getElementById(containerId); + const count = document.getElementById(countId); + if (count) count.textContent = 'Loading source...'; + if (container) { + container.innerHTML = `
${esc(message || 'Loading library...')}
`; + } +} + +function _libraryLoadingText() { + const provider = _activeLibraryProvider(); + if (!provider || provider.id === 'local' || provider.kind === 'local') { + return 'Loading library...'; + } + return `Connecting to ${provider.label || provider.id}...`; +} + +export function filterLibrary() { + clearTimeout(_debounceTimer); + _debounceTimer = setTimeout(() => { + L.libEpoch++; + L.currentPage = 0; + _treeLetter = ''; + // Letter-bar counts depend on `q` and the active filter set — + // any change to those must invalidate the tree-view stats + // cache or the next switch to tree view will render stale + // letter counts (feedBack#134 review). + L.treeStats = null; + loadLibrary(0); + }, 250); +} + +export function sortLibrary() { + // Persist whichever of the two dropdowns just changed so the next + // page load can restore both. Both selects route through this + // handler today; reading both is cheap and keeps the function + // single-purpose. + const sortEl = document.getElementById('lib-sort'); + if (sortEl && _LIB_SORT_VALUES.has(sortEl.value)) { + _writePersistedChoice(_LIB_SORT_KEY, sortEl.value); + } + const fmtEl = document.getElementById('lib-format'); + if (fmtEl && _LIB_FORMAT_VALUES.has(fmtEl.value)) { + _writePersistedChoice(_LIB_FORMAT_KEY, fmtEl.value); + } + L.libEpoch++; + L.currentPage = 0; + // Same reason as filterLibrary: format dropdown changes the stats + // payload, so the cache must drop too. + L.treeStats = null; + loadLibrary(0); +} + +async function loadGridPage(page = 0) { + const myEpoch = L.libEpoch; + const q = document.getElementById('lib-filter').value.trim(); + const sort = document.getElementById('lib-sort').value; + const format = (document.getElementById('lib-format') || {}).value || ''; + const params = new URLSearchParams({ q, page, size: PAGE_SIZE, sort }); + if (format) params.set('format', format); + _applyLibraryProviderToParams(params); + _applyLibFiltersToParams(params); + if (page === 0) { + _setLibraryLoadingMessage('lib-grid', 'lib-count', _libraryLoadingText()); + } + let data; + try { + data = await _fetchJsonOrThrow(`/api/library?${params}`); + } catch (error) { + if (myEpoch !== L.libEpoch) return; + L.currentPage = 0; + _hasMore = false; + stopInfiniteScroll(); + _setLibraryOfflineMessage('lib-grid', 'lib-count', error.message || 'This source appears to be offline.'); + return; + } + if (myEpoch !== L.libEpoch) return; // filter/sort/view changed mid-fetch + + L.currentPage = page; + const total = data.total || 0; + const songs = data.songs || []; + document.getElementById('lib-count').textContent = `${total} songs`; + + renderGridCards(songs, 'lib-grid', page === 0 ? 'replace' : 'append'); + + _hasMore = (page + 1) * PAGE_SIZE < total; + setupInfiniteScroll(); +} + +function setupInfiniteScroll() { + let sentinel = document.getElementById('lib-grid-sentinel'); + if (!sentinel) { + sentinel = document.createElement('div'); + sentinel.id = 'lib-grid-sentinel'; + sentinel.style.height = '1px'; + document.getElementById('lib-grid').after(sentinel); + } + stopInfiniteScroll(); + if (!_hasMore) return; + _gridObserver = new IntersectionObserver(async (entries) => { + if (entries[0].isIntersecting && !_loadingMore && _hasMore) { + _loadingMore = true; + try { await loadGridPage(L.currentPage + 1); } + finally { _loadingMore = false; } + } + }, { rootMargin: '400px' }); + _gridObserver.observe(sentinel); +} + +export function stopInfiniteScroll() { + if (_gridObserver) { + _gridObserver.disconnect(); + _gridObserver = null; + } +} + +function formatBadge(fmt, stemCount) { + if (fmt === 'sloppak' && (stemCount || 0) > 1) { + return `STEMS`; + } + if (fmt === 'sloppak') { + return `FEEDPAK`; + } + if (fmt === 'loose') { + return `FOLDER`; + } + return ''; +} + +function formatBadgeInline(fmt, stemCount) { + if (fmt === 'sloppak' && (stemCount || 0) > 1) { + return `STEMS`; + } + if (fmt === 'sloppak') { + return `FEEDPAK`; + } + if (fmt === 'loose') { + return `FOLDER`; + } + return ''; +} + +export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') { + const grid = document.getElementById(containerId); + const screenProviderId = containerId.startsWith('fav') ? 'local' : _activeLibraryProviderId(); + const html = songs.map(song => { + const providerId = _libraryProviderIdForSong(song, screenProviderId); + const localFilename = _libraryLocalFilename(song, providerId); + const songId = _librarySongId(song); + const title = _librarySongTitle(song, providerId); + const artist = song.artist || ''; + const duration = song.duration ? formatTime(song.duration) : ''; + const tuningRaw = song.tuning || song.tuning_name || ''; + const tuning = displayTuningName(tuningRaw); + const artUrl = _librarySongArtUrl(song, providerId); + const isLocalProvider = _isLocalLibraryProvider(providerId); + const isSloppak = song.format === 'sloppak'; + // Use the canonical display label (displayTuningName names raw offset + // strings too), not the raw token, so a row whose tuning is stored as + // offsets still qualifies for the "Convert to E Standard" button. + const stdRetune = isLocalProvider && localFilename && !isSloppak && tuning && !song.has_estd && + ['Eb Standard', 'D Standard', 'C# Standard', 'C Standard'].includes(tuning); + const retuneBtn = stdRetune + ? `` + : ''; + const fmtBadge = formatBadge(song.format, song.stem_count); + const syncStatus = !localFilename ? _librarySyncStatusMarkup(providerId, songId) : ''; + const actionButtons = isLocalProvider && localFilename + ? `${editBtn(song)}${heartBtn(localFilename, song.favorite)}` + : ''; + const canSync = !localFilename && _providerSupports(providerId, 'song.sync'); + const isInteractive = !!localFilename || canSync; + const providerAttr = `data-library-provider="${encodeURIComponent(providerId)}"`; + // For provider-backed entries, keep data-library-song alongside + // data-play once the song is synced so _restoreLibSelection can + // still match the persisted remote selection after a re-render. + const songAttr = !isLocalProvider ? ` data-library-song="${encodeURIComponent(songId)}"` : ''; + const entryAttrs = localFilename + ? `data-play="${encodeURIComponent(localFilename)}" ${providerAttr}${songAttr}` + : `data-library-provider="${encodeURIComponent(providerId)}" data-library-song="${encodeURIComponent(songId)}"`; + const ariaAction = localFilename ? 'Play' : 'Load and play'; + const ariaLabel = `${ariaAction} ${title || _libraryDisplayFilename(song, providerId)}${artist ? ' by ' + artist : ''}`; + const displayLabel = `${title || _libraryDisplayFilename(song, providerId)}${artist ? ' by ' + artist : ''}`; + const interactiveAttrs = isInteractive + ? `tabindex="0" role="button" aria-label="${_escAttr(ariaLabel)}"` + : `role="listitem" aria-label="${_escAttr(displayLabel)}"`; + const artHtml = artUrl + ? ` + ` + : `🎸`; + return `
+
+ ${artHtml} + ${fmtBadge} +
+
+
+
+

${esc(title)}

+

${esc(artist)}

+
+
+ ${actionButtons} +
+
+
+ ${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()} + ${tuning ? `${esc(tuning)}` : ''} + ${song.has_lyrics ? `Lyrics` : ''} + ${song.user_difficulty != null ? `◆${esc(song.user_difficulty)}` : ''} + ${duration ? `${duration}` : ''} +
+ ${retuneBtn} + ${syncStatus} +
+
`; + }).join(''); + if (mode === 'append') { + grid.insertAdjacentHTML('beforeend', html); + } else { + grid.innerHTML = html; + } + // Items list invalidation: any DOM mutation to the grid changes + // the result of the next `_libNavItems` call. + _bumpLibNavGeneration(); + // Re-apply the persistent selection after a fresh render so the + // user's last picked card stays highlighted across reloads / a + // round-trip through the player. Skip this during `append` mode + // (infinite scroll) so restoring selection can't re-center the + // viewport and yank the user away from the newly loaded page. + // When a search input is focused the user is actively filtering — + // re-apply the highlight but don't move the viewport (they didn't + // leave the page and their scroll position should be preserved). + if (mode !== 'append') { + const screen = containerId.startsWith('fav') ? 'favorites' : 'home'; + // Scroll only on the first render after a screen entry — + // routine search / sort / filter renders re-apply the + // highlight without moving the viewport. The flag is + // one-shot and consumed here. + const scroll = _libScrollOnNextRender[screen]; + if (scroll) _libScrollOnNextRender[screen] = false; + _restoreLibSelection(grid, screen, { scroll }); + } +} + +export async function loadTreeView() { + const myEpoch = L.libEpoch; + if (!L.treeStats) { + _setLibraryLoadingMessage('lib-tree', 'lib-count', _libraryLoadingText()); + const q = document.getElementById('lib-filter').value.trim(); + const format = (document.getElementById('lib-format') || {}).value || ''; + const sp = new URLSearchParams(); + if (q) sp.set('q', q); + if (format) sp.set('format', format); + _applyLibraryProviderToParams(sp); + _applyLibFiltersToParams(sp); + const qs = sp.toString(); + try { + L.treeStats = await _fetchJsonOrThrow(`/api/library/stats${qs ? '?' + qs : ''}`); + } catch (error) { + if (myEpoch !== L.libEpoch) return; + L.treeStats = null; + _setLibraryOfflineMessage('lib-tree', 'lib-count', error.message || 'This source appears to be offline.'); + return; + } + if (myEpoch !== L.libEpoch) return; + } + const q = document.getElementById('lib-filter').value.trim(); + await renderTreeInto('lib-tree', 'lib-count', L.treeStats, _treeLetter, q, false, undefined, myEpoch); +} + +let _treePage = 0; + +const TREE_PAGE_SIZE = 50; + +export async function renderTreeInto(containerId, countId, stats, letter, q, favoritesOnly, page, expectedEpoch = L.libEpoch) { + if (page === undefined) page = favoritesOnly ? _favTreePage || 0 : _treePage; + const container = document.getElementById(containerId); + const screenProviderId = favoritesOnly ? 'local' : _activeLibraryProviderId(); + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split(''); + const chevron = ``; + + const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter'; + const pageFn = favoritesOnly ? 'goFavTreePage' : 'goTreePage'; + let html = '
'; + html += ``; + for (const l of letters) { + const count = stats.letters[l] || 0; + const active = letter === l; + html += ``; + } + html += '
'; + + // Fetch artists for the selected letter/all + const params = new URLSearchParams(); + if (letter) params.set('letter', letter); + if (q) params.set('q', q); + if (favoritesOnly) params.set('favorites', '1'); + else _applyLibraryProviderToParams(params); + const format = (document.getElementById('lib-format') || {}).value || ''; + if (format) params.set('format', format); + if (!favoritesOnly) _applyLibFiltersToParams(params); + params.set('page', page); + params.set('size', TREE_PAGE_SIZE); + let data; + try { + data = await _fetchJsonOrThrow(`/api/library/artists?${params}`); + } catch (error) { + if (expectedEpoch !== L.libEpoch) return; + _setLibraryOfflineMessage(containerId, countId, error.message || 'This source appears to be offline.'); + return; + } + if (expectedEpoch !== L.libEpoch) return; + const artists = data.artists || []; + const totalArtists = data.total_artists || 0; + const totalPages = Math.ceil(totalArtists / TREE_PAGE_SIZE); + + let songCount = 0, artistCount = artists.length; + for (const a of artists) songCount += a.song_count; + const pageInfo = totalPages > 1 ? ` · Page ${page + 1} of ${totalPages}` : ''; + document.getElementById(countId).textContent = + `${totalArtists} artists (${songCount} songs on this page)${pageInfo}`; + + // A previous Expand/Collapse-All click is persisted as '1'/'0' and + // overrides the auto-open heuristic for both artists and albums. + // Library and Favorites have independent buttons and independent + // keys (feedBack.libTreeExpand vs feedBack.favTreeExpand) — fed + // off the favoritesOnly flag — so toggling one doesn't flip the + // other's state. Falsy / unset key → fall back to the existing + // heuristic (open when there's an active search or few rows). + const expandKey = favoritesOnly ? _FAV_TREE_EXPAND_KEY : _LIB_TREE_EXPAND_KEY; + const savedExpand = _readPersistedChoice(expandKey, _LIB_TREE_EXPAND_VALUES, null); + const forceArtistOpen = savedExpand === '1'; + const forceArtistClosed = savedExpand === '0'; + + for (const artist of artists) { + const heuristicOpen = q || artists.length <= 5; + const isOpen = forceArtistOpen ? true : forceArtistClosed ? false : heuristicOpen; + const openClass = isOpen ? ' open' : ''; + const artistAria = _escAttr(`Toggle artist ${artist.name}`); + html += `
`; + html += `
`; + html += chevron; + html += `${esc(artist.name)}`; + html += `${artist.song_count} song${artist.song_count !== 1 ? 's' : ''} · ${artist.album_count} album${artist.album_count !== 1 ? 's' : ''}`; + html += `
`; + + for (const album of artist.albums) { + const albumSongs = Array.isArray(album.songs) ? album.songs : []; + const artSong = albumSongs[0] || {}; + const artProviderId = _libraryProviderIdForSong(artSong, screenProviderId); + const artUrl = _librarySongArtUrl(artSong, artProviderId); + const albumHeuristicOpen = q || artist.albums.length === 1; + const albumIsOpen = forceArtistOpen ? true : forceArtistClosed ? false : albumHeuristicOpen; + const albumOpen = albumIsOpen ? ' open' : ''; + const albumAria = _escAttr(`Toggle album ${album.name}`); + html += `
`; + html += `
`; + html += chevron; + if (artUrl) html += ``; + html += `${esc(album.name)}`; + html += `${albumSongs.length}`; + html += `
`; + + for (const song of albumSongs) { + const providerId = _libraryProviderIdForSong(song, screenProviderId); + const localFilename = _libraryLocalFilename(song, providerId); + const songId = _librarySongId(song); + const title = _librarySongTitle(song, providerId); + const duration = song.duration ? formatTime(song.duration) : ''; + const tuningRaw = song.tuning || song.tuning_name || ''; + const tuning = displayTuningName(tuningRaw); + const isLocalProvider = _isLocalLibraryProvider(providerId); + const isSloppak = song.format === 'sloppak'; + const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd && + ['Eb Standard', 'D Standard', 'C# Standard', 'C Standard'].includes(tuningRaw); + const canSyncRow = !localFilename && _providerSupports(providerId, 'song.sync'); + const isInteractiveRow = !!localFilename || canSyncRow; + const providerAttr = `data-library-provider="${encodeURIComponent(providerId)}"`; + // Keep data-library-song alongside data-play for provider-backed + // entries once synced so _restoreLibSelection can still find the + // card after a post-sync re-render. + const rowSongAttr = !isLocalProvider ? ` data-library-song="${encodeURIComponent(songId)}"` : ''; + const rowAttrs = localFilename + ? `data-play="${encodeURIComponent(localFilename)}" ${providerAttr}${rowSongAttr}` + : `data-library-provider="${encodeURIComponent(providerId)}" data-library-song="${encodeURIComponent(songId)}"`; + const ariaAction = localFilename ? 'Play' : 'Load and play'; + const rowAria = _escAttr(`${ariaAction} ${title}${artist.name ? ' by ' + artist.name : ''}`); + const rowDisplayLabel = `${title}${artist.name ? ' by ' + artist.name : ''}`; + const rowInteractiveAttrs = isInteractiveRow + ? `tabindex="0" role="button" aria-label="${rowAria}"` + : `role="listitem" aria-label="${_escAttr(rowDisplayLabel)}"`; + html += `
`; + html += `
${esc(title)}${formatBadgeInline(song.format, song.stem_count)}
`; + html += `
`; + { const _nm = _getArrangementNamingMode(); + for (const arrangement of (song.arrangements || [])) + html += _arrangementBadgeHtml(arrangement, _nm); } + if (tuning) + html += `${esc(tuning)}`; + if (song.has_lyrics) + html += `Lyrics`; + if (song.user_difficulty != null) + html += `◆${esc(song.user_difficulty)}`; + if (duration) + html += `${duration}`; + if (stdRetune) + html += ``; + if (isLocalProvider && localFilename) { + html += editBtn(song); + html += heartBtn(localFilename, song.favorite); + } else if (!localFilename) { + html += _librarySyncStatusMarkup(providerId, songId, 'inline'); + } + html += `
`; + } + html += `
`; + } + html += `
`; + } + + // Pagination + if (totalPages > 1) { + html += '
'; + html += ``; + html += ``; + const start = Math.max(0, page - 2); + const end = Math.min(totalPages, start + 5); + for (let i = start; i < end; i++) { + html += ``; + } + html += ``; + html += ``; + html += '
'; + } + + container.innerHTML = html; + // Items list invalidation — see grid render counterpart. + _bumpLibNavGeneration(); + // Re-apply the persisted selection. For the tree we also expand + // every collapsed ancestor of the saved row so the highlight is + // actually visible — see _restoreLibSelection. Scroll only on + // the first render after a screen entry (one-shot flag set in + // showScreen) so routine renders don't yank the viewport. + const screen = favoritesOnly ? 'favorites' : 'home'; + const scroll = _libScrollOnNextRender[screen]; + if (scroll) _libScrollOnNextRender[screen] = false; + _restoreLibSelection(container, screen, { scroll }); +} + +export function goTreePage(p) { + _treePage = Math.max(0, p); + loadTreeView(); + document.getElementById('library-section').scrollIntoView({ behavior: 'smooth' }); +} + +export function filterTreeLetter(letter) { + _treeLetter = (_treeLetter === letter) ? '' : letter; + _treePage = 0; + _writePersistedLetter(_LIB_TREE_LETTER_KEY, _treeLetter); + loadTreeView(); +} + +function _toggleAllInTree(containerId, expand, persistKey) { + // Scope the open/close to the named tree's container so toggling + // Library doesn't flip the (offscreen) Favorites DOM and vice + // versa — they share `.artist-row` / `.album-group` classes. + const container = document.getElementById(containerId); + if (!container) return; + container.querySelectorAll('.artist-row').forEach(el => el.classList.toggle('open', expand)); + container.querySelectorAll('.album-group').forEach(el => el.classList.toggle('open', expand)); + // Bulk open/close changes which song-rows pass the visibility + // filter in `_libNavItems` — same reason `_toggleHeader` bumps + // the generation. Without this, a stale cached items list from + // before the toggle would let arrow nav step into now-hidden + // rows. + _bumpLibNavGeneration(); + // Persist the explicit choice so the next page reload (or letter + // change, which re-runs renderTreeInto) honors it instead of + // falling back to the auto-open heuristic. Stored as '1'/'0' so a + // missing key reliably means "no explicit choice". + _writePersistedChoice(persistKey, expand ? '1' : '0'); +} + +export function toggleAllArtists(expand) { + _toggleAllInTree('lib-tree', expand, _LIB_TREE_EXPAND_KEY); +} + +export function toggleAllFavoriteArtists(expand) { + _toggleAllInTree('fav-tree', expand, _FAV_TREE_EXPAND_KEY); +} + +// Toggle an artist/album header's parent `.open` state and keep +// `aria-expanded` on the header itself in sync so screen readers +// announce the collapsed/expanded transition correctly. Used by +// both the inline onclick (mouse) and the keyboard handlers. +export function _toggleHeader(headerEl) { + if (!headerEl) return; + const parent = headerEl.parentElement; + if (!parent) return; + parent.classList.toggle('open'); + headerEl.setAttribute('aria-expanded', parent.classList.contains('open') ? 'true' : 'false'); + // Toggling open/closed changes which song-rows pass the + // visibility filter in `_libNavItems`, so the cached items list + // is now stale. + _bumpLibNavGeneration(); +} + +// Called by the inline onclick on artist- and album-headers so the +// mouse-click path also syncs the persistent `.selected` state — +// keeps arrow-nav resuming from the last-clicked header rather than +// from a stale highlight on a different element. +export function _onHeaderClick(el) { + _toggleHeader(el); + _setLibSelection(el, { focus: false }); +} + +// ── Favorites ──────────────────────────────────────────────────────────── +let favView = 'grid'; + +let favPage = 0; + +let _favTreeLetter = _readPersistedLetter(_FAV_TREE_LETTER_KEY); + +let _favTreePage = 0; + +let _favDebounce = null; + +function heartBtn(filename, isFav) { + return ``; +} + +export function editBtn(song) { + return ``; +} + +export async function toggleFavorite(filename) { + const resp = await fetch('/api/favorites/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }), + }); + const data = await resp.json(); + // Refresh whichever view is active + const activeScreen = document.querySelector('.screen.active'); + if (activeScreen?.id === 'favorites') loadFavorites(); + else loadLibrary(); + return data.favorite; +} + +export function setFavView(view) { + favView = view; + document.getElementById('fav-grid').classList.toggle('hidden', view !== 'grid'); + document.getElementById('fav-tree').classList.toggle('hidden', view !== 'tree'); + document.querySelectorAll('.fav-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid')); + document.querySelectorAll('.fav-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree')); + document.getElementById('fav-view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; + document.getElementById('fav-view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; + const pag = document.getElementById('fav-pagination'); + if (pag && view !== 'grid') pag.innerHTML = ''; + // Same reason as setLibView: dropping the items cache so the + // next keypress re-derives against the now-active container. + _bumpLibNavGeneration(); + loadFavorites(); +} + +export async function loadFavorites() { + if (favView === 'grid') await loadFavGridPage(favPage); + else await loadFavTreeView(); +} + +export function filterFavorites() { + clearTimeout(_favDebounce); + _favDebounce = setTimeout(() => { favPage = 0; _favTreeLetter = ''; loadFavorites(); }, 250); +} + +export function sortFavorites() { favPage = 0; loadFavorites(); } + +async function loadFavGridPage(page = 0) { + const q = document.getElementById('fav-filter').value.trim(); + const sort = document.getElementById('fav-sort').value; + favPage = page; + const params = new URLSearchParams({ q, page, size: PAGE_SIZE, sort, favorites: 1 }); + const resp = await fetch(`/api/library?${params}`); + const data = await resp.json(); + const totalPages = Math.ceil((data.total || 0) / PAGE_SIZE); + document.getElementById('fav-count').textContent = + `${data.total || 0} favorites · Page ${favPage + 1} of ${Math.max(1, totalPages)}`; + renderGridCards(data.songs || [], 'fav-grid'); + renderFavPagination(totalPages); +} + +function renderFavPagination(totalPages) { + let pag = document.getElementById('fav-pagination'); + if (!pag) { + pag = document.createElement('div'); + pag.id = 'fav-pagination'; + pag.className = 'flex items-center justify-center gap-2 py-6'; + document.getElementById('fav-grid').after(pag); + } + if (totalPages <= 1) { pag.innerHTML = ''; return; } + let html = ''; + html += ``; + html += ``; + const start = Math.max(0, favPage - 2); + const end = Math.min(totalPages, start + 5); + for (let i = start; i < end; i++) { + html += ``; + } + html += ``; + html += ``; + pag.innerHTML = html; +} + +export function goFavPage(p) { loadFavGridPage(Math.max(0, p)); } + +async function loadFavTreeView() { + if (!L.favTreeStats) { + const resp = await fetch('/api/library/stats?favorites=1'); + L.favTreeStats = await resp.json(); + } + const q = document.getElementById('fav-filter').value.trim(); + const letter = _favTreeLetter; + // Reuse the tree renderer with fav-tree container and fav-count + await renderTreeInto('fav-tree', 'fav-count', L.favTreeStats, letter, q, true); +} + +export function filterFavTreeLetter(letter) { + _favTreeLetter = (_favTreeLetter === letter) ? '' : letter; + _favTreePage = 0; + _writePersistedLetter(_FAV_TREE_LETTER_KEY, _favTreeLetter); + loadFavTreeView(); +} + +export function goFavTreePage(p) { + _favTreePage = Math.max(0, p); + loadFavTreeView(); +} + +let _uploadScanPoller = null; + +export function _pollScanAndRefresh(statusEl) { + const setStatus = (s) => { if (statusEl) statusEl.textContent = s; }; + if (_uploadScanPoller) _uploadScanPoller.stop(); + + const MAX_FAILURES = 5; + const INTERVAL_MS = 1000; + let stopped = false; + let timerId = null; + let failures = 0; + const stop = () => { + stopped = true; + if (timerId) { clearTimeout(timerId); timerId = null; } + if (_uploadScanPoller && _uploadScanPoller.stop === stop) _uploadScanPoller = null; + }; + _uploadScanPoller = { stop }; + + const tick = async () => { + timerId = null; + try { + const sr = await fetch('/api/scan-status'); + if (!sr.ok) throw new Error(`HTTP ${sr.status}`); + const sd = await sr.json(); + if (stopped) return; + failures = 0; + if (sd.running) { + const cur = sd.current ? ` · ${sd.current}` : ''; + setStatus(`${sd.done} / ${sd.total} scanned${cur}...`); + } else { + stop(); + if (sd.error) setStatus(`Error: ${sd.error}`); + else setStatus('Done!'); + L.treeStats = null; + L.tuningNames = null; + // Mirror the delete path: refresh whichever collection is + // currently visible. Overwriting a favorited song while + // viewing Favorites otherwise leaves a stale entry. + const activeScreen = document.querySelector('.screen.active'); + if (activeScreen?.id === 'favorites') loadFavorites(); + else loadLibrary(); + return; + } + } catch (e) { + if (stopped) return; + failures++; + if (failures >= MAX_FAILURES) { + stop(); + setStatus(`Scan status unavailable: ${e.message || e}`); + return; + } + } + if (!stopped) timerId = setTimeout(tick, INTERVAL_MS); + }; + timerId = setTimeout(tick, INTERVAL_MS); +} + +export async function rescanLibrary() { + const btn = document.getElementById('btn-rescan'); + const status = document.getElementById('rescan-status'); + btn.disabled = true; + btn.textContent = 'Scanning...'; + status.textContent = ''; + const resp = await fetch('/api/rescan', { method: 'POST' }); + const data = await resp.json(); + status.textContent = data.message; + // Poll until done + const poll = setInterval(async () => { + const sr = await fetch('/api/scan-status'); + const sd = await sr.json(); + if (sd.running) { + const cur = sd.current ? ` · ${sd.current}` : ''; + status.textContent = `${sd.done} / ${sd.total} scanned${cur}...`; + } else { + clearInterval(poll); + btn.disabled = false; + btn.textContent = 'Rescan Library'; + status.textContent = sd.error ? `Error: ${sd.error}` : 'Done!'; + L.treeStats = null; + L.tuningNames = null; // re-fetch on next drawer open + loadLibrary(); + // Tell the v3 Songs grid the library changed so it reloads instead of + // keeping a cached (e.g. pre-DLC, empty) grid until an app restart. + if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' }); + } + }, 1000); +} + +export async function fullRescanLibrary() { + if (!confirm('This will clear the entire library cache and re-scan all songs. This can take a long time with large libraries. Continue?')) return; + const btn = document.getElementById('btn-full-rescan'); + const status = document.getElementById('rescan-status'); + btn.disabled = true; + btn.textContent = 'Clearing...'; + const resp = await fetch('/api/rescan/full', { method: 'POST' }); + const data = await resp.json(); + btn.textContent = 'Scanning...'; + status.textContent = data.message; + const poll = setInterval(async () => { + const sr = await fetch('/api/scan-status'); + const sd = await sr.json(); + if (sd.running) { + const cur = sd.current ? ` · ${sd.current}` : ''; + status.textContent = `${sd.done} / ${sd.total} scanned${cur}...`; + } else { + clearInterval(poll); + btn.disabled = false; + btn.textContent = 'Full Rescan'; + status.textContent = sd.error ? `Error: ${sd.error}` : 'Done!'; + L.treeStats = null; + L.tuningNames = null; // re-fetch on next drawer open + loadLibrary(); + // Tell the v3 Songs grid the library changed so it reloads instead of + // keeping a cached (e.g. pre-DLC, empty) grid until an app restart. + if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' }); + } + }, 1000); +} + +export function _removeLibCardsForFilename(filename) { + // The grid uses data-play="" on each card; the + // tree's song rows use the same attribute. encodeURIComponent + // matches what renderGridCards / the tree renderer emit. + const encoded = encodeURIComponent(filename); + const selector = `[data-play="${CSS.escape(encoded)}"]`; + let removed = 0; + for (const el of document.querySelectorAll(selector)) { + el.remove(); + removed++; + } + if (removed === 0) return; + // Decrement the visible count badges that loadGridPage / loadTreeView + // populated. Counts come from the server's `total` so this is a + // best-effort estimate until the next refetch, but it keeps the + // displayed number consistent with what's on screen right now. + for (const id of ['lib-count', 'fav-count']) { + const el = document.getElementById(id); + if (!el) continue; + const m = (el.textContent || '').match(/^(\d+)/); + if (!m) continue; + const next = Math.max(0, parseInt(m[1], 10) - removed); + el.textContent = (el.textContent || '').replace(/^\d+/, String(next)); + } + _bumpLibNavGeneration(); +} + +export function _setLibrarySyncState(providerId, songId, state) { + _librarySyncStates.set(_librarySyncKey(providerId, songId), state); + _renderLibrarySyncState(providerId, songId); +} + +function _renderLibrarySyncState(providerId, songId) { + const state = _librarySyncState(providerId, songId); + // Filter via dataset rather than building a CSS attribute selector — + // CSS.escape is absent in some test environments and older runtimes, + // and provider/song IDs are not constrained to CSS-safe strings. + const encodedProvider = encodeURIComponent(providerId); + const encodedSong = encodeURIComponent(songId); + for (const status of document.querySelectorAll('[data-library-sync-status]')) { + if (status.dataset.librarySyncProvider !== encodedProvider) continue; + if (status.dataset.librarySyncSong !== encodedSong) continue; + const layout = status.classList.contains('ml-1') ? 'inline' : 'block'; + status.className = _librarySyncStatusClass(state, layout); + status.textContent = _librarySyncStatusText(state); + } +} + +// ── Scan banner (non-blocking) ────────────────────────────────────────── +function showScanBanner() { + if (document.getElementById('scan-banner')) return; + const el = document.createElement('div'); + el.id = 'scan-banner'; + el.className = 'fixed bottom-0 left-0 right-0 z-50 bg-dark-700/95 backdrop-blur border-t border-gray-700 px-6 py-3 flex items-center gap-4'; + el.innerHTML = ` +
+
+ Importing Library + 0 / 0 +
+
+

Starting...

+ +
+ `; + document.body.appendChild(el); +} + +export function hideScanBanner() { + const el = document.getElementById('scan-banner'); + if (el) el.remove(); +} + +let _scanPollId = null; + +async function pollScanStatus() { + try { + const resp = await fetch('/api/scan-status'); + const data = await resp.json(); + if (data.stage === 'error' && data.error) { + // Surface the error in the banner and stop polling. + showScanBanner(); + const file = document.getElementById('scan-file'); + const prog = document.getElementById('scan-progress'); + const firstNote = document.getElementById('scan-first-note'); + if (file) { file.textContent = 'Scan failed: ' + data.error; file.classList.add('text-red-400'); } + if (prog) prog.textContent = 'Error'; + if (firstNote) firstNote.classList.add('hidden'); + clearInterval(_scanPollId); + _scanPollId = null; + return; + } + if (data.running) { + showScanBanner(); + const pct = data.total > 0 ? Math.round(data.done / data.total * 100) : 0; + const bar = document.getElementById('scan-bar'); + const prog = document.getElementById('scan-progress'); + const file = document.getElementById('scan-file'); + const firstNote = document.getElementById('scan-first-note'); + if (bar) bar.style.width = pct + '%'; + if (prog) prog.textContent = `${data.done} / ${data.total} (${pct}%)`; + if (file) { + const name = (data.current || '').replace(/_p\.archive$/i, '').replace(/_/g, ' '); + file.textContent = name || (data.stage === 'listing' ? 'Listing DLC folder...' : 'Processing...'); + } + if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan); + } else { + if (document.getElementById('scan-banner')) { + hideScanBanner(); + L.treeStats = null; // Refresh stats + loadLibrary(); + } + clearInterval(_scanPollId); + _scanPollId = null; + } + } catch (e) { /* ignore */ } +} + +export async function checkScanAndLoad() { + const resp = await fetch('/api/scan-status'); + const data = await resp.json(); + if (data.running) { + showScanBanner(); + const firstNote = document.getElementById('scan-first-note'); + if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan); + _scanPollId = setInterval(pollScanStatus, 1000); + } + loadLibrary(); +} diff --git a/tests/js/legacy_shim_hits.test.js b/tests/js/legacy_shim_hits.test.js index db7e9c3..2cc4a19 100644 --- a/tests/js/legacy_shim_hits.test.js +++ b/tests/js/legacy_shim_hits.test.js @@ -77,6 +77,11 @@ const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js'); // The viz layer was carved out of app.js too (R3a). const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js'); const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js'); +// The library itself was carved out of app.js into ./static/js/library.js (R3a). Note the +// two are DIFFERENT files: LIBRARY_JS above is the capability; this is the UI module. +// syncLibrarySong deliberately stayed behind in app.js — it reaches showScreen/playSong, +// and moving it would have dragged the whole playback core into the library module. +const LIBRARY_MODULE_JS = path.join(ROOT, 'static', 'js', 'library.js'); function source(file) { // Normalize CRLF: region() slices fixed CHARACTER windows, so on a @@ -100,9 +105,10 @@ test('plugin script hydration exposes the current plugin id for legacy registrat test('library providers route through native library capability', () => { const src = source(APP_JS); + const libModule = source(LIBRARY_MODULE_JS); const librarySrc = source(LIBRARY_JS); - const loader = region(src, 'async function loadLibraryProviders', 1800); - const selector = region(src, 'async function setLibraryProvider(providerId, options = {})', 1600); + const loader = region(libModule, 'async function loadLibraryProviders', 1800); + const selector = region(libModule, 'async function setLibraryProvider(providerId, options = {})', 1600); const sync = region(src, 'async function syncLibrarySong(providerId, songId', 1600); assert.match(librarySrc, /capabilities\.registerOwner\(['"]library['"]/); diff --git a/tests/js/v3_library_refresh.test.js b/tests/js/v3_library_refresh.test.js index 31cefc0..e6f97b6 100644 --- a/tests/js/v3_library_refresh.test.js +++ b/tests/js/v3_library_refresh.test.js @@ -16,7 +16,11 @@ const path = require('node:path'); const root = path.join(__dirname, '..', '..'); const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8'); -const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8'); +// The rescan path moved into ./static/js/library.js with the rest of the library (R3a). +// Read BOTH: this asserts the emit exists SOMEWHERE in the app, and pinning it to one file +// just means the test starts lying the next time the code moves. +const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8') + + '\n' + fs.readFileSync(path.join(root, 'static', 'js', 'library.js'), 'utf8'); test('app.js emits library:changed when a Settings rescan completes', () => { assert.match(APP, /emit\(\s*['"]library:changed['"]/,