/*
 * fee[dB]ack v0.3.0 — Songs / Library (#v3-songs), native rebuild.
 *
 * A vanilla-JS library browser over the existing /api/library* endpoints:
 * provider selector (via the `library` capability, not DOM scraping), grid +
 * tree views, sort, format filter, a tri-state filter drawer (arrangements /
 * stems / lyrics / tunings), search (driven by the topbar), infinite scroll,
 * fb song cards with accuracy badges (song_stats), favorite + save-for-later,
 * and upload. Reuses window.playSong for playback (design/05: library is an
 * active capability domain; everything else stays on the documented globals).
 */
(function () {
    'use strict';
    const sm = window.feedBack;
    const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
        { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
    const enc = encodeURIComponent;
    // Inverse of `enc` for matching a played song's filename back to a library
    // card. The `stats:recorded` event (like `song:loading`) carries the
    // filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd
    // (see `playCard`, and the highway WS which decodeURIComponent's it). But
    // cards key on the DECODED library filename (`cardKey` → `localFilename`),
    // and `/api/stats/best` is server-canonicalized to that same decoded key, so
    // an encoded filename matches no card and the post-play badge repaint silently
    // no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent
    // for already-decoded names (no '%'); on malformed input falls back to the
    // original so a real filename containing a literal '%' is never corrupted.
    function decFn(fn) {
        if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || '';
        try { return decodeURIComponent(fn); } catch (_) { return fn; }
    }

    const SORTS = [
        ['artist', 'Artist A–Z'], ['artist-desc', 'Artist Z–A'],
        ['title', 'Title A–Z'], ['title-desc', 'Title Z–A'],
        ['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
        ['year', 'Year (oldest)'], ['tuning', 'Tuning'],
    ];
    const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
    const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
    const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other'];
    const PAGE_SIZE = 24;
    // Extra rows rendered above/below the viewport so a fast scroll doesn't flash
    // blank before the next window render lands.
    const OVERSCAN_ROWS = 2;
    const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
    const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary';

    const state = {
        provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
        artist: '', album: '',
        filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] },
        page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [],
        artistCatalog: [], renderedHash: '',
        scrollBound: false,
        songsById: {}, selectMode: false, selected: new Set(),
        railLetters: null, railLettersAreSongCounts: false, railJumping: false,
        // ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
        // state.songs is a SPARSE array indexed by absolute library position
        // (0..total-1); only the fetched pages are populated and only the visible
        // window ± overscan is ever in the DOM. The sizer element gives the
        // scrollbar the full-library geometry. See renderWindow / ensureWindow.
        songs: [],            // sparse: absoluteIndex → song row
        pageCursors: {},      // pageIndex → next_cursor (keyset forward fast-path)
        keysetOk: false,      // did page 0 return a non-null cursor (local + keyset sort)?
        pageProms: {},        // pageIndex → in-flight fetch promise (de-dupe + await)
        epoch: 0,             // bumped on every reset; a stale in-flight fetch checks it
        geom: null,           // { cols, rowH, gap } measured from the live grid
        winRange: null,       // { start, end } last rendered, to skip redundant renders
        renderedSelectMode: null, // the selectMode the current window was rendered under
        gridResizeBound: false,
    };

    // ── A–Z jump rail ───────────────────────────────────────────────────────
    // Ordered buckets shown on the rail: '#' (non-alphabetic) first, then A–Z.
    const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
    // The rail only makes sense for the alphabetical sorts; for recent/year/
    // tuning a letter jump is meaningless, so it's hidden. Returns the column
    // the active sort keys on ('artist' | 'title') or null when not alphabetical.
    function railSortColumn() {
        if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist';
        if (state.sort === 'title' || state.sort === 'title-desc') return 'title';
        return null;
    }
    // The bucket a song falls in for the active sort: first char of the sort
    // column, uppercased; anything non-A–Z (digits, symbols, accents, blank)
    // buckets under '#'. Mirrors the server's letter grouping in query_stats —
    // which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY
    // is likewise raw, so we must NOT trim here either: a leading-space title
    // sorts (and buckets) under '#' on both sides, keeping the rail consistent.
    function songBucket(song) {
        const col = railSortColumn();
        if (!col) return '';
        const raw = String((col === 'title' ? song.title : song.artist) || '');
        const ch = raw.charAt(0).toUpperCase();
        return (ch >= 'A' && ch <= 'Z') ? ch : '#';
    }

    function activeFilterCount() {
        const f = state.filters;
        return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
            (f.lyrics ? 1 : 0) + f.tunings.length + (state.artist ? 1 : 0) + (state.album ? 1 : 0);
    }

    function _getV3MainScroller() { return document.getElementById('v3-main'); }

    function buildLibraryStateHash(st) {
        const f = (st && st.filters) || {};
        return JSON.stringify({
            view: st.view || 'grid',
            q: st.q || '',
            sort: st.sort || 'artist',
            provider: st.provider || 'local',
            format: st.format || '',
            artist: st.artist || '',
            album: st.album || '',
            filters: {
                arr_has: [...(f.arr_has || [])].sort(),
                arr_lacks: [...(f.arr_lacks || [])].sort(),
                stem_has: [...(f.stem_has || [])].sort(),
                stem_lacks: [...(f.stem_lacks || [])].sort(),
                lyrics: f.lyrics || '',
                tunings: [...(f.tunings || [])].sort(),
            },
        });
    }

    function _libraryStateHash() { return buildLibraryStateHash(state); }

    function _saveLibraryScrollSnapshot() {
        const main = _getV3MainScroller();
        // Geometry is now stable (the sizer reserves the full scroll height
        // regardless of how many cards are actually in the DOM), so the scroll
        // position alone is enough to restore — no page-depth bookkeeping.
        const snap = {
            hash: _libraryStateHash(),
            scrollTop: main ? main.scrollTop : 0,
            view: state.view,
        };
        try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ }
    }

    function _readLibraryScrollSnapshot() {
        try {
            const raw = sessionStorage.getItem(SCROLL_STATE_KEY);
            if (!raw) return null;
            const snap = JSON.parse(raw);
            return (snap && typeof snap === 'object') ? snap : null;
        } catch (e) { return null; }
    }

    function _clearLibraryScrollSnapshot() {
        try { sessionStorage.removeItem(SCROLL_STATE_KEY); } catch (e) { /* */ }
    }

    function _applyMainScrollTop(scrollTop) {
        const main = _getV3MainScroller();
        if (!main) return;
        const top = Math.max(0, Number(scrollTop) || 0);
        const apply = () => { main.scrollTop = top; };
        apply();
        requestAnimationFrame(apply);
        setTimeout(apply, 0);
    }

    // The windowed grid keeps only a slice of cards in the DOM, so "intact" can no
    // longer mean "has cards" — it means the grid + sizer chrome exist and page 0
    // is loaded (state.total known, first rows present), so renderWindow() can
    // repaint the right slice at any scroll position.
    function _gridDomIntact() {
        const grid = document.getElementById('v3-songs-grid');
        const sizer = document.getElementById('v3-songs-gridsizer');
        return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined;
    }

    function _treeDomIntact() {
        const tree = document.getElementById('v3-songs-tree');
        if (!tree) return false;
        return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
    }

    function queryParams(extra, opts) {
        const f = state.filters;
        const skipArtistAlbum = opts && opts.catalog;
        const p = new URLSearchParams();
        p.set('provider', state.provider);
        p.set('sort', state.sort);
        if (state.format) p.set('format', state.format);
        if (state.q) p.set('q', state.q);
        if (!skipArtistAlbum && state.artist) p.set('artist', state.artist);
        if (!skipArtistAlbum && state.album) p.set('album', state.album);
        if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
        if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
        if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
        if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
        if (f.lyrics) p.set('has_lyrics', f.lyrics);
        if (f.tunings.length) p.set('tunings', f.tunings.join(','));
        Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v));
        return p;
    }

    // The active filter set as a smart-collection rule object (raw query-param
    // format the backend stores). Mirrors queryParams' filter fields, minus
    // provider/page/size. Empty object → nothing worth saving as a collection.
    function currentFilterRules() {
        const f = state.filters, r = {};
        if (state.q) r.q = state.q;
        if (state.format) r.format = state.format;
        if (state.artist) r.artist = state.artist;
        if (state.album) r.album = state.album;
        if (f.arr_has.length) r.arrangements_has = f.arr_has.join(',');
        if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(',');
        if (f.stem_has.length) r.stems_has = f.stem_has.join(',');
        if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(',');
        if (f.lyrics) r.has_lyrics = f.lyrics;
        if (f.tunings.length) r.tunings = f.tunings.join(',');
        if (state.sort && state.sort !== 'artist') r.sort = state.sort;
        return r;
    }

    // Save the current filter set as a smart collection (a saved live query that
    // shows up as a source in the picker). #636 item 2.
    async function saveCurrentAsCollection() {
        const rules = currentFilterRules();
        if (!Object.keys(rules).length) return;
        const name = ((await window.uiPrompt({
            title: 'Save as collection',
            label: 'A live view of the current filters, in the source picker.',
            okLabel: 'Save',
            placeholder: 'Collection name',
        })) || '').trim();
        if (!name) return;
        try {
            const res = await fetch('/api/collections', {
                method: 'POST', headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ name, rules }),
            });
            if (!res.ok) return;
            const col = (await res.json()).collection;
            closeDrawer();
            if (col && col.id != null) state.provider = 'collection:' + col.id;
            await render();   // rebuilds the toolbar (provider picker now lists + selects it)
        } catch (e) { /* offline / aborted — leave the drawer as-is */ }
    }

    function albumsForArtist(name) {
        const a = (state.artistCatalog || []).find((x) => x.name === name);
        return a ? (a.albums || []) : [];
    }

    function _chromeIntact() {
        return !!(document.getElementById('v3-songs-filters') &&
            document.getElementById('v3-songs-artist') &&
            document.getElementById('v3-songs-grid'));
    }

    function artistSelectHtml() {
        const opts = ['<option value="">All artists</option>']
            .concat((state.artistCatalog || []).map((a) =>
                '<option value="' + esc(a.name) + '"' + (a.name === state.artist ? ' selected' : '') + '>' + esc(a.name) + '</option>'));
        return opts.join('');
    }

    function albumSelectHtml() {
        if (!state.artist) {
            return '<option value="">Choose artist first</option>';
        }
        const albums = albumsForArtist(state.artist);
        const opts = ['<option value="">All albums</option>']
            .concat(albums.map((n) =>
                '<option value="' + esc(n) + '"' + (n === state.album ? ' selected' : '') + '>' + esc(n) + '</option>'));
        return opts.join('');
    }

    function refreshArtistAlbumSelects() {
        const artistEl = document.getElementById('v3-songs-artist');
        const albumEl = document.getElementById('v3-songs-album');
        if (artistEl) artistEl.innerHTML = artistSelectHtml();
        if (albumEl) {
            albumEl.innerHTML = albumSelectHtml();
            albumEl.disabled = !state.artist;
        }
    }

    function syncChromeFromState() {
        const map = {
            'v3-songs-provider': state.provider,
            'v3-songs-sort': state.sort,
            'v3-songs-format': state.format,
        };
        Object.entries(map).forEach(([id, val]) => {
            const el = document.getElementById(id);
            if (el && el.value !== val) el.value = val;
        });
        refreshArtistAlbumSelects();
        const gridBtn = document.getElementById('v3-songs-grid-btn');
        const treeBtn = document.getElementById('v3-songs-tree-btn');
        if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
        if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
        const folderBtn = document.getElementById('v3-songs-folder-btn');
        if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
        // Select button tracks state.selectMode — the screen-leave teardown clears
        // select mode, so a cached-DOM re-entry must re-style the button (and the
        // window re-renders without checkboxes via renderWindow's selectMode check).
        const selBtn = document.getElementById('v3-songs-select');
        if (selBtn) selBtn.className = btnCtrl + (state.selectMode ? ' bg-fb-primary text-white' : '');
        updateFilterBadge();
    }

    async function loadArtistCatalog() {
        const artists = [];
        let page = 0, total = Infinity;
        while (artists.length < total) {
            const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }, { catalog: true }).toString());
            if (!data || !Array.isArray(data.artists)) break;
            artists.push(...data.artists);
            total = (data.total_artists != null) ? data.total_artists : artists.length;
            if (!data.artists.length || page > 1000) break;
            page++;
        }
        state.artistCatalog = artists.map((a) => ({
            name: a.name,
            albums: (a.albums || []).map((al) => al.name),
        }));
        if (state.artist && !state.artistCatalog.some((a) => a.name === state.artist)) {
            state.artist = '';
            state.album = '';
        } else if (state.album && !albumsForArtist(state.artist).includes(state.album)) {
            state.album = '';
        }
        return state.artistCatalog;
    }

    function resetScrollToTop() {
        _clearLibraryScrollSnapshot();
        _applyMainScrollTop(0);
    }

    function setArtist(value) {
        state.artist = value || '';
        if (state.album && !albumsForArtist(state.artist).includes(state.album)) state.album = '';
        resetScrollToTop();
        refreshArtistAlbumSelects();
        reload();
    }

    function setAlbum(value) {
        if (!state.artist) { state.album = ''; return; }
        state.album = value || '';
        resetScrollToTop();
        reload();
    }

    async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }

    // ── Provider-aware song helpers ────────────────────────────────────────
    // Remote library providers (feedBack-plugin-remote-library-*) expose songs
    // by provider-owned id with their own art/sync/play flow. Reuse the legacy
    // app.js globals (the shared engine) so v3 behaves identically for remote
    // providers instead of assuming every row is a local file. All degrade to
    // the local path when the helpers/providers aren't present.
    function songId(s) {
        return (window._librarySongId ? window._librarySongId(s) : (s.filename || '')) || '';
    }
    function localFilename(s) {
        return window._libraryLocalFilename ? window._libraryLocalFilename(s, state.provider) : (s.filename || '');
    }
    // Stable per-card key: the local filename when present (local song, or a
    // synced remote one), else the provider song id.
    function cardKey(s) { return localFilename(s) || songId(s); }

    function artUrl(song) {
        if (window._librarySongArtUrl) return window._librarySongArtUrl(song, state.provider);
        const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
        return song.filename ? '/api/song/' + enc(song.filename) + '/art' + v : '';
    }

    // Play a card: local (or already-synced remote) → playSong the local file;
    // an unsynced remote song → sync it first, then play when ready.
    function playCard(song, arrIdx) {
        if (!song) return;
        _saveLibraryScrollSnapshot();
        const lf = localFilename(song);
        if (lf) { if (window.playSong) window.playSong(enc(lf), arrIdx); return; }
        const sid = songId(song);
        if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true });
    }

    // Accuracy badge markup. `variant` is 'grid' (overlay pill on the card art,
    // default) or 'tree' (inline percentage in the list row). Both carry the
    // .fb-acc-badge class so a post-play refresh (repaintAccuracy) can find and
    // replace them in place without re-rendering the whole list.
    function accuracyBadge(filename, variant) {
        const acc = state.accuracy[filename];
        if (acc == null) return '';
        const pct = Math.round(acc * 100);
        if (variant === 'tree') {
            const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
            return '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>';
        }
        const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
        const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white';
        return '<span class="fb-acc-badge absolute bottom-0 right-0 ' + color + '/90 ' + text + ' px-2 py-0.5 rounded-tl-md text-xs font-bold flex items-center gap-1">' +
            '<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
    }

    // After a song is scored, the badge for that card is stale until the next
    // full render(). Refresh state.accuracy from the server and patch the badge
    // of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
    // tracks filenames scored while the library was off-screen, applied on enter.
    const _dirtyScores = new Set();

    // Set when a library scan / DLC-folder change happened while this screen was
    // off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM /
    // snapshot would otherwise survive a sidebar return, so we force a full
    // re-fetch on the next entry. (feedBack — "No DLC until restart".)
    let _libraryDirty = false;

    function repaintAccuracy(key) {
        const apply = (el, variant) => {
            if (el.getAttribute('data-fn') !== key) return;
            const old = el.querySelector('.fb-acc-badge');
            const html = accuracyBadge(key, variant);
            if (variant === 'grid') {
                const art = el.querySelector('[data-v3-play]');
                if (!art) return;
                if (old) old.remove();
                if (html) art.insertAdjacentHTML('beforeend', html);
            } else if (old) {
                if (html) old.outerHTML = html; else old.remove();
            } else if (html) {
                // No prior badge in this row — insert before the favorite button
                // so it keeps its slot (after the format chip).
                const fav = el.querySelector('[data-fav]');
                if (fav) fav.insertAdjacentHTML('beforebegin', html);
                else el.insertAdjacentHTML('beforeend', html);
            }
        };
        document.querySelectorAll('#v3-songs-grid [data-fn]').forEach((el) => apply(el, 'grid'));
        document.querySelectorAll('#v3-songs-tree [data-fn]').forEach((el) => apply(el, 'tree'));
    }

    async function applyScoreRefresh() {
        if (!_dirtyScores.size) return;
        const fresh = await jget('/api/stats/best');
        // Fetch failed — keep the entries dirty so the next trigger (or screen
        // enter) retries rather than silently dropping the badge update.
        if (!fresh) return;
        state.accuracy = fresh;
        const keys = Array.from(_dirtyScores);
        _dirtyScores.clear();
        keys.forEach(repaintAccuracy);
        // A new score shifts the repertoire meter + the keep-practicing shelf.
        renderLibraryHome();
    }

    // ── Practice-aware library home (repertoire meter + "Keep practicing") ─────
    // Both read data we already have: state.accuracy (/api/stats/best =
    // {filename: best_accuracy}) and /api/stats/recent. A song is "in your
    // repertoire" at the same threshold the green accuracy badge uses (>= 0.9);
    // a started song below that is "in progress". This is descriptive
    // encouragement — it never gates content, decays, or nags (the goal-gradient
    // / endowed-progress idea, kept healthy).
    const MASTERY_ACCURACY = 0.9;

    function _repertoireCounts() {
        let mastered = 0, learning = 0;
        for (const v of Object.values(state.accuracy || {})) {
            if (typeof v !== 'number') continue;
            if (v >= MASTERY_ACCURACY) mastered++; else learning++;
        }
        return { mastered, learning };
    }

    // The home block is the unfiltered "front door": shown on the grid view when
    // the user isn't running a focused query (search / filter) or selecting.
    // Local provider only — the meter's mastered count and the shelf both read
    // local practice stats (state.accuracy / /api/stats/recent), so on a remote
    // provider they'd mix local numerators with a remote song total and play
    // local files while browsing a remote library. Hide it there.
    function libHomeVisible() {
        return state.view === 'grid' && state.provider === 'local'
            && !state.selectMode && !state.q && activeFilterCount() === 0;
    }

    let _homeToken = 0;
    async function renderLibraryHome() {
        const host = document.getElementById('v3-lib-home');
        if (!host) return;
        if (!libHomeVisible()) { host.classList.add('hidden'); return; }
        // A newer render (view/filter/score change) supersedes this one so a
        // slow response can't repaint a home the grid already moved past.
        const myToken = ++_homeToken;
        // Unfiltered library size for the meter denominator (the grid's
        // state.total tracks the active filter; the meter is library-wide) +
        // recently-played rows for the shelf, fetched together.
        const [stats, recent] = await Promise.all([
            jget('/api/library/stats?provider=' + enc(state.provider)),
            jget('/api/stats/recent?limit=24'),
        ]);
        if (_homeToken !== myToken || !libHomeVisible()) {            // changed mid-fetch
            if (_homeToken === myToken) host.classList.add('hidden');
            return;
        }
        const total = (stats && (stats.total_songs ?? stats.total)) || 0;
        if (total <= 0) { host.classList.add('hidden'); return; }    // empty library
        // Shelf = recently-played, not-yet-mastered songs, newest first. Mastery
        // is per-SONG (state.accuracy = MAX best across arrangements, what the
        // green badge shows) — recents are per-(song,arrangement), so dedupe by
        // filename and gate on the song's best, keeping the shelf and its badges
        // consistent (no green-badged "keep practicing" card, no dupes).
        const acc = state.accuracy || {};
        const seen = new Set();
        const shelf = (Array.isArray(recent) ? recent : [])
            .filter((r) => {
                if (!r || seen.has(r.filename)) return false;
                const best = acc[r.filename];
                if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false;
                seen.add(r.filename);
                return true;
            })
            .slice(0, 8);

        const { mastered, learning } = _repertoireCounts();
        const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
        const meter =
            '<div class="v3-rep-meter">' +
              '<div class="flex items-baseline justify-between gap-3 mb-1">' +
                '<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
                '<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
                  (learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' +
              '</div>' +
              '<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
            '</div>';

        let shelfHtml = '';
        if (shelf.length) {
            const cards = shelf.map((r) =>
                '<button class="v3-kp-card group text-left" data-kp="' + esc(r.filename) + '" data-arr="' + esc(r.arrangement != null ? r.arrangement : '') + '" title="' + esc(r.title) + '">' +
                '<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card">' +
                '<img src="' + esc(r.art_url) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
                accuracyBadge(r.filename) +
                '</div>' +
                '<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
                '<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
                '</button>').join('');
            shelfHtml =
                '<section class="v3-kp-shelf mt-4">' +
                '<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
                '<div class="v3-kp-row">' + cards + '</div>' +
                '</section>';
        }

        host.innerHTML = meter + shelfHtml;
        host.classList.remove('hidden');
        // The home block sits above the grid sizer, so its height shifts where the
        // window maps in scroll space — repaint the window once it's laid out.
        if (state.view === 'grid') requestWindowRender();
        // Wire shelf cards → play (mirrors playCard's local path; recents are
        // always local-library rows, so no provider sync is needed).
        host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => {
            const fn = btn.getAttribute('data-kp');
            const arr = btn.getAttribute('data-arr');
            if (!fn || !window.playSong) return;
            _saveLibraryScrollSnapshot();
            window.playSong(enc(fn), arr === '' ? undefined : Number(arr));
        }));
    }

    // Toggle/refresh the home block on view/sort/filter/search changes.
    function updateLibraryHome() {
        const host = document.getElementById('v3-lib-home');
        if (!host) return;
        if (!libHomeVisible()) { host.classList.add('hidden'); return; }
        renderLibraryHome();
    }

    // Source format of a song — prefer the server's `format` field, fall back
    // to the filename extension. Returns '' for unknown.
    function fmtLabel(song) {
        let f = (song.format || '').toLowerCase();
        if (!f) {
            const fn = (song.filename || '').toLowerCase();
            f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : '';
        }
        return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : '';
    }
    // Corner badge for art-based cards (sloppak accented, others muted).
    function fmtBadge(song) {
        const l = fmtLabel(song);
        if (!l) return '';
        const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
        return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
    }

    // Clickable arrangement chips — one <button data-arr="<index>"> per
    // arrangement. wireCards() binds the click to playCard(song, index), which
    // opens THAT arrangement in the highway via playSong(filename, index).
    // Shared by the grid card and the tree row so both views render the same
    // badges with the same click-to-open behaviour. Capped at 4 to match the
    // card layout.
    function arrChipsHtml(song) {
        return (song.arrangements || []).slice(0, 4).map((a) =>
            '<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').join('');
    }

    function songCard(song) {
        const fav = song.favorite;
        const key = cardKey(song);
        // In select mode the checkbox occupies top-2 left-2, so shift the
        // tuning chip right (left-9) to avoid overlapping it.
        const tuningLabel = (typeof window.displayTuningName === 'function')
            ? window.displayTuningName(song.tuning_name || song.tuning)
            : (song.tuning_name || '');
        let tuning = '';
        if (tuningLabel) {
            const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
                ? (window.parseRawTuningOffsets(song.tuning_offsets)
                    || window.parseRawTuningOffsets(song.tuning_name || song.tuning))
                : null;
            const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
                && typeof window.displayTuningTargets === 'function')
                ? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
                : '';
            const badgeTitle = targetNotes
                ? ('Custom Tuning: ' + targetNotes)
                : tuningLabel;
            const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2');
            if (targetNotes) {
                tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center" title="' + esc(badgeTitle) + '">'
                    + esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
            } else {
                tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[10px] font-bold px-1.5 py-0.5 rounded-sm" title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + '</span>';
            }
        }
        // Display-only (pointer-events-none) so a click falls through to the
        // card's data-v3-play handler, which owns the toggle — avoids double-toggle.
        const checkbox = state.selectMode
            ? '<input type="checkbox" data-select class="absolute top-2 left-2 z-20 w-5 h-5 accent-fb-primary pointer-events-none"' + (state.selected.has(key) ? ' checked' : '') + '>'
            : '';
        const arrChips = arrChipsHtml(song);
        // Plugin-contributed card actions placed 'inline' (in the hover action
        // row) or 'overlay' (centered over the art). Menu-placed actions live in
        // the ⋮ menu (openCardMenu); rendering these here means plugins using
        // those placements are no longer silently dropped. No bundled action
        // uses them, so for the stock library both strings are empty — the card
        // renders exactly as before.
        const reg = sm && sm.libraryCardActions;
        const acts = (reg && typeof reg.list === 'function') ? reg.list(song) : [];
        const actBtn = (a) =>
            '<button data-act-card="' + esc(a.id) + '" title="' + esc(a.label || a.id) + '" aria-label="' + esc(a.label || a.id) + '"' +
            (a.enabled === false ? ' disabled' : '') +
            ' class="px-2 h-7 min-w-[1.75rem] rounded-full bg-black/55 hover:bg-black/75 flex items-center justify-center text-xs leading-none ' +
            (a.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') +
            (a.destructive ? 'text-fb-accent' : 'text-white') + '">' + esc(a.icon || a.label || '•') + '</button>';
        const inlineBtns = acts.filter((a) => a.placement === 'inline').map(actBtn).join('');
        const overlayActs = acts.filter((a) => a.placement === 'overlay');
        const overlay = overlayActs.length
            ? '<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition pointer-events-none"><div class="flex flex-wrap gap-1 justify-center max-w-[90%] pointer-events-auto">' + overlayActs.map(actBtn).join('') + '</div></div>'
            : '';
        // Recycled cards re-render from state, so a selected card must paint its
        // ring on initial markup (toggleSelect only adds it to a live node).
        const selRing = state.selected.has(key) ? ' ring-2 ring-fb-primary' : '';
        return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
            '<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
            '<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
            tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay +
            '<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
            inlineBtns +
            '<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
            '<button data-save title="Save for later" aria-label="Save for later" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm">🔖</button>' +
            '<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
            '</div></div>' +
            '<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(song.title) + '">' + esc(song.title) + '</div>' +
            '<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' +
            // Always emit the chip row (even when empty) at a FIXED single-line
            // height — uniform card height is what makes the windowed grid's
            // absolute-position math exact (.v3-card-chips in v3.css).
            '<div class="v3-card-chips flex gap-1 mt-1">' + arrChips + '</div>' +
            '</div>';
    }

    // Per-card action menu, built from the ui.library-card-injection registry
    // (core Edit/Retune + any plugin-registered actions).
    let _closeCardMenu = null;   // tears down the currently-open card menu + its document closer
    function openCardMenu(cardEl, song, anchorBtn) {
        // Fully close any already-open menu first — removing just the DOM node
        // (as before) would orphan its document-level click closer.
        if (_closeCardMenu) _closeCardMenu();
        const reg = sm && sm.libraryCardActions;
        // Only show actions intended for the overflow menu — actions placed
        // 'inline'/'overlay' get their own affordances on the card (see songCard).
        // Undefined placement defaults to the menu.
        const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
        const menu = document.createElement('div');
        menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
        const rows = [
            { id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(song.filename)); } },
            { id: '__playlist', label: 'Add to playlist' },
            ...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
        ];
        menu.innerHTML = rows.map((r) =>
            '<button data-act="' + esc(r.id) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 ' +
            (r.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') +
            (r.destructive ? 'text-fb-accent' : 'text-fb-text') + '">' + esc(r.label) +
            (r.plugin && r.plugin !== 'core' ? '<span class="text-[10px] text-fb-textDim ml-1">' + esc(r.plugin) + '</span>' : '') + '</button>').join('');
        cardEl.appendChild(menu);
        // Tear down BOTH the menu and its document-level closer together, so a
        // menu-item click doesn't leave the closer attached (it would otherwise
        // leak, retaining this menu's closures until the next document click).
        const closer = (e) => { if (!menu.contains(e.target) && e.target !== anchorBtn) closeMenu(); };
        function closeMenu() { menu.remove(); document.removeEventListener('click', closer); if (_closeCardMenu === closeMenu) _closeCardMenu = null; }
        _closeCardMenu = closeMenu;
        menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
            e.stopPropagation();
            const id = b.getAttribute('data-act');
            closeMenu();
            if (id === '__play') { playCard(song); return; }
            if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
            if (reg) await reg.run(id, song, { source: 'v3-songs' });
        }));
        setTimeout(() => document.addEventListener('click', closer), 0);
    }

    function wireCards(scope) {
        scope.querySelectorAll('[data-fn]').forEach((el) => {
            if (el.dataset.wired) return;   // don't double-bind on append/auto-fill
            el.dataset.wired = '1';
            const fn = el.getAttribute('data-fn');
            const song = state.songsById[fn] || { filename: fn };
            el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
                if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
                playCard(song);   // local → play; unsynced remote → sync then play
            }));
            el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
            el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
                e.stopPropagation();
                const idx = ab.getAttribute('data-arr');
                playCard(song, idx === '' ? undefined : Number(idx));
            }));
            el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
                e.stopPropagation();
                const btn = e.currentTarget;
                try {
                    const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) });
                    const d = await r.json();
                    btn.textContent = d.favorite ? '♥' : '♡';
                    btn.setAttribute('aria-pressed', d.favorite ? 'true' : 'false');
                    btn.classList.toggle('text-fb-accent', d.favorite);
                    btn.classList.toggle('text-white', !d.favorite);
                } catch (err) { /* */ }
            });
            el.querySelector('[data-save]')?.addEventListener('click', async (e) => {
                e.stopPropagation();
                if (window.v3Saved) { const saved = await window.v3Saved.toggle(fn); e.currentTarget.classList.toggle('text-fb-primary', !!saved); }
            });
            // Inline/overlay plugin card actions → run via the shared registry.
            el.querySelectorAll('[data-act-card]').forEach((ab) => ab.addEventListener('click', async (e) => {
                e.stopPropagation();
                const reg = sm && sm.libraryCardActions;
                if (reg) await reg.run(ab.getAttribute('data-act-card'), song, { source: 'v3-songs' });
            }));
        });
    }

    // ── Multi-select + batch actions ──────────────────────────────────────--
    function toggleSelect(fn, el) {
        if (state.selected.has(fn)) state.selected.delete(fn); else state.selected.add(fn);
        const on = state.selected.has(fn);
        const cb = el.querySelector('[data-select]'); if (cb) cb.checked = on;
        el.querySelector('[data-v3-play]')?.classList.toggle('ring-2', on);
        el.querySelector('[data-v3-play]')?.classList.toggle('ring-fb-primary', on);
        renderBatchBar();
    }

    function setSelectMode(on) {
        state.selectMode = on;
        if (!on) state.selected.clear();
        const btn = document.getElementById('v3-songs-select');
        if (btn) btn.className = btnCtrl + (on ? ' bg-fb-primary text-white' : '');
        reload();           // re-render cards with/without checkboxes
        renderBatchBar();
    }

    function renderBatchBar() {
        let bar = document.getElementById('v3-songs-batch');
        if (!state.selectMode || state.selected.size === 0) { if (bar) bar.remove(); return; }
        if (!bar) {
            bar = document.createElement('div');
            bar.id = 'v3-songs-batch';
            bar.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-fb-card border border-fb-border/60 rounded-full shadow-xl px-4 py-2';
            document.body.appendChild(bar);
        }
        bar.innerHTML =
            '<span class="text-sm text-fb-text">' + state.selected.size + ' selected</span>' +
            '<button data-batch="playlist" class="text-sm bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1 rounded-full">Add to playlist</button>' +
            '<button data-batch="saved" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1 rounded-full">Save for Later</button>' +
            '<button data-batch="clear" class="text-sm text-fb-textDim hover:text-fb-text px-2">Clear</button>';
        bar.querySelector('[data-batch="clear"]').addEventListener('click', () => { state.selected.clear(); reload(); renderBatchBar(); });
        bar.querySelector('[data-batch="saved"]').addEventListener('click', batchSave);
        bar.querySelector('[data-batch="playlist"]').addEventListener('click', batchAddToPlaylist);
    }

    async function batchSave() {
        const lists = (await jget('/api/playlists')) || [];
        const saved = lists.find((p) => p.system_key === 'saved_for_later');
        let present = new Set();
        if (saved) { const pl = await jget('/api/playlists/' + saved.id); present = new Set(((pl && pl.songs) || []).map((s) => s.filename)); }
        // Additive: only toggle (add) songs not already saved.
        for (const fn of state.selected) {
            if (!present.has(fn)) {
                try { await fetch('/api/saved/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
            }
        }
        finishBatch();
    }

    // Prompt for a target playlist (pick a listed number, or type a new name to
    // create it) and add the given song filenames to it. Shared by the
    // select-mode batch bar and the per-card ⋮ menu's single-song add. Returns
    // the playlist id (or null if cancelled).
    async function addFilenamesToPlaylist(filenames) {
        const fns = Array.from(filenames || []);
        if (!fns.length) return null;
        const lists = (await jget('/api/playlists')) || [];
        const choices = lists.filter((p) => !p.system_key);
        const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join('   ');
        const ans = ((await window.uiPrompt({
            title: 'Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to a playlist',
            label: (labels ? labels + ' ' : '') + 'Type a number above, or a new playlist name:',
            okLabel: 'Add',
            placeholder: 'Number or new playlist name',
        })) || '').trim();
        if (!ans) return null;
        let pid = null;
        const num = parseInt(ans, 10);
        if (!isNaN(num) && choices[num - 1]) pid = choices[num - 1].id;
        else { const created = await jsend('POST', '/api/playlists', { name: ans }); pid = created && created.id; }
        if (!pid) return null;
        for (const fn of fns) {
            try { await fetch('/api/playlists/' + pid + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
        }
        if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } }
        return pid;
    }

    async function batchAddToPlaylist() {
        const pid = await addFilenamesToPlaylist(state.selected);
        // Only tear down the multi-select when the add actually happened. A
        // cancelled or failed picker returns null — preserve the selection (and
        // skip the reload) so the user can retry, matching the pre-refactor
        // behaviour where !ans / !pid returned early before finishBatch().
        if (pid) finishBatch();
    }

    function finishBatch() {
        state.selected.clear();
        if (window.v3Playlists) { try { window.v3Playlists.refresh(); window.v3Playlists.refreshSaved(); } catch (e) { /* */ } }
        reload(); renderBatchBar();
    }

    async function jsend(method, url, body) {
        try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; }
    }

    // ── Grid (windowed / recycled — #636 item 3 stage 2) ───────────────────--
    // Only the visible cards (± OVERSCAN_ROWS) live in the DOM; a sizer element
    // sized to the FULL library gives the scrollbar its geometry. state.songs is
    // a sparse array indexed by absolute position; ensureWindow() fetches the
    // pages a window needs (keyset forward fast-path, else OFFSET random-access),
    // and renderWindow() paints the slice the current scrollTop maps to.

    // Live count of cards actually in the DOM — bounded under windowing, so it's
    // the bounded-DOM invariant the tests assert (NOT a "loaded so far" signal).
    function loadedCount() { return document.querySelectorAll('#v3-songs-grid [data-fn]').length; }

    // The scroll listener lives on the SHARED #v3-main container, so guard every
    // render entry point on the Songs screen actually being active — otherwise
    // scrolling another screen would keep rendering into the hidden grid after
    // Songs has been visited once.
    function songsActive() { const el = document.getElementById('v3-songs'); return !!el && el.classList.contains('active'); }

    function _gridEl() { return document.getElementById('v3-songs-grid'); }
    function _sizerEl() { return document.getElementById('v3-songs-gridsizer'); }

    // Measure columns + row pitch from the LIVE grid: cols from the computed
    // grid-template-columns (tracks resolve to explicit pixel sizes), rowH from a
    // rendered card's box + the grid row-gap. Cards are uniform height (aspect-
    // square art + truncated text + the fixed-height .v3-card-chips row), so one
    // measured card sizes every row. Falls back to a coarse estimate until the
    // first card exists, then re-measures.
    function measureGeom() {
        const grid = _gridEl();
        if (!grid) return state.geom || { cols: 2, rowH: 240, gap: 16 };
        const cs = getComputedStyle(grid);
        const tracks = (cs.gridTemplateColumns || '').trim();
        const cols = (tracks && tracks !== 'none')
            ? Math.max(1, tracks.split(/\s+/).length)
            : (state.geom ? state.geom.cols : 2);
        const gap = parseFloat(cs.rowGap) || 0;
        let rowH = state.geom && state.geom.rowH;
        const card = grid.querySelector('[data-fn]') || grid.querySelector('.v3-card-skel');
        if (card) { const h = card.getBoundingClientRect().height; if (h > 0) rowH = h + gap; }
        if (!rowH || rowH <= 0) rowH = 240 + gap;   // estimate until a card is measured
        state.geom = { cols, rowH, gap };
        return state.geom;
    }

    // The sizer's top edge measured in the scroller's content coordinate space
    // (accounts for the practice-home block above it, sticky toolbar, etc.).
    function _sizerTopInScroller(main, sizer) {
        return sizer.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop;
    }

    function _windowHasHoles(start, end) {
        for (let i = start; i < end; i++) if (state.songs[i] === undefined) return true;
        return false;
    }

    // A placeholder card with the SAME vertical structure (and therefore height)
    // as a real card, shown only if a window's fetch hasn't landed yet. No
    // [data-fn] → wireCards / repaintAccuracy skip it.
    function _skeletonCard() {
        return '<div class="v3-card-skel" aria-hidden="true">' +
            '<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card animate-pulse"></div>' +
            '<div class="mt-1 text-sm text-transparent truncate">·</div>' +
            '<div class="text-xs text-transparent truncate">·</div>' +
            '<div class="v3-card-chips flex gap-1 mt-1"></div>' +
            '</div>';
    }

    function _renderCardsRange(start, end) {
        let html = '';
        for (let i = start; i < end; i++) {
            const s = state.songs[i];
            html += s ? songCard(s) : _skeletonCard();
        }
        return html;
    }

    // Fetch a single OFFSET page into the sparse store. Uses the stage-1 keyset
    // cursor when the previous page is already loaded (cheap forward scroll);
    // otherwise OFFSET page= for random access (jumps, restore, non-keyset
    // providers). Records the returned next_cursor so a later contiguous page can
    // chain off it. Returns a promise that callers AWAIT (so ensureWindow never
    // returns with a hole still in flight); concurrent requests for the same page
    // share the one promise. An `epoch` captured at launch guards against a reset
    // (provider/sort/filter change) landing mid-fetch and writing stale rows into
    // the new dataset.
    function _loadPage(p) {
        if (p < 0 || state.songs[p * PAGE_SIZE] !== undefined) return Promise.resolve();
        if (state.pageProms[p]) return state.pageProms[p];
        const epoch = state.epoch;
        const prom = (async () => {
            const extra = { size: PAGE_SIZE };
            const prevCursor = state.keysetOk ? state.pageCursors[p - 1] : null;
            if (prevCursor) extra.after = prevCursor; else extra.page = p;
            const data = await jget('/api/library?' + queryParams(extra).toString());
            if (state.epoch !== epoch || !data) return;   // reset mid-fetch → discard stale
            state.total = data.total || 0;
            if (typeof data.next_cursor !== 'undefined') {
                state.pageCursors[p] = data.next_cursor;
                if (p === 0) state.keysetOk = !!data.next_cursor;
            }
            const base = p * PAGE_SIZE;
            (data.songs || []).forEach((s, i) => {
                state.songs[base + i] = s;
                state.songsById[cardKey(s)] = s;
            });
        })();
        state.pageProms[p] = prom;
        prom.finally(() => { if (state.pageProms[p] === prom) delete state.pageProms[p]; });
        return prom;
    }

    // Ensure every absolute index in [start, end) is loaded (fetch — or await an
    // in-flight fetch of — the covering pages). Pages resolve in order so the
    // keyset fast-path can chain off the previous page's cursor.
    async function ensureWindow(start, end) {
        if (end <= start) return;
        const p0 = Math.floor(start / PAGE_SIZE);
        const p1 = Math.floor((end - 1) / PAGE_SIZE);
        for (let p = p0; p <= p1; p++) {
            if (state.songs[p * PAGE_SIZE] === undefined) await _loadPage(p);
        }
    }

    let _winRAF = 0;
    function requestWindowRender() {
        if (_winRAF) return;
        _winRAF = requestAnimationFrame(() => { _winRAF = 0; renderWindow(); });
    }

    // Paint the slice of cards the current scrollTop maps to. Sizes the sizer to
    // the full library, computes the visible row range (± overscan), fetches any
    // missing pages, then swaps the grid's innerHTML to just that slice. A token
    // guards against an out-of-order fetch repainting a window the user scrolled
    // past.
    let _winToken = 0;
    async function renderWindow() {
        if (state.view !== 'grid' || !songsActive()) return;
        const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main');
        if (!grid || !sizer || !main) return;
        const { cols, rowH } = measureGeom();
        const total = state.total || 0;
        const rows = Math.ceil(total / Math.max(1, cols));
        sizer.style.height = (rows * rowH) + 'px';
        if (total === 0) {
            grid.innerHTML = ''; grid.style.top = '0px';
            state.winRange = { start: 0, end: 0 };
            return;
        }
        const sizerTop = _sizerTopInScroller(main, sizer);
        const viewTop = Math.max(0, main.scrollTop - sizerTop);
        const viewBottom = viewTop + main.clientHeight;
        const firstRow = Math.max(0, Math.floor(viewTop / rowH) - OVERSCAN_ROWS);
        const lastRow = Math.min(rows - 1, Math.ceil(viewBottom / rowH) + OVERSCAN_ROWS);
        const start = firstRow * cols;
        const end = Math.min(total, (lastRow + 1) * cols);
        // Re-render when the range changed, a card is missing, OR select mode
        // toggled since the window was last painted (so checkboxes/rings on cached
        // cards track state — e.g. after leaving Songs in select mode and back).
        const same = state.winRange && state.winRange.start === start && state.winRange.end === end
            && state.renderedSelectMode === state.selectMode;
        if (same && !_windowHasHoles(start, end)) return;
        const myToken = ++_winToken;
        if (_windowHasHoles(start, end)) {
            await ensureWindow(start, end);
            if (_winToken !== myToken || state.view !== 'grid') return;   // superseded
        }
        if (_closeCardMenu) _closeCardMenu();   // its DOM is about to be replaced
        grid.style.top = (firstRow * rowH) + 'px';
        grid.innerHTML = _renderCardsRange(start, end);
        wireCards(grid);
        state.winRange = { start, end };
        state.renderedSelectMode = state.selectMode;
        if (sm && typeof sm.emit === 'function') {
            try { sm.emit('v3:library-window-rendered', { start, end, total }); } catch (e) { /* */ }
        }
    }

    // Reset/initial load of the grid. Clears the sparse store, fetches page 0
    // (which establishes state.total + whether the keyset fast-path is available),
    // then renders the window twice — the first render lays a real card so the
    // second can measure the true row height and settle the window size.
    async function loadGrid(reset) {
        // A reset requested mid-fetch (provider/sort/filter/search change) must
        // not be dropped — remember it and re-run once the in-flight load returns.
        if (state.loading) { if (reset) state.pendingReset = true; return; }
        const grid = _gridEl();
        if (!grid) return;
        if (reset) {
            if (_closeCardMenu) _closeCardMenu();
            state.epoch++;            // invalidate any in-flight page fetch from the old query
            state.songs = [];
            state.pageCursors = {};
            state.pageProms = {};
            state.keysetOk = false;
            state.winRange = null;
            state.renderedSelectMode = null;
            state.geom = null;
            state.total = 0;
            grid.innerHTML = '';
            grid.style.top = '0px';
            const sizer = _sizerEl();
            if (sizer) sizer.style.height = '0px';
        }
        state.loading = true;
        await _loadPage(0);
        state.loading = false;
        if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); }
        const countEl = document.getElementById('v3-songs-count');
        if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's');
        // The sentinel no longer drives loading (the sizer reserves full height);
        // keep the node for coexistence but it has no visible role.
        const sentinel = document.getElementById('v3-songs-sentinel');
        if (sentinel) sentinel.style.display = 'none';
        await renderWindow();   // first paint (rowH from estimate)
        await renderWindow();   // re-measure rowH from a real card, settle the window
    }

    // A scroll on #v3-main re-renders the window (rAF-coalesced). No more
    // near-bottom paging trigger — the visible range alone decides what's shown.
    function bindScroll() {
        const main = document.getElementById('v3-main');
        if (!main || state.scrollBound) return;
        state.scrollBound = true;
        main.addEventListener('scroll', () => {
            if (state.view !== 'grid') return;
            requestWindowRender();
        }, { passive: true });
    }

    // Re-measure + re-render when the scroller's WIDTH changes (column count and
    // the aspect-square art height both track width). Height-only changes just
    // need a re-render to widen/narrow the visible window.
    function bindGridResize() {
        if (state.gridResizeBound) return;
        const main = document.getElementById('v3-main');
        if (!main || typeof ResizeObserver !== 'function') return;
        state.gridResizeBound = true;
        let lastW = main.clientWidth;
        new ResizeObserver(() => {
            if (state.view !== 'grid') return;
            const w = main.clientWidth;
            if (w !== lastW) { lastW = w; state.geom = null; }   // force re-measure
            requestWindowRender();
        }).observe(main);
    }

    // ── A–Z jump rail interaction ─────────────────────────────────────────────
    // With the windowed grid the rail seeks DIRECTLY: sort_letters gives the
    // per-bucket song counts, so the first card of a letter is at the cumulative
    // count of the buckets before it — convert that index to a scrollTop and let
    // the scroll handler render+fetch the destination window (O(1), no page-
    // through). The rail only offers letters the server reports present for the
    // active sort+filter, so a tap always lands on a real card. (A legacy provider
    // lacking sort_letters falls back to a bounded forward scan.)
    function railEl() { return document.getElementById('v3-songs-azrail'); }
    function railBubbleEl() { return document.getElementById('v3-songs-azbubble'); }
    function railVisible() { return state.view === 'grid' && !!railSortColumn(); }

    let _railToken = 0;
    async function refreshRail() {
        const rail = railEl();
        if (!rail) return;
        if (!railVisible()) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
        const col = railSortColumn();
        // A newer refresh (sort/filter/search/provider change) supersedes this
        // one — a slow stats response must not repaint a rail the grid moved on.
        const myToken = ++_railToken;
        // Present letters for the active sort+filter (filter-synced; counts
        // songs). `sort_letters=1` opts into the active-sort breakdown so the
        // dashboard / v2 tree (which read only `letters`) skip the extra query.
        const stats = await jget('/api/library/stats?' + queryParams({ sort_letters: 1 }).toString());
        if (_railToken !== myToken || !railVisible()) {                 // changed mid-fetch
            if (_railToken === myToken) rail.classList.add('hidden');
            return;
        }
        // Prefer the active-sort breakdown. `letters` is the artist distinct-
        // count, so it only matches the cards on an artist sort; a legacy/third-
        // party provider that predates `sort_letters` returns none, in which
        // case a title sort would advertise wrong letters — hide the rail then.
        let letters = stats && stats.sort_letters;
        // sort_letters counts SONGS per bucket of the active sort column — exactly
        // the cumulative the windowed jump needs to seek to a row index. The
        // `letters` fallback is a distinct-ARTIST count (legacy provider without
        // sort_letters, artist sort only), which can't drive a precise seek — flag
        // it so jumpToLetter does a bounded scan instead of trusting the math.
        const songCounts = !!(stats && stats.sort_letters);
        if (!letters) {
            if (col === 'artist') letters = (stats && stats.letters) || {};
            else { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
        }
        state.railLetters = letters;
        state.railLettersAreSongCounts = songCounts;
        // No present letters (empty or fully-filtered grid) → nothing to jump
        // to; hide the rail instead of rendering a column of disabled buttons.
        if (!Object.keys(letters).length) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
        const desc = state.sort.endsWith('-desc');
        const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS;
        // Roving tabindex: only the first present letter is in the tab order;
        // the rest are reached with the arrow keys (see bindRailOnce). Avoids
        // dumping up to 27 tab stops into the page.
        let firstPresent = true;
        rail.innerHTML = order.map((L) => {
            const n = letters[L] || 0;
            const present = n > 0;
            const tabbable = present && firstPresent;
            if (tabbable) firstPresent = false;
            const name = L === '#' ? 'non-alphabetical' : L;
            return '<button type="button" class="v3-azrail-letter" data-letter="' + esc(L) + '"'
                + (present ? '' : ' disabled') + ' tabindex="' + (tabbable ? '0' : '-1') + '"'
                + ' aria-label="Jump to ' + esc(name) + (present ? ', ' + n + ' song' + (n === 1 ? '' : 's') : ' (none)') + '">'
                + esc(L) + '</button>';
        }).join('');
        rail.classList.remove('hidden');
        bindRailOnce();
    }

    function _setRailActive(letter) {
        railEl()?.querySelectorAll('.v3-azrail-letter').forEach((b) => {
            b.classList.toggle('is-active', b.getAttribute('data-letter') === letter);
        });
    }
    function _showBubble(letter) { const b = railBubbleEl(); if (b) { b.textContent = letter; b.classList.remove('hidden'); } }
    function _hideBubble() { railBubbleEl()?.classList.add('hidden'); }

    // The absolute index of the first card in a bucket, from the sort_letters
    // song-counts: sum the counts of every bucket ordered before it. O(1) — no
    // page-through. Returns null when we don't have true song-counts (the legacy
    // distinct-artist fallback), so the caller can scan instead.
    function _letterStartIndex(letter) {
        if (!state.railLettersAreSongCounts) return null;
        const letters = state.railLetters || {};
        const desc = state.sort.endsWith('-desc');
        const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS;
        let idx = 0;
        for (const b of order) { if (b === letter) return idx; idx += (letters[b] || 0); }
        return idx;
    }

    // Fallback for providers without sort_letters: walk the sparse store forward
    // (fetching pages as needed, bounded by total) until a card's bucket matches.
    async function _scanForLetter(letter, token) {
        const total = state.total || 0;
        for (let i = 0; i < total; i++) {
            if (state.songs[i] === undefined) {
                await ensureWindow(i, Math.min(total, i + PAGE_SIZE));
                if (_jumpToken !== token) return null;
            }
            const s = state.songs[i];
            if (s && songBucket(s) === letter) return i;
        }
        return null;
    }

    let _jumpToken = 0;
    async function jumpToLetter(letter) {
        const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main');
        if (!grid || !sizer || !main || state.view !== 'grid' || !letter) return;
        _setRailActive(letter);
        const myToken = ++_jumpToken;   // a newer jump supersedes this one
        const { cols, rowH } = measureGeom();
        let targetIndex = _letterStartIndex(letter);
        if (targetIndex == null) {
            targetIndex = await _scanForLetter(letter, myToken);
            if (_jumpToken !== myToken) return;
            if (targetIndex == null) return;   // letter not present
        }
        const total = state.total || 0;
        if (targetIndex >= total) targetIndex = Math.max(0, total - 1);
        const targetRow = Math.floor(targetIndex / Math.max(1, cols));
        // Pre-fetch the destination window so cards are present when the smooth
        // scroll arrives (avoids a flash of skeletons at the landing row).
        await ensureWindow(targetIndex, Math.min(total, targetIndex + cols * (OVERSCAN_ROWS * 2 + 4)));
        if (_jumpToken !== myToken || state.view !== 'grid') return;
        const sizerTop = _sizerTopInScroller(main, sizer);
        const toolbar = document.getElementById('v3-songs-toolbar');
        const pad = (toolbar ? toolbar.offsetHeight : 0) + 12; // clear the sticky toolbar
        const top = Math.max(0, sizerTop + targetRow * rowH - pad);
        main.scrollTo({ top, behavior: 'smooth' });
        requestWindowRender();
    }

    function bindRailOnce() {
        const rail = railEl();
        if (!rail || rail._bound) return;
        rail._bound = true;
        let dragging = false, moved = false, lastDrag = null;
        const letterAtY = (y) => {
            const els = rail.querySelectorAll('.v3-azrail-letter');
            if (!els.length) return null;
            for (const el of els) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; }
            return y < els[0].getBoundingClientRect().top ? els[0] : els[els.length - 1]; // clamp past ends
        };
        rail.addEventListener('click', (e) => {
            const btn = e.target.closest('.v3-azrail-letter');
            if (!btn || btn.disabled) return;
            if (moved) { moved = false; return; }   // a drag already handled it
            jumpToLetter(btn.getAttribute('data-letter'));
        });
        rail.addEventListener('pointerdown', (e) => {
            const btn = e.target.closest('.v3-azrail-letter');
            if (!btn) return;
            dragging = true; moved = false; lastDrag = null;
            try { rail.setPointerCapture(e.pointerId); } catch (_) { /* */ }
            _showBubble(btn.getAttribute('data-letter'));
        });
        rail.addEventListener('pointermove', (e) => {
            if (!dragging) return;
            const el = letterAtY(e.clientY);
            if (!el || el.disabled) return;
            moved = true;
            const L = el.getAttribute('data-letter');
            _showBubble(L);
            if (L !== lastDrag) { lastDrag = L; jumpToLetter(L); }  // only on change
        });
        const end = () => { dragging = false; _hideBubble(); };
        rail.addEventListener('pointerup', end);
        rail.addEventListener('pointercancel', end);
        rail.addEventListener('keydown', (e) => {
            if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
            const btns = [...rail.querySelectorAll('.v3-azrail-letter:not([disabled])')];
            const i = btns.indexOf(document.activeElement);
            if (i < 0) return;
            e.preventDefault();
            const next = btns[i + (e.key === 'ArrowDown' ? 1 : -1)];
            if (next) {
                btns[i].setAttribute('tabindex', '-1');   // roving tabindex follows focus
                next.setAttribute('tabindex', '0');
                next.focus();
                jumpToLetter(next.getAttribute('data-letter'));
            }
        });
    }

    // Pin the sticky toolbar directly beneath the sticky topbar. Both live in
    // the #v3-main scroller, so without an explicit offset they share top:0 and
    // the toolbar covers the topbar's song search. The topbar has two responsive
    // rows, so its height is measured (and re-measured on resize) instead of
    // hard-coded.
    function positionToolbar() {
        const topbar = document.getElementById('v3-topbar');
        const bar = document.getElementById('v3-songs-toolbar');
        if (!topbar || !bar) return;
        bar.style.top = topbar.offsetHeight + 'px';
    }
    function bindToolbarReflow() {
        if (state.resizeBound) return;
        const topbar = document.getElementById('v3-topbar');
        if (!topbar) return;
        state.resizeBound = true;
        // Observe the topbar itself: its height changes with viewport width AND
        // when the song search is toggled in/out on screen changes. ResizeObserver
        // fires once on observe(), so this also fixes up the initial position
        // regardless of render() vs syncActive() ordering.
        if (typeof ResizeObserver === 'function') {
            new ResizeObserver(positionToolbar).observe(topbar);
        } else {
            window.addEventListener('resize', positionToolbar, { passive: true });
        }
    }

    // ── Tree ────────────────────────────────────────────────────────────────
    async function loadTree() {
        const host = document.getElementById('v3-songs-tree');
        if (!host) return;
        // Capture expanded groups BEFORE the "Loading…" wipe below, so a reload
        // (e.g. toggling select mode) restores them instead of collapsing all.
        const openArtists = new Set(
            [...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist')));
        host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
        // Page through ALL artists — the endpoint clamps size to 100, so a
        // single request would silently truncate libraries with >100 artists.
        const artists = [];
        let page = 0, total = Infinity;
        while (artists.length < total) {
            const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }).toString());
            if (!data || !Array.isArray(data.artists)) break;
            artists.push(...data.artists);
            total = (data.total_artists != null) ? data.total_artists : artists.length;
            if (!data.artists.length || page > 1000) break;   // safety: no progress / runaway guard
            page++;
        }
        if (!artists.length) { host.innerHTML = '<p class="text-fb-textDim text-sm">Nothing here.</p>'; return; }
        artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; })));
        host.innerHTML = artists.map((a) =>
            '<details data-artist="' + esc(a.name) + '"' + (openArtists.has(a.name) ? ' open' : '') + ' class="border-b border-fb-border/40"><summary class="cursor-pointer py-2 text-fb-text flex items-center justify-between">' +
            '<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' +
            '<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) =>
                '<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
                (al.songs || []).map((s) => {
                    const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k);
                    // Display-only checkbox (pointer-events-none); the row's
                    // capture-phase select handler (render()) owns the toggle.
                    const checkbox = state.selectMode
                        ? '<input type="checkbox" data-select class="shrink-0 w-5 h-5 accent-fb-primary pointer-events-none"' + (sel ? ' checked' : '') + '>'
                        : '';
                    return (
                    '<div class="relative flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
                    checkbox +
                    '<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
                    '<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
                    (chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
                    (fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
                    accuracyBadge(k, 'tree') +
                    // Same fav / save-for-later / overflow-menu cluster as the grid
                    // card. Always shown (like the arrangement chips), not hover-
                    // revealed. wireCards() binds all three for any [data-fn].
                    '<div class="flex items-center gap-0.5 shrink-0">' +
                    '<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (s.favorite ? 'true' : 'false') + '" class="px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
                    '<button data-save title="Save for later" aria-label="Save for later" class="px-1 text-fb-textDim hover:text-fb-text">🔖</button>' +
                    '<button data-menu title="More" aria-label="More actions" class="px-1 text-fb-textDim hover:text-fb-text leading-none">⋮</button>' +
                    '</div>' +
                    '</div>'); }).join('') + '</div>').join('') + '</div></details>').join('');
        wireCards(host);
    }

    // ── Filter drawer ─────────────────────────────────────────────────────--
    function triState(list_has, list_lacks, value) {
        if (list_has.includes(value)) return 'has';
        if (list_lacks.includes(value)) return 'lacks';
        return 'any';
    }
    function cycleTri(hasArr, lacksArr, value) {
        const s = triState(hasArr, lacksArr, value);
        const rm = (a) => { const i = a.indexOf(value); if (i >= 0) a.splice(i, 1); };
        rm(hasArr); rm(lacksArr);
        if (s === 'any') hasArr.push(value);
        else if (s === 'has') lacksArr.push(value);
        // 'lacks' → cycles back to any (already removed)
    }
    function triPill(group, value, label, st) {
        const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40'
            : st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40'
                : 'bg-gray-800/50 text-fb-textDim border-gray-700';
        const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : '';
        return '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '">' + mark + esc(label) + '</button>';
    }
    function renderDrawer() {
        const d = document.getElementById('v3-songs-drawer');
        if (!d) return;
        const f = state.filters;
        d.innerHTML =
            '<div class="p-5 space-y-5">' +
            '<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
            '<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
            section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
            section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
            section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
            section('Tuning', (state.tuningNames || []).map((t) => {
                // Filter on the server's grouping key (raw offsets for customs)
                // so two "Custom Tuning" entries are distinct; show their target
                // notes in the label so they're distinguishable.
                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;
                }
                return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
            }).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
            // Collections always replay against the LOCAL library, so only offer
            // "save" when browsing local with a non-empty filter set.
            (state.provider === 'local' && Object.keys(currentFilterRules()).length
                ? '<div class="pt-3 border-t border-fb-border/50"><button data-drawer-save class="w-full text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md py-2">＋ Save as collection</button></div>'
                : '') +
            '<div class="flex justify-between pt-3 border-t border-fb-border/50"><button data-drawer-clear class="text-sm text-fb-textDim hover:text-fb-text">Clear all</button>' +
            '<button data-drawer-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Done</button></div></div>';

        d.querySelectorAll('[data-tri]').forEach((b) => b.addEventListener('click', () => {
            const g = b.getAttribute('data-tri'), v = b.getAttribute('data-val');
            if (g === 'arr') cycleTri(f.arr_has, f.arr_lacks, v);
            else if (g === 'stem') cycleTri(f.stem_has, f.stem_lacks, v);
            else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); }
            renderDrawer();
        }));
        d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
        d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
        d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
        d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
            state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] };
            state.artist = '';
            state.album = '';
            renderDrawer();
            await loadArtistCatalog();
            refreshArtistAlbumSelects();
            reload();
        });
        d.querySelector('[data-drawer-apply]')?.addEventListener('click', async () => {
            closeDrawer();
            await loadArtistCatalog();
            refreshArtistAlbumSelects();
            reload();
        });
    }
    function section(label, inner) {
        return '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-2">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
    }
    function openDrawer() { renderDrawer(); document.getElementById('v3-songs-drawer')?.classList.remove('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.remove('hidden'); }
    function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
    function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }

    // The host loads the Folder Library plugin's screen.js at startup (defining
    // window.folderLibrary). If it isn't present yet, inject it once; the
    // plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The
    // promise is memoised so concurrent folder-view switches don't double-inject.
    let _flLoadPromise = null;
    function _ensureFolderLibrary() {
        if (window.folderLibrary) return Promise.resolve();
        if (_flLoadPromise) return _flLoadPromise;
        _flLoadPromise = new Promise((resolve, reject) => {
            const s = document.createElement('script');
            s.src = '/api/plugins/folder_library/screen.js';
            s.onload = () => resolve();
            s.onerror = () => { _flLoadPromise = null; reject(new Error('Failed to load Folder Library')); };
            document.head.appendChild(s);
        });
        return _flLoadPromise;
    }

    function reload() {
        _clearLibraryScrollSnapshot();
        // Record the state this fetch reflects so a later sidebar return can
        // tell whether the grid is stale (e.g. an off-screen search changed
        // state.q) and needs a refresh rather than a scroll-preserving no-op.
        state.renderedHash = _libraryStateHash();
        updateFilterBadge();
        // A sort/filter/search/view change rebuilds the grid from page 0, so any
        // in-flight letter jump is now paging through a dataset that's about to
        // be discarded — supersede it so it can't scroll the rebuilt grid.
        _jumpToken++;
        // Keep a handle on the load so callers (notably the scroll restore on
        // screen re-entry) can await page-0 actually landing before paging
        // deeper. The visibility/scroll resets below stay synchronous.
        // Hide the SIZER (not the inner grid) for non-grid views, so its reserved
        // scroll height collapses and the tree/folder content sits at the top.
        document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
        document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
        document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
        // Refresh the A–Z jump rail (shows only for the grid + alphabetical
        // sorts; hides itself otherwise). Independent of the grid load.
        refreshRail();
        // Refresh the practice-aware home (repertoire meter + keep-practicing
        // shelf); hides itself when searching/filtering/selecting or off-grid.
        updateLibraryHome();
        { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
        if (state.view === 'folder') {
            _applyMainScrollTop(0);
            return _ensureFolderLibrary().then(() => window.folderLibrary?.load());
        }
        const loaded = state.view === 'grid' ? loadGrid(true) : loadTree();
        _applyMainScrollTop(0);
        return loaded;
    }

    // ── Chrome ────────────────────────────────────────────────────────────--
    async function loadProviders() {
        try {
            const lp = sm && sm.libraryProviders;
            // refresh() re-fetches /api/library/providers so REMOTE providers
            // (registered by feedBack-plugin-remote-library-*) appear — list()
            // returns only the capability's initial local-only snapshot.
            const fn = lp && (typeof lp.refresh === 'function' ? lp.refresh : (typeof lp.list === 'function' ? lp.list : null));
            if (fn) {
                const snap = await fn.call(lp);
                if (snap && Array.isArray(snap.providers)) {
                    state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local';
                    return snap.providers;
                }
            }
        } catch (e) { /* */ }
        const data = await jget('/api/library/providers');
        return (data && data.providers) || [{ id: 'local', label: 'My Library' }];
    }

    async function render() {
        const root = document.getElementById('v3-songs');
        if (!root) return;
        const providers = await loadProviders();
        const [, tn] = await Promise.all([
            (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
            jget('/api/library/tuning-names?provider=' + enc(state.provider)),
            loadArtistCatalog(),
        ]);
        state.tuningNames = (tn && tn.tunings) || [];

        const opt = (arr, sel) => arr.map(([v, l]) => '<option value="' + esc(v) + '"' + (v === sel ? ' selected' : '') + '>' + esc(l) + '</option>').join('');
        const provOpts = providers.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === state.provider ? ' selected' : '') + '>' + esc(p.label || p.id) + '</option>').join('');
        const ctrl = btnCtrl;

        root.innerHTML =
            '<div class="max-w-7xl mx-auto px-6 md:px-8 pb-8">' +
            '<div id="v3-songs-toolbar" class="sticky z-20 -mx-6 md:-mx-8 px-6 md:px-8 py-3 mb-4 bg-fb-sidebar/95 backdrop-blur border-b border-fb-border/40">' +
            '<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">' +
            '<div><p class="text-fb-textDim text-sm" id="v3-songs-count"></p></div>' +
            '<div class="flex flex-wrap gap-2">' +
            (providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
            '<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
            '<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
            '<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
            '<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
            '<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
            '<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
            '<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
            '<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
            '</div></div></div>' +
            // Practice-aware library home: a repertoire progress meter + a
            // "Keep practicing" shelf of started-but-not-mastered songs. Shown
            // only on the grid view when not searching/filtering/selecting
            // (renderLibraryHome + updateLibraryHome). Empty/absent → collapses.
            '<div id="v3-lib-home" class="hidden mb-5"></div>' +
            // Windowed grid: the sizer reserves the full-library scroll height;
            // #v3-songs-grid is absolutely positioned inside it and holds only the
            // visible window's cards (.v3-grid-window in v3.css).
            '<div id="v3-songs-gridsizer" class="relative">' +
            '<div id="v3-songs-grid" class="v3-grid-window grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
            '</div>' +
            '<div id="v3-songs-tree" class="hidden"></div>' +
            '<div id="lib-folder-controls" style="display:none"></div>' +
            '<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
            '<div id="v3-songs-sentinel" class="h-8"></div>' +
            // A–Z jump rail (grid + alphabetical sorts only; populated by
            // refreshRail). The bubble shows the current letter while dragging.
            '<nav id="v3-songs-azrail" class="v3-azrail hidden" aria-label="Jump to letter"></nav>' +
            '<div id="v3-songs-azbubble" class="v3-azbubble hidden" aria-hidden="true"></div>' +
            // Filter drawer + overlay
            '<div id="v3-songs-overlay" class="fixed inset-0 bg-black/50 z-40 hidden"></div>' +
            '<aside id="v3-songs-drawer" class="fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll"></aside>' +
            '</div>';

        // Wire toolbar.
        const byId = (id) => document.getElementById(id);
        byId('v3-songs-provider')?.addEventListener('change', async (e) => {
            state.provider = e.target.value;
            state.artist = '';
            state.album = '';
            try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
            await loadArtistCatalog();
            refreshArtistAlbumSelects();
            reload();
        });
        byId('v3-songs-artist')?.addEventListener('change', (e) => setArtist(e.target.value));
        byId('v3-songs-album')?.addEventListener('change', (e) => setAlbum(e.target.value));
        byId('v3-songs-sort').addEventListener('change', (e) => { state.sort = e.target.value; reload(); });
        byId('v3-songs-format').addEventListener('change', async (e) => {
            state.format = e.target.value;
            await loadArtistCatalog();
            refreshArtistAlbumSelects();
            reload();
        });
        byId('v3-songs-filters').addEventListener('click', openDrawer);
        byId('v3-songs-overlay').addEventListener('click', closeDrawer);
        byId('v3-songs-upload').addEventListener('click', () => {
            const legacy = document.getElementById('upload-songs-file');
            // Upload targets the LOCAL library + scan; watchUploadScan refreshes
            // the grid for the local provider. Uploading while browsing a remote
            // provider won't surface the new local songs — switching the grid to
            // local on upload is a P23 remote-provider follow-up.
            if (legacy) { legacy.click(); watchUploadScan(); }
        });
        byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));

        // Bulletproof multi-select: in select mode, a capture-phase click on the
        // grid toggles the card and STOPS the event, so nothing (a per-card
        // handler, a stray/legacy listener, an arrangement chip) can start
        // playback. Fixes "checkbox click opens the song / access-denied".
        const gridEl = byId('v3-songs-grid');
        if (gridEl) gridEl.addEventListener('click', (e) => {
            if (!state.selectMode) return;
            const card = e.target.closest('[data-fn]');
            if (!card || !gridEl.contains(card)) return;
            e.preventDefault();
            e.stopImmediatePropagation();
            toggleSelect(card.getAttribute('data-fn'), card);
        }, true);

        // Same bulletproof guard for the list/tree view. Without it, clicking a
        // song row (or its arrangement chip) in select mode falls through to the
        // per-card play handler and starts playback instead of selecting. The
        // <summary> group headers sit OUTSIDE any [data-fn], so closest() is null
        // for them and their native expand/collapse is left untouched.
        const treeEl = byId('v3-songs-tree');
        if (treeEl) treeEl.addEventListener('click', (e) => {
            if (!state.selectMode) return;
            const card = e.target.closest('[data-fn]');
            if (!card || !treeEl.contains(card)) return;
            e.preventDefault();
            e.stopImmediatePropagation();
            toggleSelect(card.getAttribute('data-fn'), card);
        }, true);
        const setView = async (v) => {
            state.view = v;
            byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
            byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
            byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
            if (v === 'folder') await _ensureFolderLibrary();
            return reload();
        };
        byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
        byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
        byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder'));
        // Await the initial load so a caller awaiting render() (the scroll
        // restore on screen re-entry) sees a populated grid + real state.total
        // before it tries to page deeper.
        await setView(state.view);
        bindScroll();
        bindGridResize();
        positionToolbar();
        bindToolbarReflow();
        updateFilterBadge();
        state.built = true;
    }

    async function onV3SongsScreenEnter() {
        // A library scan / DLC-folder change marked the grid stale — re-fetch
        // from scratch instead of restoring a cached (possibly empty, pre-DLC)
        // snapshot. Must win over every fast-path below.
        if (_libraryDirty) { _libraryDirty = false; await reload(); return; }
        // Pull in any scores recorded while the library was off-screen (the usual
        // play→return flow) before the fast-paths below restore the cached DOM,
        // so the just-played song's badge is current. The full render() path
        // re-fetches accuracy itself, so this is a no-op cost there.
        await applyScoreRefresh();
        const snap = _readLibraryScrollSnapshot();
        const hashMatch = !!(snap && snap.hash === _libraryStateHash());
        const domReady = state.built && !!document.getElementById('v3-songs-grid');
        const chromeOk = _chromeIntact();
        const viewOk = state.view === (snap && snap.view ? snap.view : state.view);

        if (snap && hashMatch && domReady && chromeOk && viewOk) {
            if (state.view === 'grid' && _gridDomIntact()) {
                // Geometry is stable (the sizer still holds the full height from
                // the prior session), so restore is just: restore scrollTop, then
                // repaint the window that maps to it. No more page-through.
                document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', false);
                document.getElementById('v3-songs-tree')?.classList.toggle('hidden', true);
                syncChromeFromState();
                updateLibraryHome();   // select-mode clear on leave re-shows the home block
                _applyMainScrollTop(snap.scrollTop || 0);
                requestWindowRender();
                _clearLibraryScrollSnapshot();
                return;
            }
            if (state.view === 'tree' && _treeDomIntact()) {
                document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', true);
                document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false);
                syncChromeFromState();
                _applyMainScrollTop(snap.scrollTop || 0);
                _clearLibraryScrollSnapshot();
                return;
            }
        }

        // Sidebar return without a player snapshot — keep grid, refresh chrome.
        if (!snap && domReady && chromeOk && state.built) {
            syncChromeFromState();
            // If state drifted while we were away (notably an off-screen topbar
            // search updating state.q), the persisted grid is stale — refetch
            // instead of silently showing the old results. Unchanged state keeps
            // the scroll-preserving no-op.
            if (state.renderedHash !== _libraryStateHash()) { reload(); return; }
            document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
            document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
            document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
            { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
            updateLibraryHome();   // select-mode clear on leave re-shows the home block
            // Re-render in case the viewport resized while we were away (column
            // count / row height may have changed) or select mode was cleared.
            if (state.view === 'grid') requestWindowRender();
            return;
        }

        const snapToRestore = hashMatch ? snap : null;
        if (snap && !hashMatch) _clearLibraryScrollSnapshot();
        await render();
        if (snapToRestore && snapToRestore.hash === _libraryStateHash()) {
            // render() built + sized the sizer at scrollTop 0; move to the saved
            // position and let the scroll handler repaint that window.
            _applyMainScrollTop(snapToRestore.scrollTop || 0);
            if (state.view === 'grid') requestWindowRender();
        }
        _clearLibraryScrollSnapshot();
    }

    // After an upload click-through (which reuses the legacy uploader +
    // background scan), poll /api/scan-status and reload the v3 grid once the
    // scan we triggered finishes — the legacy uploader only refreshes the
    // legacy screens, so without this newly-uploaded songs wouldn't appear in
    // v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
    let _uploadScanTimer = null;
    function watchUploadScan() {
        if (_uploadScanTimer) clearInterval(_uploadScanTimer);
        let sawRunning = false, ticks = 0;
        _uploadScanTimer = setInterval(async () => {
            ticks++;
            let sd = null;
            try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
            if (sd && sd.running) sawRunning = true;
            if ((sawRunning && sd && !sd.running) || ticks >= 90) {
                clearInterval(_uploadScanTimer); _uploadScanTimer = null;
                if (sawRunning) reload();
            }
        }, 1000);
    }

    // Topbar search drives this screen.
    async function search(q) {
        state.q = q || '';
        if (songsActive()) {
            await loadArtistCatalog();
            refreshArtistAlbumSelects();
            reload();
        } else if (window.showScreen) {
            window.showScreen('v3-songs');
        }
    }

    window.v3Songs = {
        render: render,
        reload: reload,
        search: search,
        setQuery: (q) => { state.q = q || ''; },
        getSort: () => state.sort,
        getArtist: () => state.artist,
        getAlbum: () => state.album,
        // The grid is windowed: only a slice of cards is in the DOM at any time.
        // A plugin that decorates cards should read THIS (not a global
        // querySelectorAll that assumes every card is present) and re-run on each
        // `v3:library-window-rendered` event rather than once at load.
        visibleCards: () => document.querySelectorAll('#v3-songs-grid [data-fn]'),
        filterParams: () => {
            const f = state.filters;
            const p = new URLSearchParams();
            if (f.arr_has.length)   p.set('arrangements_has',   f.arr_has.join(','));
            if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
            if (f.stem_has.length)  p.set('stems_has',          f.stem_has.join(','));
            if (f.stem_lacks.length) p.set('stems_lacks',       f.stem_lacks.join(','));
            if (f.lyrics)           p.set('has_lyrics',         f.lyrics);
            if (f.tunings.length)   p.set('tunings',            f.tunings.join(','));
            return p.toString();
        },
        _scrollHelpers: {
            SCROLL_STATE_KEY,
            buildLibraryStateHash,
            readSnapshot: _readLibraryScrollSnapshot,
            clearSnapshot: _clearLibraryScrollSnapshot,
        },
    };

    if (sm && typeof sm.on === 'function') {
        sm.on('screen:changed', (e) => {
            const id = e && e.detail && e.detail.id;
            if (id === 'v3-songs') { onV3SongsScreenEnter(); return; }
            // Leaving Songs: tear down select mode + the body-mounted batch bar,
            // so an active multi-selection doesn't leave a floating bar (and
            // stale selection) visible on unrelated screens.
            if (state.selectMode || state.selected.size) {
                state.selectMode = false;
                state.selected.clear();
                const bar = document.getElementById('v3-songs-batch');
                if (bar) bar.remove();
            }
        });
        // stats-recorder POSTs the score asynchronously and emits this once the
        // server has the new best — that's the correct moment to refresh the
        // badge (song:stop fires before the POST resolves, so it's too early).
        // If the library is visible right now, repaint immediately; otherwise
        // mark it dirty and onV3SongsScreenEnter applies it on return.
        sm.on('stats:recorded', (e) => {
            // Decode to the library-card key space — the event carries the
            // encodeURIComponent'd filename, but cards (data-fn) and
            // state.accuracy key on the decoded library filename. Without this
            // the repaint below (and applyScoreRefresh's repaintAccuracy) match
            // no card, so a just-earned score stays invisible until a full
            // render() (restart / search / re-enter), which is this bug.
            const fn = decFn(e && e.detail && e.detail.filename);
            if (!fn) return;
            _dirtyScores.add(fn);
            // Only repaint now if the library is the active screen; otherwise
            // leave it dirty for onV3SongsScreenEnter (applyScoreRefresh clears
            // the set, so repainting against a hidden grid would drop the update).
            const active = document.querySelector('.screen.active');
            if (active && active.id === 'v3-songs') applyScoreRefresh();
        });
        // A library scan (rescan / full rescan from Settings, or a DLC-folder
        // change) can add or remove songs while this grid is cached — the
        // Settings rescan only refreshed the classic library, so the v3 grid
        // stayed on its pre-scan (e.g. empty, pre-DLC) state until an app
        // restart. Reload now if we're showing; otherwise mark dirty so the next
        // entry re-fetches instead of restoring the stale snapshot.
        sm.on('library:changed', () => {
            const active = document.querySelector('.screen.active');
            if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); }
            else _libraryDirty = true;
        });
    }
})();
