// Demo analytics — real impl set by demo.js; no-op in normal builds
window.slopsmithDemoTrack = window.slopsmithDemoTrack ?? 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, .slopsmith-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 (slopsmith#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 = 'slopsmith.libLastSelected';
const _FAV_SELECTED_KEY = 'slopsmith.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;
return _isInsideInteractiveControl(e.target);
}
function _handleLibArrowNav(e) {
// Space (' ') is the standard activation key for focusable
// elements alongside Enter — without it, a screen-reader user
// hitting Space on a focused card would just scroll the page
// instead of activating it. We treat Space identically to Enter
// inside this handler.
const isActivate = e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar';
if (!isActivate &&
!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
return false;
}
if (_isInsideInteractiveControl(document.activeElement)) return false;
const { items, container, mode } = _libNavItems();
if (!items.length) return false;
const currentTarget = (document.activeElement && items.includes(document.activeElement))
? document.activeElement
: (_lastLibSelected && items.includes(_lastLibSelected) ? _lastLibSelected : null);
if (isActivate) {
if (!currentTarget) return false;
e.preventDefault();
// Sync persistent selection before activating so Tab-then-Enter
// (no prior arrow nav or mouse click) still lights up the `.selected`
// ring and updates `_lastLibSelected`/localStorage — consistent with
// the click delegate at the bottom of this file.
_setLibSelection(currentTarget, { focus: false });
if (currentTarget.classList.contains('song-row') ||
currentTarget.classList.contains('song-card')) {
if (currentTarget.dataset.librarySong && !currentTarget.dataset.play) {
const providerId = decodeURIComponent(currentTarget.dataset.libraryProvider || '');
if (!_providerSupports(providerId, 'song.sync')) return true;
syncLibrarySong(
providerId,
decodeURIComponent(currentTarget.dataset.librarySong || ''),
{ playWhenReady: true },
);
return true;
}
// Song row OR card → play it. Pass `dataset.play` raw to
// match the click delegate; `playSong` handles decoding
// internally so decoding here would double-decode and
// throw `URIError` on filenames containing `%`.
playSong(currentTarget.dataset.play, undefined, { bridge: false });
} else if (currentTarget.classList.contains('artist-header') ||
currentTarget.classList.contains('album-header')) {
// Header row → toggle the parent open/closed and re-derive
// visible items so the next arrow press lands correctly.
// `_toggleHeader` keeps `aria-expanded` in sync for
// assistive tech.
_toggleHeader(currentTarget);
// Keep keyboard focus on the header we just toggled —
// browsers sometimes drop focus to body when the
// surrounding subtree changes display.
currentTarget.focus({ preventScroll: true });
}
return true;
}
if (e.key === 'Home') { e.preventDefault(); _setLibSelection(items[0]); return true; }
if (e.key === 'End') { e.preventDefault(); _setLibSelection(items[items.length - 1]); return true; }
if (mode === 'list') {
if (e.key === 'ArrowDown') { e.preventDefault(); _moveSelectionInItems(items, 1); return true; }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveSelectionInItems(items, -1); return true; }
// Right/Left expand and collapse the artist/album under focus,
// file-manager style. With nothing selected yet, both keys
// initialize selection on the first visible item (matches
// Up/Down behavior in `_moveSelectionInItems`) so the first
// press doesn't fall through to native scroll.
if (!currentTarget && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
e.preventDefault();
_setLibSelection(items[0]);
return true;
}
if (e.key === 'ArrowRight' && currentTarget) {
const parent = (currentTarget.classList.contains('artist-header') ||
currentTarget.classList.contains('album-header'))
? currentTarget.parentElement : null;
if (parent && !parent.classList.contains('open')) {
e.preventDefault();
// Use the shared toggle path so aria-expanded stays
// synced with the visual state for screen readers.
_toggleHeader(currentTarget);
currentTarget.focus({ preventScroll: true });
return true;
}
// Already open — step to the next visible item (which is
// the first child of this header).
e.preventDefault();
_moveSelectionInItems(items, 1);
return true;
}
if (e.key === 'ArrowLeft' && currentTarget) {
// If on an open header, collapse it. If on a song row or
// closed header, jump to the nearest enclosing header.
const isHeader = currentTarget.classList.contains('artist-header') ||
currentTarget.classList.contains('album-header');
const headerParent = isHeader ? currentTarget.parentElement : null;
if (headerParent && headerParent.classList.contains('open')) {
e.preventDefault();
_toggleHeader(currentTarget);
currentTarget.focus({ preventScroll: true });
return true;
}
// Walk up to the nearest .album-header / .artist-header
// ancestor's sibling header. Closest album-group → its
// header; otherwise closest artist-row → its header.
const albumGroup = currentTarget.closest('.album-group');
if (albumGroup && albumGroup.contains(currentTarget) &&
!currentTarget.classList.contains('album-header')) {
e.preventDefault();
_setLibSelection(albumGroup.querySelector('.album-header'));
return true;
}
const artistRow = currentTarget.closest('.artist-row');
if (artistRow && !currentTarget.classList.contains('artist-header')) {
e.preventDefault();
_setLibSelection(artistRow.querySelector('.artist-header'));
return true;
}
return false;
}
return false;
}
// Grid mode: 2D nav. Columns are read from the live CSS grid so
// we follow the responsive breakpoints automatically.
const cols = _gridColumns(container);
if (e.key === 'ArrowRight') { e.preventDefault(); _moveSelectionInItems(items, 1); return true; }
if (e.key === 'ArrowLeft') { e.preventDefault(); _moveSelectionInItems(items, -1); return true; }
if (e.key === 'ArrowDown') { e.preventDefault(); _moveSelectionInItems(items, cols); return true; }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveSelectionInItems(items, -cols); return true; }
return false;
}
// Focus trap: keep Tab / Shift+Tab cycling inside `modal` so focus
// can't escape to the content underneath while the overlay is open.
// Call this once after the modal is in the DOM and initial focus is set.
function _trapFocusInModal(modal) {
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const els = Array.from(modal.querySelectorAll(FOCUSABLE)).filter(el => {
if (!_isElementVisible(el)) return false;
if (getComputedStyle(el).visibility === 'hidden') return false;
if (el.disabled) return false;
return true;
});
if (!els.length) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
}
// Styled async confirm dialog. Returns a Promise. For destructive
// prompts pass `danger: true` — confirm button turns red and Cancel gets
// initial focus so an accidental Enter won't fire the action. `body` is
// inserted as HTML so callers can use formatting; callers are responsible
// for escaping any user-supplied content in it (use _escAttr).
function _confirmDialog({ title, body = '', confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
return new Promise((resolve) => {
const previouslyFocused = document.activeElement;
const modal = document.createElement('div');
modal.className = 'slopsmith-modal fixed inset-0 z-[250] flex items-center justify-center bg-black/70 backdrop-blur-sm';
modal.setAttribute('role', 'alertdialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-label', title || 'Confirm');
const confirmClass = danger
? 'flex-1 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-red-400/60'
: 'flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-accent/60';
modal.innerHTML = `
${_escAttr(title || '')}
${body}
`;
document.body.appendChild(modal);
function finish(result) {
modal.remove();
document.removeEventListener('keydown', onKey, true);
if (previouslyFocused && document.body.contains(previouslyFocused)) {
try { previouslyFocused.focus({ preventScroll: true }); } catch {}
}
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); finish(false); }
else if (e.key === 'Enter' && document.activeElement === modal.querySelector('[data-confirm]')) {
e.preventDefault(); finish(true);
}
}
modal.addEventListener('click', (e) => {
if (e.target === modal) finish(false);
else if (e.target.closest('[data-confirm]')) finish(true);
else if (e.target.closest('[data-cancel]')) finish(false);
});
document.addEventListener('keydown', onKey, true);
_trapFocusInModal(modal);
// Focus Cancel by default for destructive prompts so an accidental
// Enter / Space won't fire the dangerous action; otherwise focus
// the confirm button so Enter accepts.
const focusTarget = modal.querySelector(danger ? '[data-cancel]' : '[data-confirm]');
if (focusTarget) focusTarget.focus({ preventScroll: true });
});
}
// Shortcut cheat-sheet overlay. Opens on `?` (Shift+/), closes on
// Esc (handled by the generic modal close path) or on backdrop /
// close-button click. The list mirrors the canonical shortcut table
// in this file's keydown handler — when a shortcut changes here, the
// table below should change too. We keep it inline rather than
// fetching a separate file so the cheat sheet can never disagree
// with the version of app.js the user actually loaded.
function _openShortcutsModal() {
if (document.getElementById('shortcuts-modal')) return;
function _isTreeMode() {
// Check if we're in tree view (not grid) on the active library screen
const screen = document.querySelector('.screen.active');
if (!screen) return false;
const tree = screen.querySelector('#lib-tree,#fav-tree');
return tree && !tree.classList.contains('hidden');
}
const ctx = _getCurrentContext();
// Library shortcuts that are handled by the navigation system (not in registry)
const navShortcuts = [
{ keys: '↑ ↓', desc: 'Move selection' },
{ keys: '→', desc: 'Step in', condition: _isTreeMode },
{ keys: '←', desc: 'Step out', condition: _isTreeMode },
{ keys: 'Home / End', desc: 'Jump to first / last item' },
{ keys: 'Enter / Space', desc: 'Activate selection (play song / toggle header)' },
];
// Filter out items whose condition returns false
const filterNavItems = (items) => items.filter(item => !item.condition || item.condition());
// Format a shortcut entry for display, including modifier prefixes
const formatShortcut = (s) => {
const mods = s.modifiers || {};
let label = '';
if (mods.ctrl) label += 'Ctrl+';
if (mods.alt) label += 'Alt+';
if (mods.shift) label += 'Shift+';
if (mods.meta) label += 'Meta+';
return label + s.key;
};
// Get shortcuts from active panel by scope
const getPanelShortcuts = (panel, scope) => {
const shortcuts = [];
for (const [key, s] of panel.shortcuts) {
if (s.scope === scope) {
shortcuts.push({ keys: formatShortcut(s), desc: s.description });
}
}
return shortcuts;
};
const activePanel = _panels.get(_activePanel);
const defaultPanel = _panels.get('default');
// Merge shortcuts from both active and default panel for display
const mergeShortcuts = (scope) => {
const result = [];
if (activePanel) result.push(...getPanelShortcuts(activePanel, scope));
if (defaultPanel && defaultPanel !== activePanel) result.push(...getPanelShortcuts(defaultPanel, scope));
return result;
};
const playerShortcuts = mergeShortcuts('player');
const globalShortcuts = mergeShortcuts('global');
const libraryShortcuts = mergeShortcuts('library');
// Get plugin shortcuts for current plugin screen
const pluginShortcuts = [];
if (ctx.isPlugin && activePanel) {
for (const [key, s] of activePanel.shortcuts) {
if (s.scope.startsWith('plugin-') && s.scope === ctx.screen) {
pluginShortcuts.push({ keys: formatShortcut(s), desc: s.description });
}
}
}
// Get shortcuts from other panels (if multiple panels exist)
const otherPanelShortcuts = [];
if (_panels.size > 1) {
for (const [panelId, panel] of _panels) {
if (panelId === _activePanel) continue;
for (const [key, s] of panel.shortcuts) {
otherPanelShortcuts.push({ keys: formatShortcut(s), desc: s.description, panel: panelId });
}
}
}
// Build sections based on current context
const sections = [];
if (ctx.isSettings) {
sections.push({ heading: 'Settings', items: mergeShortcuts('settings') });
} else if (ctx.isLibrary) {
sections.push({ heading: 'Library', items: [
...filterNavItems(navShortcuts),
...libraryShortcuts,
{ keys: 'Esc', desc: 'Clear search' }
]});
}
if (ctx.isPlayer) {
sections.push({ heading: 'Player', items: playerShortcuts });
}
if (!ctx.isSettings && globalShortcuts.length > 0) {
sections.push({ heading: 'Global', items: globalShortcuts });
}
if (pluginShortcuts.length > 0) {
sections.push({ heading: 'Current Plugin', items: pluginShortcuts });
}
if (otherPanelShortcuts.length > 0) {
// Group other panel shortcuts by panel
const byPanel = new Map();
for (const item of otherPanelShortcuts) {
if (!byPanel.has(item.panel)) {
byPanel.set(item.panel, []);
}
byPanel.get(item.panel).push(item);
}
for (const [panelId, items] of byPanel) {
sections.push({ heading: `Panel ${panelId}`, items });
}
}
const modal = document.createElement('div');
modal.id = 'shortcuts-modal';
modal.className = 'slopsmith-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-label', 'Keyboard shortcuts');
// Record the element that triggered the modal so Esc / close can
// return focus to the correct entry even if _lastLibSelected drifts.
// Scope to the active screen so a stale _lastLibSelected from a
// different screen (e.g. Library vs Favorites) doesn't receive focus.
const _scModal = document.querySelector('.screen.active');
modal._opener = (_lastLibSelected && document.body.contains(_lastLibSelected)
&& _scModal && _scModal.contains(_lastLibSelected))
? _lastLibSelected : null;
const sectionsHtml = sections.map(section => {
const itemsHtml = section.items.map(({ keys, desc }) => `
${esc(desc)}${esc(keys)}
`).join('');
return `
${esc(section.heading)}
${itemsHtml}
`;
}).join('');
modal.innerHTML = `
Keyboard shortcuts
${sectionsHtml}
`;
// Click outside the inner panel (i.e. on the backdrop) closes the
// modal — matches the conventional dialog UX.
modal.addEventListener('click', (ev) => {
if (ev.target === modal || ev.target.closest('[data-shortcuts-close]')) {
const opener = modal._opener;
modal.remove();
const focusTarget = (opener && document.body.contains(opener)) ? opener
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
if (focusTarget) focusTarget.focus({ preventScroll: true });
}
});
document.body.appendChild(modal);
// Move focus into the dialog so background shortcuts (and arrow
// nav) can't fire on the underlying library entry while the
// overlay is open. Close button is the safe default — there's no
// primary input to focus on a read-only cheat sheet.
const closeBtn = modal.querySelector('[data-shortcuts-close]');
if (closeBtn) closeBtn.focus({ preventScroll: true });
// Trap Tab / Shift+Tab inside the modal so focus can't escape to
// the library content underneath while the overlay is open.
_trapFocusInModal(modal);
}
document.addEventListener('keydown', (e) => {
// Modifier-key combos belong to the browser / OS shortcuts; never
// intercept those.
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (_handleLibArrowNav(e)) return;
// `?` (Shift+/) opens the keyboard-shortcuts cheat sheet. Some
// Linux/Electron stacks report Shift+/ as key='/' with code='Slash',
// so check the help shape before treating plain '/' as search.
if (_isShortcutHelpKey(e)) {
if (_isShortcutHelpSuppressedTarget(e.target || document.activeElement)) return;
e.preventDefault();
// Stop other keydown listeners on document (notably the shortcut
// registry below) from also consuming this event — otherwise a
// Linux/Electron Shift+Slash reported as key='/' opens help here and
// then the registry's plain `/` library-search shortcut focuses
// #lib-filter behind the modal. (Copilot review on #602.)
e.stopImmediatePropagation();
_openShortcutsModal();
return;
}
if (e.key === '/') {
if (_isTextInput(document.activeElement)) return;
// Also bail when focus is inside the filter drawer, a dialog, or
// any other interactive region — those contexts have their own
// keyboard semantics and shouldn't be hijacked by the search
// shortcut (e.g. a focused checkbox inside the filters drawer).
if (_isInsideInteractiveControl(document.activeElement)) return;
const search = _activeSearchInput();
if (!search) return;
e.preventDefault(); // suppress the literal '/' the input would receive
search.focus();
// Move caret to end without mutating .value — round-tripping
// the value resets the browser's undo stack and can fire
// unexpected input events on some engines. setSelectionRange
// is the no-side-effects path.
try {
const len = search.value.length;
search.setSelectionRange(len, len);
} catch {
// Some input types (search/email/tel) don't support
// selection APIs in older browsers; the focus alone is
// still useful, just no caret-end guarantee.
}
return;
}
// Single-letter shortcuts that act on the focused / selected
// library entry — works on both grid cards and tree rows. Each
// dispatches to a button class that the entry markup already
// exposes, so plugins can keep owning the actual behavior:
// c → .sloppak-convert-btn (Sloppak Converter plugin)
// f → .fav-btn (favorite heart toggle)
// e → .edit-btn (edit metadata modal)
// No-op when no entry is currently focused / selected, when the
// entry doesn't expose the requested button (e.g. a sloppak
// entry has no convert button), or when the button is disabled.
// Bails on text input / drawer focus so single-letter typing in
// inputs still works.
const entryShortcut = { c: 'button.sloppak-convert-btn', f: 'button.fav-btn', e: 'button.edit-btn' }[e.key.toLowerCase()];
if (entryShortcut) {
if (_isInsideInteractiveControl(document.activeElement)) return;
const ae = document.activeElement;
const activeScreen = document.querySelector('.screen.active');
const isEntry = el => el && el.classList && (el.classList.contains('song-card') || el.classList.contains('song-row'));
// Scope both candidates to the active screen so that a stale
// _lastLibSelected from Library doesn't fire when the user is
// on Favorites (or vice-versa), and so pressing f/e/c on a
// hidden screen can't accidentally persist that filename into
// the current screen's localStorage key.
const inActiveScreen = el => activeScreen && activeScreen.contains(el);
const target = (isEntry(ae) && inActiveScreen(ae)) ? ae
: (isEntry(_lastLibSelected) && inActiveScreen(_lastLibSelected) ? _lastLibSelected : null);
if (!target) return;
const btn = target.querySelector(entryShortcut);
if (!btn || btn.disabled) return;
e.preventDefault();
// Sync the persistent selection to the acted-on entry so that
// Esc-to-close-modal returns focus to the correct element and
// the `.selected` highlight stays consistent with the action.
_setLibSelection(target, { focus: false });
btn.click();
return;
}
if (e.key === 'Escape') {
// Modal-first: close the topmost open modal (edit-metadata,
// shortcuts cheat sheet, future modals) so Esc dismisses
// from anywhere — including when keyboard focus is inside
// a form field within the modal. Restores focus to the
// element that opened the modal (tracked in modal._opener)
// so arrow nav resumes without an extra Tab; falls back to
// _lastLibSelected when the opener is no longer in the DOM.
const modals = document.querySelectorAll('[role="dialog"][aria-modal="true"].slopsmith-modal');
if (modals.length) {
e.preventDefault();
e.stopImmediatePropagation();
const modal = modals[modals.length - 1];
const opener = modal._opener;
modal.remove();
const focusTarget = (opener && document.body.contains(opener)) ? opener
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
if (focusTarget) focusTarget.focus({ preventScroll: true });
return;
}
// Esc while typing in either search box clears + blurs. Other Esc
// semantics (drawer close, screen back) are handled elsewhere; we
// only act when a search box is the focused element.
const ae = document.activeElement;
if (ae && (ae.id === 'lib-filter' || ae.id === 'fav-filter')) {
if (ae.value) {
ae.value = '';
ae.dispatchEvent(new Event('input', { bubbles: true }));
}
ae.blur();
}
}
});
// ── Screen Navigation ─────────────────────────────────────────────────────
async function showScreen(id) {
// Capture the previous screen before changing active classes
const prevScreenId = document.querySelector('.screen.active')?.id;
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
// Mark the next render as a screen-entry so it scrolls the
// restored selection into view exactly once. Routine renders
// (search / sort / filter typing) won't have this flag set and
// so won't yank the viewport. Also bump the nav-items
// generation so the next keypress doesn't reuse a cache built
// against a now-hidden screen's container.
_bumpLibNavGeneration();
if (id === 'home') {
_libScrollOnNextRender.home = true;
const beforeProviderId = _activeLibraryProviderId();
await loadLibraryProviders({ restoreSaved: true });
if (_activeLibraryProviderId() !== beforeProviderId) {
_resetLibraryProviderViewState();
} else {
_libEpoch++;
currentPage = 0;
_treeStats = null;
stopInfiniteScroll();
}
loadLibrary(0);
}
if (id === 'favorites') { _libScrollOnNextRender.favorites = true; loadFavorites(); }
if (id === 'settings') {
// Record where we came from so Esc can go back. The player screen
// is torn down by the `id !== 'player'` branch below, so
// re-entering it via showScreen() would land on a dead screen —
// fall back to the player's own origin (or 'home') instead.
if (prevScreenId && prevScreenId !== 'settings') {
_settingsOriginScreen = prevScreenId === 'player'
? (_playerOriginScreen || 'home')
: prevScreenId;
}
loadSettings();
}
if (id !== 'player') {
const audio = document.getElementById('audio');
const stopTime = _audioTime();
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying;
highway.stop();
// Cancel any queued seeks, in-flight shim closures, AND active
// count-in timers before stopping playback so none of these paths
// can mutate the torn-down session (mirrors the same triple reset
// in playSong()).
_cancelCountIn();
_resetJuceAudioShimChain();
_resetAudioSeekState();
if (window._juceMode) {
// HTML5 emits 'pause' via the media-element listener below;
// JUCE doesn't, so plugins would stay stuck in "playing".
// Snapshot the canonical payload BEFORE stop() resets _pos
// to 0, then emit AFTER stop completes. Mirrors the HTML5
// pause contract via _songEventPayload (audioT/chartT/perfNow).
const payload = _songEventPayload();
const wasPlaying = isPlaying;
await jucePlayer.stop().catch(() => {});
if (wasPlaying && window.slopsmith) {
window.slopsmith.isPlaying = false;
window.slopsmith.emit('song:pause', payload);
}
window._juceMode = false;
window._juceAudioUrl = null;
}
if (hadPlayableSong) window.slopsmith.emit('song:stop', { time: stopTime || 0, screen: id });
audio.pause();
audio.src = '';
window._currentSongAudio = null;
// Reloading any song later should get a fresh JUCE routing attempt.
window._clearJuceRerouteMemo?.();
isPlaying = false;
setPlayButtonState(false);
}
window.scrollTo(0, 0);
if (window.slopsmith) window.slopsmith.emit('screen:changed', { id });
}
// ── Library ──────────────────────────────────────────────────────────────
// Persist the view toggle (grid vs tree), sort selection, and format
// filter across reloads. Stored as separate keys (rather than one
// blob) so future controls can opt in independently and a corrupted
// single value doesn't wipe the rest. Validation lives at the read
// site — we coerce unknown values back to safe defaults rather than
// trusting whatever happens to be in localStorage.
const _LIB_VIEW_KEY = 'slopsmith.libView';
const _LIB_SORT_KEY = 'slopsmith.libSort';
const _LIB_FORMAT_KEY = 'slopsmith.libFormat';
const _LIB_PROVIDER_KEY = 'slopsmith.libProvider';
const _LIB_VIEW_VALUES = new Set(['grid', 'tree']);
const _LIB_SORT_VALUES = new Set([
'artist', 'artist-desc', 'title', 'title-desc',
'recent', 'year-desc', 'year', 'tuning',
]);
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
// Tree-view expand/collapse persistence. Three states per tree:
// '1' → user asked to expand all
// '0' → user asked to collapse all
// null → no explicit choice; renderTreeInto's existing heuristic
// (auto-open when search active or few artists) wins
//
// Library and Favorites are separate trees with separate
// Expand/Collapse buttons, so each gets its own key — toggling one
// must not flip the other's persisted state.
const _LIB_TREE_EXPAND_KEY = 'slopsmith.libTreeExpand';
const _FAV_TREE_EXPAND_KEY = 'slopsmith.favTreeExpand';
const _LIB_TREE_EXPAND_VALUES = new Set(['1', '0']);
function _readPersistedChoice(key, allowed, fallback) {
try {
const v = localStorage.getItem(key);
return v !== null && allowed.has(v) ? v : fallback;
} catch {
return fallback;
}
}
function _writePersistedChoice(key, value) {
try { localStorage.setItem(key, value); } catch { /* private mode / quota */ }
}
function _libraryProviderApi() {
const api = window.slopsmith && window.slopsmith.libraryProviders;
return api && typeof api === 'object' ? api : null;
}
function _libraryProviderSnapshot() {
const api = _libraryProviderApi();
if (api && typeof api.snapshot === 'function') return api.snapshot();
return { available: false, current: 'local', providers: [{ id: 'local', label: 'My Library', kind: 'local', capabilities: ['library.read', 'art.read', 'song.play'], default: true }] };
}
function _providerById(providerId) {
const api = _libraryProviderApi();
if (api && typeof api.providerById === 'function') return api.providerById(providerId);
return (_libraryProviderSnapshot().providers || []).find(provider => provider.id === providerId) || null;
}
function _activeLibraryProvider() {
const api = _libraryProviderApi();
if (api && typeof api.activeProvider === 'function') return api.activeProvider();
const snapshot = _libraryProviderSnapshot();
return _providerById(snapshot.current) || _providerById('local') || (snapshot.providers || [])[0];
}
function _activeLibraryProviderId() {
const api = _libraryProviderApi();
if (api && typeof api.activeProviderId === 'function') return api.activeProviderId();
return (_activeLibraryProvider() || {}).id || 'local';
}
function _isLocalLibraryProvider(providerId) {
const api = _libraryProviderApi();
if (api && typeof api.isLocal === 'function') return api.isLocal(providerId);
const provider = _providerById(providerId);
return providerId === 'local' || (provider && provider.kind === 'local');
}
function _providerSupports(providerId, capability) {
const api = _libraryProviderApi();
if (api && typeof api.supports === 'function') return api.supports(providerId, capability);
const provider = _providerById(providerId);
return !!provider && Array.isArray(provider.capabilities) && provider.capabilities.includes(capability);
}
function _applyLibraryProviderToParams(params) {
params.set('provider', _activeLibraryProviderId());
return params;
}
function _resetLibraryProviderViewState() {
_libEpoch++;
currentPage = 0;
_treePage = 0;
_treeStats = null;
_tuningNames = null;
stopInfiniteScroll();
}
function _renderLibraryProviderSelector() {
const select = document.getElementById('lib-provider');
const title = document.getElementById('lib-title');
const activeProvider = _activeLibraryProvider();
const providers = _libraryProviderSnapshot().providers || [];
if (select) {
select.innerHTML = providers.map(provider =>
``
).join('');
select.value = activeProvider.id;
select.classList.toggle('hidden', providers.length <= 1);
}
if (title) title.textContent = activeProvider.id === 'local' ? 'Your Library' : (activeProvider.label || activeProvider.id);
}
async function loadLibraryProviders({ restoreSaved = false, reloadOnChange = false } = {}) {
const beforeProviderId = _activeLibraryProviderId();
const api = _libraryProviderApi();
if (api && typeof api.refresh === 'function') {
await api.refresh({ restoreSaved });
}
_renderLibraryProviderSelector();
const afterProviderId = _activeLibraryProviderId();
if (reloadOnChange && afterProviderId !== beforeProviderId) {
_resetLibraryProviderViewState();
loadLibrary(0);
}
}
async function setLibraryProvider(providerId, options = {}) {
const beforeProviderId = _activeLibraryProviderId();
try {
const capabilityApi = window.slopsmith && window.slopsmith.capabilities;
if (capabilityApi && typeof capabilityApi.command === 'function') {
await capabilityApi.command('library', 'select-provider', {
requester: 'app.library',
target: { providerId },
payload: options && typeof options === 'object' ? options : {},
});
} else {
_libraryProviderApi()?.select?.(String(providerId || ''));
}
} catch (err) {
// Reached from an inline onchange="setLibraryProvider(this.value)"
// handler that does not await us, so a rejection would otherwise
// surface as an unhandled promise rejection. Log and bail without a
// reload. Re-render the selector so the