// Demo analytics — real impl set by demo.js; no-op in normal builds
window.feedBackDemoTrack = window.feedBackDemoTrack ?? null;
// Sync the play/pause button's icon and accessible state in one place so
// screen readers, tooltips, and aria-pressed stay aligned with playback.
// Updates the existing child's src in place rather than rewriting
// innerHTML, so any future children (fallback label, loading spinner, …)
// survive state changes.
function setPlayButtonState(isPlaying) {
const btn = document.getElementById('btn-play');
if (!btn) return;
const label = isPlaying ? 'Pause' : 'Play';
const icon = isPlaying ? 'pause' : 'play';
let img = btn.querySelector('img.button-icon-svg');
if (!img) {
img = document.createElement('img');
img.className = 'button-icon-svg';
img.alt = '';
img.setAttribute('aria-hidden', 'true');
btn.appendChild(img);
}
img.src = `/static/svg/${icon}.svg`;
btn.setAttribute('aria-label', label);
btn.setAttribute('aria-pressed', isPlaying ? 'true' : 'false');
btn.title = label;
}
// ── Global keyboard shortcuts ─────────────────────────────────────────────
//
// `/` focuses the active screen's search input (Library / Favorites);
// `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail
// convention. The listener bails when the user is already typing in
// any text-accepting element so it can't intercept normal typing —
// including inputs inside the filters drawer, plugin settings, or
// modal dialogs.
function _isTextInput(el) {
if (!el) return false;
const tag = el.tagName;
if (tag === 'INPUT') {
// Some types (button, checkbox, radio, range, ...) don't
// accept text; only intercept the ones that do.
const t = (el.type || 'text').toLowerCase();
return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t);
}
if (tag === 'TEXTAREA') return true;
if (tag === 'SELECT') return true;
if (el.isContentEditable) return true;
return false;
}
function _isShortcutHelpKey(e) {
return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/'));
}
function _isShortcutHelpSuppressedTarget(el) {
if (!el) return false;
const tag = el.tagName;
if (tag === 'INPUT') {
const t = (el.type || 'text').toLowerCase();
return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t);
}
if (tag === 'TEXTAREA') return true;
if (el.isContentEditable) return true;
if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true;
return false;
}
function _activeSearchInput() {
// Pick the search field for whichever screen is currently active.
// No match (e.g. on the player or settings screen) means `/` does
// nothing — the shortcut only fires where a search box exists.
const active = document.querySelector('.screen.active');
if (!active) return null;
if (active.id === 'home') return document.getElementById('lib-filter');
if (active.id === 'favorites') return document.getElementById('fav-filter');
return null;
}
// ── Library keyboard navigation ──────────────────────────────────────────
//
// Arrow keys move a single "selected" item among the visible cards
// (grid view) or song rows (tree view). Enter plays the selected
// song. The selected element gets:
// - native keyboard focus via .focus() so :focus-visible draws the
// accessible ring (announced by screen readers, follows scroll)
// - a `.selected` class that persists when focus drifts elsewhere
// so the user can glance back and still see their place.
//
// Grid columns are inferred from the live computed grid template at
// the moment of navigation, so up/down works correctly across all
// breakpoints (1 / 2 / 3 / 4 cols depending on viewport).
function _isElementVisible(el) {
// Walk ancestors looking for display:none. Handles collapsed
// `.album-body` / `.artist-body` subtrees (hidden via CSS class
// rules). Using a DOM walk rather than `offsetParent` avoids the
// false-negative for `position:fixed` elements whose offsetParent
// is null even when they are perfectly visible.
if (!el) return false;
let node = el;
while (node && node !== document.body) {
if (getComputedStyle(node).display === 'none') return false;
node = node.parentElement;
}
return true;
}
// `_libNavItems` is consulted on every arrow / Enter / Space / Home /
// End / activation press, including during autorepeat. Re-running
// `querySelectorAll` + visibility filtering on every keypress is the
// dominant cost on large libraries (hundreds of nodes × per-keypress
// layout reads), so the result is memoised against a generation
// counter that's bumped only when the underlying DOM actually
// changes shape: render functions and `_toggleHeader` bump
// `_libNavGeneration`. Cache misses fall through to a fresh query.
let _libNavGeneration = 0;
let _libNavItemsCache = { gen: -1, items: [], container: null, mode: null, scope: null };
function _bumpLibNavGeneration() { _libNavGeneration++; }
function _libNavItems() {
const active = document.querySelector('.screen.active');
if (!active) return { items: [], container: null, mode: null };
let tree, grid;
if (active.id === 'home') {
tree = document.getElementById('lib-tree');
grid = document.getElementById('lib-grid');
} else if (active.id === 'favorites') {
tree = document.getElementById('fav-tree');
grid = document.getElementById('fav-grid');
} else {
return { items: [], container: null, mode: null };
}
const treeMode = tree && !tree.classList.contains('hidden');
const scope = treeMode ? tree : grid;
// Cache key includes the active container — switching grid↔tree or
// home↔favorites must miss even if the generation hasn't ticked.
if (
_libNavItemsCache.gen === _libNavGeneration &&
_libNavItemsCache.scope === scope &&
scope && document.body.contains(scope)
) {
return {
items: _libNavItemsCache.items,
container: _libNavItemsCache.container,
mode: _libNavItemsCache.mode,
};
}
let items, container, mode;
if (treeMode) {
// List mode — include artist headers, album headers, and song
// rows so arrow nav still works when artists/albums are
// collapsed (only the headers are visible then). Filter to
// the currently-displayed nodes so collapsed children don't
// count as targets the keyboard can land on.
const all = Array.from(tree.querySelectorAll(
'.artist-header, .album-header, .song-row[data-play], .song-row[data-library-song][tabindex="0"]'
));
items = all.filter(_isElementVisible);
container = tree;
mode = 'list';
} else {
items = Array.from((grid || document).querySelectorAll('.song-card[data-play], .song-card[data-library-song][tabindex="0"]'));
container = grid;
mode = 'grid';
}
_libNavItemsCache = { gen: _libNavGeneration, items, container, mode, scope };
return { items, container, mode };
}
function _gridColumns(container) {
// Count columns by grouping the first row of children by their
// top coordinate. Robust against any grid-template-columns syntax
// (`repeat(...)`, `auto-fit`, named lines, etc.) where naively
// splitting `getComputedStyle().gridTemplateColumns` on whitespace
// would miscount because of spaces inside `repeat(...)` /
// `minmax(...)`. Falls back to 1 when the container is empty
// so callers' max(1, ...) clamps stay valid.
if (!container) return 1;
const children = Array.from(container.children).filter(
c => c && c.offsetParent !== null
);
if (!children.length) return 1;
const firstTop = children[0].getBoundingClientRect().top;
let cols = 0;
for (const c of children) {
// Allow ~1px slop for sub-pixel rounding so two children that
// would visually align still group together.
if (Math.abs(c.getBoundingClientRect().top - firstTop) < 1.5) cols++;
else break;
}
return Math.max(1, cols);
}
// Tracked separately from `document.activeElement` so the persistent
// `.selected` highlight survives focus drifting elsewhere (clicks
// outside the grid, drawer opening, etc). Also lets us avoid a global
// `querySelectorAll('.selected')` on every arrow press — large
// libraries make that a noticeable hot path.
let _lastLibSelected = null;
// Tracks which list screen launched the player so Esc-from-player
// returns the user to that screen instead of always defaulting to
// the Library (feedBack#126). Reset on every `playSong` call so a
// song launched from a deep-link / plugin screen still gets a sane
// fallback ('home').
let _playerOriginScreen = 'home';
let _settingsOriginScreen = 'home';
// One-shot flag set in `showScreen` when the user enters Home or
// Favorites. Consumed by the very next library render so the
// restored selection scrolls into view exactly once on screen entry
// (player → home, hard reload). Routine re-renders driven by
// search / sort / filter changes leave the user's scroll position
// alone — the highlight still re-applies, but they aren't yanked.
const _libScrollOnNextRender = { home: false, favorites: false };
// localStorage keys for "remember the last selection across reloads
// and after returning from the player". One key per screen so the
// Library and Favorites trees don't fight over the same slot. Only
// song-row / song-card selections are persisted — header selections
// in the tree are ephemeral by design (re-derived from arrow nav).
const _LIB_SELECTED_KEY = 'feedBack.libLastSelected';
const _FAV_SELECTED_KEY = 'feedBack.favLastSelected';
function _selectedKeyForActiveScreen() {
const active = document.querySelector('.screen.active');
if (!active) return null;
if (active.id === 'home') return _LIB_SELECTED_KEY;
if (active.id === 'favorites') return _FAV_SELECTED_KEY;
return null;
}
function _persistLibSelection(el) {
if (!el || !el.dataset) return;
// Both local entries (data-play) and remote entries (data-library-song,
// no data-play yet) are persisted so the selection highlight survives a
// library re-render after sync or provider switch.
const isLocal = !!el.dataset.play;
const isRemote = !isLocal && !!el.dataset.librarySong;
if (!isLocal && !isRemote) return;
const key = _selectedKeyForActiveScreen();
if (!key) return;
// Stored as JSON `{f, a, p, s}`:
// f — encoded filename (local entries); drives data-play restore.
// a — artist, for future cross-page restore.
// p — encoded provider id; prevents cross-provider collisions.
// s — encoded song id (remote entries); drives data-library-song restore.
// Older bare-string and {f,a}/{f,a,p} formats are still tolerated in
// `_loadPersistedLibSelection`.
const artist = el.dataset.artist || '';
const provider = el.dataset.libraryProvider || '';
// For synced provider entries (data-play + data-library-song both present),
// persist both f and s so _restoreLibSelection can match the card by either
// attribute after a post-sync re-render.
const payload = isLocal
? { f: el.dataset.play, a: artist, p: provider, s: el.dataset.librarySong || '' }
: { f: '', a: artist, p: provider, s: el.dataset.librarySong };
try {
localStorage.setItem(key, JSON.stringify(payload));
} catch { /* private mode / quota */ }
}
function _loadPersistedLibSelection(key) {
let raw = null;
try { raw = localStorage.getItem(key); } catch { return null; }
if (!raw) return null;
// Tolerate the older bare-string format (just the encoded
// filename) — older builds wrote that and we'd rather upgrade
// silently than orphan the user's saved selection.
if (raw[0] !== '{') return { f: raw, a: '', p: '', s: '' };
try {
const o = JSON.parse(raw);
return (o && typeof o === 'object') ? { f: o.f || '', a: o.a || '', p: o.p || '', s: o.s || '' } : null;
} catch { return null; }
}
function _setLibSelection(el, { focus = true } = {}) {
if (!el) return;
// Only the previously-tracked element needs its `.selected` class
// cleared. classList.remove on an element that no longer carries
// the class is a no-op, so a stale `_lastLibSelected` from a
// re-render is harmless. Avoids the global `querySelectorAll`
// pass that the earlier implementation ran on every keypress.
if (_lastLibSelected && _lastLibSelected !== el) {
_lastLibSelected.classList.remove('selected');
}
el.classList.add('selected');
_lastLibSelected = el;
// Save song selections to localStorage so a reload (or returning
// from the player) can restore the highlight. Headers don't get
// persisted — they don't carry a stable id and the tree's auto-
// open heuristic re-derives them on each render anyway.
_persistLibSelection(el);
if (focus) {
// `preventScroll: true` skips the browser's native focus-scroll,
// then we run a single `scrollIntoView` so we don't double-jank
// when the element is partially in view. The browser's default
// focus scroll uses `block: 'nearest'` too but isn't smoothable
// and can interact poorly with sticky headers.
el.focus({ preventScroll: true });
}
_scrollSelectionIntoView(el);
}
// Scroll the selected element to keep it inside a margin from the
// viewport edges. Plain `scrollIntoView({block:'nearest'})` only
// reacts when the element is fully off-screen, so during arrow nav
// the selection drifts to the edge and stays partially visible
// until it falls off — feels laggy. Centering when the row enters
// the buffer zone keeps it comfortably on-screen as the user holds
// the arrow keys.
const _SCROLL_EDGE_MARGIN = 96;
function _scrollSelectionIntoView(el) {
if (!el) return;
const r = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < _SCROLL_EDGE_MARGIN || r.bottom > vh - _SCROLL_EDGE_MARGIN) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
}
}
function _restoreLibSelection(scopeEl, screen, { scroll = true } = {}) {
// Re-apply the persistent `.selected` class to whichever song
// matches the saved filename. For the tree we also walk up and
// open every collapsed ancestor so the restored row is actually
// visible — the user shouldn't have to hunt for their place
// inside a collapsed artist node.
if (!scopeEl) return null;
const key = screen === 'favorites' ? _FAV_SELECTED_KEY : _LIB_SELECTED_KEY;
const saved = _loadPersistedLibSelection(key);
if (!saved || (!saved.f && !saved.s)) return null;
// Match by dataset values — both stored and DOM values are in the
// encoded form, so no decoding is needed. Avoid interpolating persisted
// data into CSS selectors so malformed localStorage can't make
// querySelector throw and break rendering.
//
// Local entries: match data-play (f) + data-library-provider (p) when p
// is present to avoid cross-provider collisions on the same filename.
// Remote entries: match data-library-song (s) + data-library-provider (p).
// When f is present but no data-play card matches (e.g. the file has not
// been downloaded on this load), fall back to the s (provider song-id) so
// a previously-synced remote selection can still be restored.
let el = null;
if (saved.f) {
const candidates = scopeEl.querySelectorAll('.song-card[data-play], .song-row[data-play]');
el = Array.from(candidates).find((node) => {
if (node.dataset.play !== saved.f) return false;
if (saved.p && node.dataset.libraryProvider !== saved.p) return false;
return true;
});
}
if (!el && saved.s) {
const candidates = scopeEl.querySelectorAll('.song-card[data-library-song], .song-row[data-library-song]');
el = Array.from(candidates).find((node) => {
if (node.dataset.librarySong !== saved.s) return false;
if (saved.p && node.dataset.libraryProvider !== saved.p) return false;
return true;
});
}
if (!el) return null;
// Open every collapsed ancestor in the tree so the restored row
// is on-screen; harmless on the grid since cards have no such
// ancestors. Sync `aria-expanded` on the matching header inside
// each ancestor too — bypassing `_toggleHeader` here would leave
// assistive tech reporting "collapsed" while the visual is open.
let n = el.parentElement;
while (n && n !== scopeEl) {
if (n.classList.contains('artist-row') || n.classList.contains('album-group')) {
n.classList.add('open');
const header = Array.from(n.children).find(c => c.classList.contains('artist-header') || c.classList.contains('album-header'));
if (header) header.setAttribute('aria-expanded', 'true');
}
n = n.parentElement;
}
if (_lastLibSelected && _lastLibSelected !== el) {
_lastLibSelected.classList.remove('selected');
}
el.classList.add('selected');
_lastLibSelected = el;
// Center the restored element in the viewport so the user's eye
// lands on it instead of having to scan up from the bottom edge.
// `block: 'center'` is forgiving of items already on-screen — the
// browser only scrolls when needed to bring the requested
// alignment into view.
// Skip when the caller opts out (e.g. during search/filter/sort
// re-renders, where the user's scroll position should be left
// alone and only the `.selected` class is re-applied).
if (scroll) {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
}
return el;
}
function _moveSelectionInItems(items, deltaIdx) {
// Items are passed in by the caller so we don't re-query the DOM
// twice per keypress (handler queries `_libNavItems`, then we'd
// query it again).
if (!items.length) return false;
const current = document.activeElement && items.includes(document.activeElement)
? document.activeElement
: (_lastLibSelected && items.includes(_lastLibSelected) ? _lastLibSelected : null);
let idx = current ? items.indexOf(current) : -1;
let next;
if (idx === -1) {
// No current selection — first arrow lands on the first item
// regardless of direction. Saves a press.
next = items[0];
} else {
next = items[Math.max(0, Math.min(items.length - 1, idx + deltaIdx))];
}
_setLibSelection(next);
return true;
}
function _isInsideInteractiveControl(el) {
// Bail when the user is interacting with anything that has its
// own keyboard semantics — form controls (checkbox / select /
// button) consume arrow keys for their own behavior, and the
// filters drawer is a focus trap of those. Without this guard the
// library's arrow nav would steal arrow presses from a focused
// tuning checkbox or sort dropdown.
if (!el) return false;
const tag = el.tagName;
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return true;
if (el.isContentEditable) return true;
if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal')) return true;
return false;
}
function _isSpaceKey(e) {
return e.key === ' ' || e.key === 'Spacebar';
}
function _sectionPracticeBarContains(el) {
if (!el) return false;
const bar = document.getElementById('section-practice-bar');
return !!(bar && bar.contains(el));
}
function _shortcutDispatchBlocked(e) {
if (_isTextInput(e.target)) return true;
// Space in Section Practice bar should pause/resume, not toggle checkboxes/buttons.
if (_isSpaceKey(e) && _sectionPracticeBarContains(e.target)) return false;
// While the Section Practice popover is open, Esc just closes it (handled by
// the popover's own keydown listener) — suppress the player-scope
// "back to library" Esc so the user doesn't get bounced out of the player.
if (e.key === 'Escape' && _sectionPracticePopoverOpen()) return true;
// Space on the player screen should always play/pause, even if focus is on a
// sidebar nav link, player rail button, popover control, or any other
// interactive element — the shortcut dispatcher calls preventDefault so the
// focused element won't also activate. Two exceptions keep native Space:
// text inputs (already exempted above), and focus inside a true modal
// dialog (role="dialog" aria-modal="true", or a .feedBack-modal overlay)
// layered over the player — a modal traps interaction, so Space must reach
// its focused control (e.g. the Close button) rather than toggle playback
// behind it. Non-modal player popovers/toasts (loop A/B, arrangement pin,
// role="dialog" aria-modal="false") are not modals and stay covered.
if (_isSpaceKey(e) && _getCurrentContext().isPlayer &&
!(e.target && e.target.closest &&
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
return false;
}
// Escape is the universal "back" action and must fire like Space above even
// when a transport/rail control