diff --git a/plugins/career/routes.py b/plugins/career/routes.py index dfc6834..ef0fb20 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -5,12 +5,10 @@ accuracy across arrangements crosses 0/1/2/3 of the thresholds in ``venues.json`` (data-driven so tuning never touches code). Cumulative stars unlock venue tiers (bar → club → arena). -Venue packs (crowd-loop videos rendered offline in UE) are heavyweight and -never ship with the app: ``venues.json`` points at a release asset per -venue, downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues/ -/`` on a background thread (constitution: nothing heavy inline on the -request path), sha256-verified, then served back with the same -FileResponse/no-cache recipe as highway_3d's custom-video route. +Venue packs (crowd-loop videos rendered offline in UE) may be bundled with +the plugin under ``venue-packs//`` or downloaded on demand into +``CONFIG_DIR/plugin_uploads/career/venues//``. Downloaded packs override +bundled packs so release assets can replace a built-in starter venue. Endpoints (all under /api/plugins/career/): GET /state stars + per-venue unlock/install/download status @@ -42,6 +40,7 @@ DOWNLOAD_CHUNK = 1024 * 256 _lock = threading.Lock() _state = { "content": None, # parsed venues.json + "plugin_dir": None, # plugin root; bundled packs live below it "venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues "meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL) "log": logging.getLogger("feedBack.plugin.career"), @@ -60,8 +59,27 @@ def _venue_dir(venue_id) -> Path: return _state["venues_dir"] / venue_id +def _bundled_venue_dir(venue_id) -> Path: + return _state["plugin_dir"] / "venue-packs" / venue_id + + +def _pack_dir(venue_id): + """Runtime pack location: downloaded override first, bundled fallback.""" + local = _venue_dir(venue_id) + if (local / "manifest.json").is_file(): + return local + bundled = _bundled_venue_dir(venue_id) + if (bundled / "manifest.json").is_file(): + return bundled + return local + + def _installed(venue_id): - return (_venue_dir(venue_id) / "manifest.json").is_file() + return (_pack_dir(venue_id) / "manifest.json").is_file() + + +def _bundled(venue_id): + return (_bundled_venue_dir(venue_id) / "manifest.json").is_file() def _stars(): @@ -174,12 +192,16 @@ def _download_pack(venue_id, pack, progress): def setup(app, context): plugin_dir = Path(__file__).resolve().parent + _state["plugin_dir"] = plugin_dir _state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8")) _state["venues_dir"] = ( Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues") _state["venues_dir"].mkdir(parents=True, exist_ok=True) _state["meta_db"] = context.get("meta_db") _state["log"] = context.get("log") or _state["log"] + for v in _state["content"]["venues"]: + if _bundled(v["id"]): + _validate_pack_dir(_bundled_venue_dir(v["id"])) @app.get(f"/api/plugins/{PLUGIN_ID}/state") def get_state(): @@ -195,7 +217,8 @@ def setup(app, context): "star_threshold": v["star_threshold"], "unlocked": stars_total >= v["star_threshold"], "installed": _installed(v["id"]), - "has_pack": bool(v.get("pack")), + "bundled": _bundled(v["id"]), + "has_pack": _bundled(v["id"]) or bool(v.get("pack")), "download": dl, }) return { @@ -244,12 +267,13 @@ def setup(app, context): async def get_pack_file(venue_id: str, filename: str): if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename): raise HTTPException(404, "Not found.") - path = _venue_dir(venue_id) / filename + pack_dir = _pack_dir(venue_id) + path = pack_dir / filename # Defense-in-depth beyond the regexes (same recipe as highway_3d): - # the resolved path must stay inside the venues dir. + # the resolved path must stay inside the selected pack dir. try: resolved = path.resolve() - resolved.relative_to(_state["venues_dir"].resolve()) + resolved.relative_to(pack_dir.resolve()) except (OSError, ValueError): raise HTTPException(404, "Not found.") if not resolved.is_file(): diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 2cc523d..1d081cb 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -89,9 +89,12 @@ const main = active ? `` : ``; + const remove = v.bundled + ? '' + : ``; action = `
${main} - + ${remove}
`; } else if (v.has_pack) { const err = dl.status === 'error' diff --git a/plugins/career/venue-packs/bar/bar-ambience.mp3 b/plugins/career/venue-packs/bar/bar-ambience.mp3 new file mode 100644 index 0000000..56c8a2f Binary files /dev/null and b/plugins/career/venue-packs/bar/bar-ambience.mp3 differ diff --git a/plugins/career/venue-packs/bar/bored.mp4 b/plugins/career/venue-packs/bar/bored.mp4 new file mode 100644 index 0000000..09e850f Binary files /dev/null and b/plugins/career/venue-packs/bar/bored.mp4 differ diff --git a/plugins/career/venue-packs/bar/cheer.mp4 b/plugins/career/venue-packs/bar/cheer.mp4 new file mode 100644 index 0000000..d59f3c4 Binary files /dev/null and b/plugins/career/venue-packs/bar/cheer.mp4 differ diff --git a/plugins/career/venue-packs/bar/clap.mp4 b/plugins/career/venue-packs/bar/clap.mp4 new file mode 100644 index 0000000..89959ab Binary files /dev/null and b/plugins/career/venue-packs/bar/clap.mp4 differ diff --git a/plugins/career/venue-packs/bar/ecstatic.mp4 b/plugins/career/venue-packs/bar/ecstatic.mp4 new file mode 100644 index 0000000..039f7ab Binary files /dev/null and b/plugins/career/venue-packs/bar/ecstatic.mp4 differ diff --git a/plugins/career/venue-packs/bar/engaged.mp4 b/plugins/career/venue-packs/bar/engaged.mp4 new file mode 100644 index 0000000..f0a4cbd Binary files /dev/null and b/plugins/career/venue-packs/bar/engaged.mp4 differ diff --git a/plugins/career/venue-packs/bar/intro.mp4 b/plugins/career/venue-packs/bar/intro.mp4 new file mode 100644 index 0000000..b7277e3 Binary files /dev/null and b/plugins/career/venue-packs/bar/intro.mp4 differ diff --git a/plugins/career/venue-packs/bar/manifest.json b/plugins/career/venue-packs/bar/manifest.json new file mode 100644 index 0000000..913f3f9 --- /dev/null +++ b/plugins/career/venue-packs/bar/manifest.json @@ -0,0 +1,18 @@ +{ + "venue": "bar", + "version": 1, + "loops": { + "bored": "bored.mp4", + "neutral": "neutral.mp4", + "engaged": "engaged.mp4", + "ecstatic": "ecstatic.mp4" + }, + "stingers": { + "clap": "clap.mp4", + "cheer": "cheer.mp4" + }, + "intro": { + "video": "intro.mp4", + "audio": "bar-ambience.mp3" + } +} diff --git a/plugins/career/venue-packs/bar/neutral.mp4 b/plugins/career/venue-packs/bar/neutral.mp4 new file mode 100644 index 0000000..b141f0e Binary files /dev/null and b/plugins/career/venue-packs/bar/neutral.mp4 differ diff --git a/static/app.js b/static/app.js index 86368c3..4eea1a3 100644 --- a/static/app.js +++ b/static/app.js @@ -258,6 +258,27 @@ import { playSong, showScreen, } from './js/session.js'; +import { + ShortcutPanel, + _DEBUG_SHORTCUTS, + _activePanel, + _activeSearchInput, + _defaultPanel, + _getCurrentContext, + _gridColumns, + _handleLibArrowNav, + _isInsideInteractiveControl, + _isShortcutActive, + _isShortcutHelpKey, + _isShortcutHelpSuppressedTarget, + _isSpaceKey, + _isTextInput, + _modifiersMatch, + _openShortcutsModal, + _panels, + _shortcutDispatchBlocked, + defaultPanel, +} from './js/shortcuts.js'; // The playback transport. These used to BE app.js — they are imported back now, and the // four modules that reached for them through the host seam import them directly instead. import { @@ -270,57 +291,6 @@ import { // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; -// ── 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 @@ -336,544 +306,6 @@ function _activeSearchInput() { // breakpoints (1 / 2 / 3 / 4 cols depending on viewport). -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); -} - -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 _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 - - ${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: - // 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, or when the button is disabled. - // Bails on text input / drawer focus so single-letter typing in - // inputs still works. - const entryShortcut = { 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"].feedBack-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(); - } - } -}); - // ── Library ────────────────────────────────────────────────────────────── const _LIB_PROVIDER_KEY = 'feedBack.libProvider'; @@ -2436,377 +1868,23 @@ window.getShortcutWindowId = () => { // Each panel has its own shortcut registry. This allows multiple panels // (e.g., splitscreen) to have their own keyboard shortcuts without collisions. -class ShortcutPanel { - constructor(id) { - this.id = id; - this.shortcuts = new Map(); - } - - _compositeKey(key, scope) { - return `${scope}::${key}`; - } - - registerShortcut(options) { - const { key, description, scope = 'global', condition = null, handler, modifiers = null } = options; - - if (!key || !handler) { - console.error(`registerShortcut: key and handler are required`); - return; - } - - // Validate scope - const validScopes = ['global', 'player', 'library', 'settings']; - const isValidScope = validScopes.includes(scope) || - scope.startsWith('plugin-'); - if (!isValidScope) { - console.warn(`registerShortcut: invalid scope '${scope}'. Valid scopes are: global, player, library, settings, or plugin-{id}`); - } - - // Conflict detection: warn if key+scope is already registered - const compositeKey = this._compositeKey(key, scope); - if (this.shortcuts.has(compositeKey)) { - console.warn(`registerShortcut [${this.id}]: '${key}' in scope '${scope}' is already registered; overwriting. Previous:`, this.shortcuts.get(compositeKey)); - } - - this.shortcuts.set(compositeKey, { key, description, scope, condition, handler, modifiers }); - } - - unregisterShortcut(key, scope) { - return this.shortcuts.delete(this._compositeKey(key, scope)); - } - - clearShortcuts() { - this.shortcuts.clear(); - } - - listShortcuts() { - return Array.from(this.shortcuts.entries()).map(([ck, s]) => [s.key, s]); - } -} - -// Global panel management -const _panels = new Map(); -let _activePanel = null; -let _defaultPanel = null; - -// Create default panel on init -const defaultPanel = new ShortcutPanel('default'); -_panels.set('default', defaultPanel); -_defaultPanel = 'default'; -_activePanel = 'default'; - // ── Panel API ─────────────────────────────────────────────────────────────── -window.createShortcutPanel = (id) => { - if (_panels.has(id)) { - console.warn(`createShortcutPanel: panel '${id}' already exists`); - return _panels.get(id); - } - const panel = new ShortcutPanel(id); - _panels.set(id, panel); - return panel; -}; - -window.setActiveShortcutPanel = (id) => { - if (!_panels.has(id)) { - console.error(`setActiveShortcutPanel: panel '${id}' does not exist`); - return; - } - _activePanel = id; -}; - -window.getActiveShortcutPanel = () => _activePanel; - -window.isInShortcutPanel = () => { - return _activePanel !== 'default'; -}; - -window.getGlobalShortcutContext = () => { - console.warn('getGlobalShortcutContext: Global shortcuts are exceptional. Consider using panel-scoped shortcuts instead.'); - return _panels.get('default'); -}; - // ── Shortcut registry (routes to active panel) ─────────────────────────────── -window.registerShortcut = (options) => { - const panelId = _activePanel || _defaultPanel || 'default'; - const panel = _panels.get(panelId); - - if (!panel) { - console.error(`registerShortcut: No panel found for registration: ${panelId}`); - return; - } - - panel.registerShortcut(options); -}; - -// Flat, read-only snapshot of every registered shortcut across all panels, -// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same -// shortcut can live in both the active panel and the default panel) and uses -// the same modifier-prefix formatting as the shortcuts modal. Returns -// [{ combo, description, scope }]; remapping is not supported, so this is -// purely informational. -window.getAllShortcuts = () => { - const fmt = (s) => { - const m = s.modifiers || {}; - return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '') - + (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key; - }; - const seen = new Set(); - const out = []; - for (const [, panel] of _panels) { - if (!panel || !panel.shortcuts) continue; - for (const [, s] of panel.shortcuts) { - const combo = fmt(s); - const dedupe = combo + '|' + (s.scope || ''); - if (seen.has(dedupe)) continue; - seen.add(dedupe); - out.push({ combo, description: s.description || '', scope: s.scope || 'global' }); - } - } - return out; -}; - -window.unregisterShortcut = (key, scope) => { - // Try the active panel first to preserve panel isolation; fall back to - // other panels so a shortcut registered before a panel switch is still - // removable. - const resolvedScope = scope || 'global'; - const activePanelId = _activePanel || _defaultPanel || 'default'; - const activePanel = _panels.get(activePanelId); - if (activePanel && activePanel.unregisterShortcut(key, resolvedScope)) { - return true; - } - for (const [panelId, panel] of _panels) { - if (panelId === activePanelId) continue; - if (panel.unregisterShortcut(key, resolvedScope)) { - return true; - } - } - return false; -}; - -window.clearWindowShortcuts = (windowId) => { - // Remove all shortcuts registered for a specific window - // This is for backward compatibility with window-specific shortcuts - let removed = 0; - for (const [panelId, panel] of _panels) { - if (panelId.startsWith(`window-${windowId}`)) { - panel.clearShortcuts(); - _panels.delete(panelId); - removed++; - } - } - return removed; -}; - -function _getCurrentContext() { - const currentScreen = document.querySelector('.screen.active')?.id; - return { - screen: currentScreen, - windowId: window.getShortcutWindowId(), - activePanel: _activePanel, - isPlayer: currentScreen === 'player', - isLibrary: ['home', 'favorites'].includes(currentScreen), - isSettings: currentScreen === 'settings', - isPlugin: currentScreen?.startsWith('plugin-') - }; -} - -function _isShortcutActive(shortcut, ctx) { - if (shortcut.scope === 'global') return true; - if (shortcut.scope === 'player' && ctx.isPlayer) return true; - if (shortcut.scope === 'library' && ctx.isLibrary) return true; - if (shortcut.scope === 'settings' && ctx.isSettings) return true; - if (shortcut.scope.startsWith('plugin-')) { - const pluginId = shortcut.scope.replace('plugin-', ''); - return ctx.screen === `plugin-${pluginId}`; - } - return false; -} - -function _modifiersMatch(e, modifiers) { - if (!modifiers) return true; - if (modifiers.ctrl !== undefined && modifiers.ctrl !== e.ctrlKey) return false; - if (modifiers.alt !== undefined && modifiers.alt !== e.altKey) return false; - if (modifiers.shift !== undefined && modifiers.shift !== e.shiftKey) return false; - if (modifiers.meta !== undefined && modifiers.meta !== e.metaKey) return false; - return true; -} - -// Debug mode for keyboard shortcuts -let _DEBUG_SHORTCUTS = false; - -window._setDebugShortcuts = (enabled) => { - _DEBUG_SHORTCUTS = enabled; - console.log(`[Shortcuts] Debug mode ${enabled ? 'ENABLED' : 'DISABLED'}`); -}; - -window._listShortcuts = () => { - console.log('=== Registered Shortcuts ==='); - for (const [panelId, panel] of _panels) { - console.log(`Panel: ${panelId}`); - for (const [, s] of panel.shortcuts) { - console.log(` ${s.key.padEnd(15)} | ${s.scope.padEnd(10)} | ${s.description}`); - } - } - console.log('=== End ==='); -}; - -window._testShortcut = (key, scope) => { - // Mirror the dispatcher: try the active panel first, then default. - const resolvedScope = scope || 'global'; - const tried = new Set(); - const panelOrder = [_activePanel, _defaultPanel, 'default'].filter(id => { - if (!id || tried.has(id)) return false; - tried.add(id); - return true; - }); - - for (const panelId of panelOrder) { - const panel = _panels.get(panelId); - if (!panel) continue; - const shortcut = panel.shortcuts.get(panel._compositeKey(key, resolvedScope)); - if (!shortcut) continue; - - const ctx = _getCurrentContext(); - const active = _isShortcutActive(shortcut, ctx); - let conditionMet = true; - if (shortcut.condition) { - try { conditionMet = !!shortcut.condition(); } - catch (err) { conditionMet = `threw: ${err.message}`; } - } - console.log(`Shortcut '${key}' [${resolvedScope}] [${panelId}]:`, { - description: shortcut.description, - scope: shortcut.scope, - currentContext: ctx, - isActive: active, - conditionMet - }); - return; - } - - console.log(`Shortcut '${key}' (scope: ${resolvedScope}) not registered in any panel`); -}; - -// Expose internals for debugging (prefixed with _ to indicate private) -// These are for development/debugging only and should not be used by plugins. -window._panels = _panels; -window._getCurrentContext = _getCurrentContext; -window._isShortcutActive = _isShortcutActive; - // ── Registry-based keydown handler ───────────────────────────────────────── // // This handler processes all registered shortcuts through the central registry. // It runs after the library navigation handler (which handles /, ?, c, f, e, etc.) // and before any other keydown listeners. -document.addEventListener('keydown', e => { - if (_shortcutDispatchBlocked(e)) return; - - const ctx = _getCurrentContext(); - const activePanel = _panels.get(_activePanel); - const defaultPanel = _panels.get('default'); - - if (!activePanel && !defaultPanel) return; - - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Key pressed:', { key: e.key, code: e.code, ctx, activePanel: _activePanel }); - } - - // Try active panel first, then fall back to default - const panelsToDispatch = []; - if (activePanel && activePanel !== defaultPanel) panelsToDispatch.push(activePanel); - if (defaultPanel) panelsToDispatch.push(defaultPanel); - - for (const panel of panelsToDispatch) { - for (const [, shortcut] of panel.shortcuts) { - // Match on both e.key (character produced) and e.code (physical key) - if (e.key !== shortcut.key && e.code !== shortcut.key) continue; - - // Check modifier keys if specified - if (!_modifiersMatch(e, shortcut.modifiers)) continue; - - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Matched shortcut:', shortcut.key, shortcut); - } - - // Check scope - if (!_isShortcutActive(shortcut, ctx)) { - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Not active - scope mismatch:', shortcut.scope, ctx); - } - continue; - } - - // Check condition callback — guard against plugin errors - if (shortcut.condition) { - try { - if (!shortcut.condition()) { - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Not active - condition failed'); - } - continue; - } - } catch (err) { - console.error('[Shortcuts] condition() threw for key:', shortcut.key, err); - continue; - } - } - - e.preventDefault(); - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Executing handler for:', shortcut.key); - } - // Guard handler against plugin errors - try { - shortcut.handler(e); - } catch (err) { - console.error('[Shortcuts] handler() threw for key:', shortcut.key, err); - } - return; - } -} - - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] No shortcut matched for:', e.key, e.code); - } -}); - // ── Window cleanup ─────────────────────────────────────────────────────────── // Clean up window-specific shortcuts when a window is closed. // This is important for popup windows (e.g., splitscreen plugin) that // may be closed by the user. -window.addEventListener('beforeunload', () => { - const windowId = window.getShortcutWindowId(); - const removed = window.clearWindowShortcuts(windowId); - if (removed > 0 && _DEBUG_SHORTCUTS) { - console.log(`[Shortcuts] Cleaned up ${removed} shortcuts for window ${windowId}`); - } -}); - // ── Register built-in shortcuts ─────────────────────────────────────────── -// Global shortcuts -registerShortcut({ - key: '?', - description: 'Show keyboard shortcuts', - scope: 'global', - handler: () => _openShortcutsModal() -}); - -// Library shortcuts -registerShortcut({ - key: '/', - description: 'Focus search', - scope: 'library', - handler: () => { - const input = _activeSearchInput(); - if (input) input.focus(); - } -}); - registerShortcut({ key: 'f', description: 'Toggle favorite', @@ -3190,6 +2268,11 @@ document.addEventListener('click', e => { // tests/js/host_contract.test.js fails CI if this list and the host.* uses under // static/js/ ever drift apart. configureHost({ + // shortcuts.js's library arrow-nav needs syncLibrarySong (Enter on a selected row). It + // cannot import it: syncLibrarySong reaches showScreen/playSong, and a module importing + // app.js closes a cycle. So it comes across the seam, and host.js throws loudly if this line + // is ever dropped. + syncLibrarySong, handleSliderInput, playSong, // count-in is a module now, so section-practice reaches it through the seam too — diff --git a/static/js/session.js b/static/js/session.js index 264930c..5d6daa2 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -105,8 +105,61 @@ export let _settingsOriginScreen = 'home'; // ── Screen Navigation ───────────────────────────────────────────────────── export async function showScreen(id) { + // ── 'home' is the LEGACY library screen. Always route it to the v3 Songs list. ── + // + // The v3 shell replaced #home with #v3-songs. That mapping DID exist — but only inside + // wrappers on `window.showScreen`, and only for callers that go through `window`: + // + // app.js publishes the raw fn -> shell.js wraps it (adding the mapping) + // -> the stems plugin wraps it AGAIN, capturing whatever + // happened to be there at the time + // + // Two ways that fails, and testers hit both: + // + // 1. ORDER. Three independent parties monkey-patch window.showScreen, each capturing the + // current value. Plugins load ASYNCHRONOUSLY, so the chain links up in whatever order + // the race settles — and any capture taken before shell.js installs, or any + // re-assignment after it, silently drops the mapping. + // + // 2. THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the + // Esc-from-settings shortcut call the IMPORTED showScreen directly, so no wrapper ever + // sees them. Verified in a browser: the unwrapped function with 'home' lands on the dead + // legacy screen every single time. + // + // Hence "randomly, when moving to the library from another menu option" — and "never when a + // song ends", because closeCurrentSong resolves its target through _resolvePlayerOrigin(), + // which already applies this mapping. + // + // So it lives HERE now: ONE guard in the function every caller routes through, rather than a + // chain of monkey-patches that must each remember. + // + // ONLY 'home'. NOT 'v3-home'. _resolvePlayerOrigin() maps BOTH — correctly, because it + // computes where to RETURN TO after a song, and coming back to the Songs list from the + // dashboard is the right behaviour. Copying that condition here was a [P1] (Codex caught it): + // #v3-home is the v3 DASHBOARD, a real screen the shell's Home nav, the onboarding tour and + // the dashboard re-render listener all target. Redirecting it would make Home unreachable. + // + // A legacy alias is not the same thing as a return target. + if (id === 'home' && document.getElementById('v3-songs')) { + id = 'v3-songs'; + } + // Capture the previous screen before changing active classes const prevScreenId = document.querySelector('.screen.active')?.id; + + // ── screen:changing — emitted BEFORE any of the work below ────────────────── + // + // Timing matters here, and Codex caught me getting it wrong. The stems plugin used to + // monkey-patch window.showScreen so it could tear down its audio graph BEFORE navigation + // began. screen:changed fires at the very END of this function — after awaiting library and + // provider loads — so moving that plugin onto it would have delayed teardown behind a slow + // fetch, or skipped it entirely if the fetch threw. Stems would keep playing on a non-player + // screen. + // + // So there are two events, and the distinction is the whole point: + // screen:changing — before anything happens. "I am leaving `from`." Cancel/teardown here. + // screen:changed — after the DOM and data are settled. "I am on `id`." + if (window.feedBack) window.feedBack.emit('screen:changing', { id, from: prevScreenId || null }); 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 @@ -186,7 +239,15 @@ export async function showScreen(id) { setPlayButtonState(false); } window.scrollTo(0, 0); - if (window.feedBack) window.feedBack.emit('screen:changed', { id }); + // `from` is the screen we just LEFT. Without it, "I am leaving the player" is not + // expressible from an event, and the only way to express it was to WRAP window.showScreen — + // which is what shell.js and the stems plugin both did, and why the library intermittently + // showed the legacy screen (#923, #924): three parties patching one global, each capturing + // whatever was there at the time, in whatever order the plugin loads settled. + // + // Additive: every existing listener (app.js, audio-mixer.js, tour-engine.js) reads `id` and + // is unaffected. + if (window.feedBack) window.feedBack.emit('screen:changed', { id, from: prevScreenId || null }); } export let currentFilename = ''; diff --git a/static/js/shortcuts.js b/static/js/shortcuts.js new file mode 100644 index 0000000..1c50c05 --- /dev/null +++ b/static/js/shortcuts.js @@ -0,0 +1,983 @@ +// KEYBOARD SHORTCUTS: the panel registry, the global dispatchers, and the plugin-facing API. +// +// ━━━ MOST OF THIS SUBSYSTEM IS TOP-LEVEL STATEMENTS, NOT DECLARATIONS ━━━ +// +// 10 declarations — and 18 top-level statements. window.registerShortcut, +// window.createShortcutPanel, getAllShortcuts, unregisterShortcut, clearWindowShortcuts, the +// panel registry, and BOTH global keydown dispatchers are all bare statements at app.js's top +// level. A dependency scan that walks declarations sees NONE of them, and would have reported +// this cluster as 246 lines. It is more than double that. +// +// That blind spot has now cost twice: it nearly shipped a dead library A-Z rail (#896), and it +// threw "Assignment to constant variable" in the session carve (#921), where the autoplay gate's +// top-level statements wrote state that had become a read-only import. The extractor takes them +// by construction now — any top-level statement that TOUCHES a moved binding comes along. +// +// window.registerShortcut and friends are a PLUGIN-FACING API. They keep working because app.js +// still publishes them; the definitions simply live here, next to the dispatcher they feed. + +import { + _lastLibSelected, + _libNavItems, + _moveSelectionInItems, + _providerSupports, + _setLibSelection, + _toggleHeader, +} from './library.js'; +import { + _sectionPracticeBarContains, + _sectionPracticePopoverOpen, +} from './section-practice.js'; +import { + _trapFocusInModal, + esc, +} from './dom.js'; +import { + playSong, +} from './session.js'; +import { host } from './host.js'; +// ── 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. +export 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; +} + +export function _isShortcutHelpKey(e) { + return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/')); +} + +export 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; +} + +export 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; +} + +export 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); +} + +export 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; +} + +export function _isSpaceKey(e) { + return e.key === ' ' || e.key === 'Spacebar'; +} + +export 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 + + ${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: + // 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, or when the button is disabled. + // Bails on text input / drawer focus so single-letter typing in + // inputs still works. + const entryShortcut = { 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"].feedBack-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(); + } + } +}); + +export class ShortcutPanel { + constructor(id) { + this.id = id; + this.shortcuts = new Map(); + } + + _compositeKey(key, scope) { + return `${scope}::${key}`; + } + + registerShortcut(options) { + const { key, description, scope = 'global', condition = null, handler, modifiers = null } = options; + + if (!key || !handler) { + console.error(`registerShortcut: key and handler are required`); + return; + } + + // Validate scope + const validScopes = ['global', 'player', 'library', 'settings']; + const isValidScope = validScopes.includes(scope) || + scope.startsWith('plugin-'); + if (!isValidScope) { + console.warn(`registerShortcut: invalid scope '${scope}'. Valid scopes are: global, player, library, settings, or plugin-{id}`); + } + + // Conflict detection: warn if key+scope is already registered + const compositeKey = this._compositeKey(key, scope); + if (this.shortcuts.has(compositeKey)) { + console.warn(`registerShortcut [${this.id}]: '${key}' in scope '${scope}' is already registered; overwriting. Previous:`, this.shortcuts.get(compositeKey)); + } + + this.shortcuts.set(compositeKey, { key, description, scope, condition, handler, modifiers }); + } + + unregisterShortcut(key, scope) { + return this.shortcuts.delete(this._compositeKey(key, scope)); + } + + clearShortcuts() { + this.shortcuts.clear(); + } + + listShortcuts() { + return Array.from(this.shortcuts.entries()).map(([ck, s]) => [s.key, s]); + } +} + +// Global panel management +export const _panels = new Map(); + +export let _activePanel = null; + +export let _defaultPanel = null; + +// Create default panel on init +export const defaultPanel = new ShortcutPanel('default'); + +_panels.set('default', defaultPanel); + +_defaultPanel = 'default'; + +_activePanel = 'default'; + +window.createShortcutPanel = (id) => { + if (_panels.has(id)) { + console.warn(`createShortcutPanel: panel '${id}' already exists`); + return _panels.get(id); + } + const panel = new ShortcutPanel(id); + _panels.set(id, panel); + return panel; +}; + +window.setActiveShortcutPanel = (id) => { + if (!_panels.has(id)) { + console.error(`setActiveShortcutPanel: panel '${id}' does not exist`); + return; + } + _activePanel = id; +}; + +window.getActiveShortcutPanel = () => _activePanel; + +window.isInShortcutPanel = () => { + return _activePanel !== 'default'; +}; + +window.getGlobalShortcutContext = () => { + console.warn('getGlobalShortcutContext: Global shortcuts are exceptional. Consider using panel-scoped shortcuts instead.'); + return _panels.get('default'); +}; + +window.registerShortcut = (options) => { + const panelId = _activePanel || _defaultPanel || 'default'; + const panel = _panels.get(panelId); + + if (!panel) { + console.error(`registerShortcut: No panel found for registration: ${panelId}`); + return; + } + + panel.registerShortcut(options); +}; + +// Flat, read-only snapshot of every registered shortcut across all panels, +// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same +// shortcut can live in both the active panel and the default panel) and uses +// the same modifier-prefix formatting as the shortcuts modal. Returns +// [{ combo, description, scope }]; remapping is not supported, so this is +// purely informational. +window.getAllShortcuts = () => { + const fmt = (s) => { + const m = s.modifiers || {}; + return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '') + + (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key; + }; + const seen = new Set(); + const out = []; + for (const [, panel] of _panels) { + if (!panel || !panel.shortcuts) continue; + for (const [, s] of panel.shortcuts) { + const combo = fmt(s); + const dedupe = combo + '|' + (s.scope || ''); + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push({ combo, description: s.description || '', scope: s.scope || 'global' }); + } + } + return out; +}; + +window.unregisterShortcut = (key, scope) => { + // Try the active panel first to preserve panel isolation; fall back to + // other panels so a shortcut registered before a panel switch is still + // removable. + const resolvedScope = scope || 'global'; + const activePanelId = _activePanel || _defaultPanel || 'default'; + const activePanel = _panels.get(activePanelId); + if (activePanel && activePanel.unregisterShortcut(key, resolvedScope)) { + return true; + } + for (const [panelId, panel] of _panels) { + if (panelId === activePanelId) continue; + if (panel.unregisterShortcut(key, resolvedScope)) { + return true; + } + } + return false; +}; + +window.clearWindowShortcuts = (windowId) => { + // Remove all shortcuts registered for a specific window + // This is for backward compatibility with window-specific shortcuts + let removed = 0; + for (const [panelId, panel] of _panels) { + if (panelId.startsWith(`window-${windowId}`)) { + panel.clearShortcuts(); + _panels.delete(panelId); + removed++; + } + } + return removed; +}; + +export function _getCurrentContext() { + const currentScreen = document.querySelector('.screen.active')?.id; + return { + screen: currentScreen, + windowId: window.getShortcutWindowId(), + activePanel: _activePanel, + isPlayer: currentScreen === 'player', + isLibrary: ['home', 'favorites'].includes(currentScreen), + isSettings: currentScreen === 'settings', + isPlugin: currentScreen?.startsWith('plugin-') + }; +} + +export function _isShortcutActive(shortcut, ctx) { + if (shortcut.scope === 'global') return true; + if (shortcut.scope === 'player' && ctx.isPlayer) return true; + if (shortcut.scope === 'library' && ctx.isLibrary) return true; + if (shortcut.scope === 'settings' && ctx.isSettings) return true; + if (shortcut.scope.startsWith('plugin-')) { + const pluginId = shortcut.scope.replace('plugin-', ''); + return ctx.screen === `plugin-${pluginId}`; + } + return false; +} + +export function _modifiersMatch(e, modifiers) { + if (!modifiers) return true; + if (modifiers.ctrl !== undefined && modifiers.ctrl !== e.ctrlKey) return false; + if (modifiers.alt !== undefined && modifiers.alt !== e.altKey) return false; + if (modifiers.shift !== undefined && modifiers.shift !== e.shiftKey) return false; + if (modifiers.meta !== undefined && modifiers.meta !== e.metaKey) return false; + return true; +} + +// Debug mode for keyboard shortcuts +export let _DEBUG_SHORTCUTS = false; + +window._setDebugShortcuts = (enabled) => { + _DEBUG_SHORTCUTS = enabled; + console.log(`[Shortcuts] Debug mode ${enabled ? 'ENABLED' : 'DISABLED'}`); +}; + +window._listShortcuts = () => { + console.log('=== Registered Shortcuts ==='); + for (const [panelId, panel] of _panels) { + console.log(`Panel: ${panelId}`); + for (const [, s] of panel.shortcuts) { + console.log(` ${s.key.padEnd(15)} | ${s.scope.padEnd(10)} | ${s.description}`); + } + } + console.log('=== End ==='); +}; + +window._testShortcut = (key, scope) => { + // Mirror the dispatcher: try the active panel first, then default. + const resolvedScope = scope || 'global'; + const tried = new Set(); + const panelOrder = [_activePanel, _defaultPanel, 'default'].filter(id => { + if (!id || tried.has(id)) return false; + tried.add(id); + return true; + }); + + for (const panelId of panelOrder) { + const panel = _panels.get(panelId); + if (!panel) continue; + const shortcut = panel.shortcuts.get(panel._compositeKey(key, resolvedScope)); + if (!shortcut) continue; + + const ctx = _getCurrentContext(); + const active = _isShortcutActive(shortcut, ctx); + let conditionMet = true; + if (shortcut.condition) { + try { conditionMet = !!shortcut.condition(); } + catch (err) { conditionMet = `threw: ${err.message}`; } + } + console.log(`Shortcut '${key}' [${resolvedScope}] [${panelId}]:`, { + description: shortcut.description, + scope: shortcut.scope, + currentContext: ctx, + isActive: active, + conditionMet + }); + return; + } + + console.log(`Shortcut '${key}' (scope: ${resolvedScope}) not registered in any panel`); +}; + +// Expose internals for debugging (prefixed with _ to indicate private) +// These are for development/debugging only and should not be used by plugins. +window._panels = _panels; + +window._getCurrentContext = _getCurrentContext; + +window._isShortcutActive = _isShortcutActive; + +document.addEventListener('keydown', e => { + if (_shortcutDispatchBlocked(e)) return; + + const ctx = _getCurrentContext(); + const activePanel = _panels.get(_activePanel); + const defaultPanel = _panels.get('default'); + + if (!activePanel && !defaultPanel) return; + + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Key pressed:', { key: e.key, code: e.code, ctx, activePanel: _activePanel }); + } + + // Try active panel first, then fall back to default + const panelsToDispatch = []; + if (activePanel && activePanel !== defaultPanel) panelsToDispatch.push(activePanel); + if (defaultPanel) panelsToDispatch.push(defaultPanel); + + for (const panel of panelsToDispatch) { + for (const [, shortcut] of panel.shortcuts) { + // Match on both e.key (character produced) and e.code (physical key) + if (e.key !== shortcut.key && e.code !== shortcut.key) continue; + + // Check modifier keys if specified + if (!_modifiersMatch(e, shortcut.modifiers)) continue; + + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Matched shortcut:', shortcut.key, shortcut); + } + + // Check scope + if (!_isShortcutActive(shortcut, ctx)) { + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Not active - scope mismatch:', shortcut.scope, ctx); + } + continue; + } + + // Check condition callback — guard against plugin errors + if (shortcut.condition) { + try { + if (!shortcut.condition()) { + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Not active - condition failed'); + } + continue; + } + } catch (err) { + console.error('[Shortcuts] condition() threw for key:', shortcut.key, err); + continue; + } + } + + e.preventDefault(); + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Executing handler for:', shortcut.key); + } + // Guard handler against plugin errors + try { + shortcut.handler(e); + } catch (err) { + console.error('[Shortcuts] handler() threw for key:', shortcut.key, err); + } + return; + } +} + + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] No shortcut matched for:', e.key, e.code); + } +}); + +window.addEventListener('beforeunload', () => { + const windowId = window.getShortcutWindowId(); + const removed = window.clearWindowShortcuts(windowId); + if (removed > 0 && _DEBUG_SHORTCUTS) { + console.log(`[Shortcuts] Cleaned up ${removed} shortcuts for window ${windowId}`); + } +}); + +// Global shortcuts +registerShortcut({ + key: '?', + description: 'Show keyboard shortcuts', + scope: 'global', + handler: () => _openShortcutsModal() +}); + +// Library shortcuts +registerShortcut({ + key: '/', + description: 'Focus search', + scope: 'library', + handler: () => { + const input = _activeSearchInput(); + if (input) input.focus(); + } +}); diff --git a/static/v3/shell.js b/static/v3/shell.js index 743182f..025dadc 100644 --- a/static/v3/shell.js +++ b/static/v3/shell.js @@ -320,22 +320,49 @@ } } - // ── showScreen wrapper (idempotent rehydration — design/05 §Rehydration) ─ + // ── Stay in sync with the active screen (idempotent rehydration — design/05) ─ + // + // This USED to monkey-patch window.showScreen. It doesn't any more, and that is the point. + // + // Three parties were wrapping that one global — app.js publishes it, this wrapped it, and the + // stems plugin wrapped it again — each capturing whatever happened to be there at the time. + // Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled, and + // a capture taken before this installed silently dropped the home -> v3-songs mapping this + // wrapper carried. That is why the library intermittently showed the legacy screen (#923). + // + // The mapping lives inside showScreen() now, where no wrapper can lose it. And everything + // left here is just "the screen changed" — which showScreen already EMITS, and which app.js, + // audio-mixer.js and tour-engine.js have always listened for rather than patching. + // + // So: be a listener, like everyone else. window.showScreen is a plain function again. function installShowScreenHook() { const hooks = window.__feedBackV3ShellHooks || (window.__feedBackV3ShellHooks = {}); - hooks.syncActive = syncActive; // always point at the latest impl + hooks.syncActive = syncActive; // always point at the latest impl if (hooks.installed) return; hooks.installed = true; - hooks.baseShowScreen = window.showScreen; - window.showScreen = function (id) { - // Route every "go to the library" navigation to the v3 native Songs - // screen instead of the legacy #home library, so player-close, - // settings-back, the hidden legacy navbar, etc. all stay in v3. - const target = (id === 'home') ? 'v3-songs' : id; - const r = hooks.baseShowScreen ? hooks.baseShowScreen.call(this, target) : undefined; - try { hooks.syncActive && hooks.syncActive(target); } catch (e) { /* non-fatal */ } - return r; + + // RETRY IF THE BUS IS LATE. The old wrapper didn't need window.feedBack to exist; a + // listener does. Bailing out when it isn't ready yet would silently leave the sidebar + // highlight and topbar title frozen forever — a dead nav, with nothing thrown. (Codex + // caught the identical hole in the stems plugin's version of this.) + const wire = () => { + const bus = window.feedBack; + if (!bus || typeof bus.on !== 'function') { + // `feedBack:capabilities:ready` — capabilities.js:1536. NOT the slopsmith: name: + // that was the pre-DMCA event and NOTHING dispatches it any more, so a fallback + // keyed on it can never fire. Codex caught exactly that here. (The old alias is + // kept too, in case an older capabilities build is in play.) + window.addEventListener('feedBack:capabilities:ready', wire, { once: true }); + window.addEventListener('slopsmith:capabilities:ready', wire, { once: true }); + return; + } + bus.on('screen:changed', (ev) => { + const id = ev && ev.detail && ev.detail.id; + if (!id) return; + try { hooks.syncActive && hooks.syncActive(id); } catch (e) { /* non-fatal */ } + }); }; + wire(); } // ── Boot ──────────────────────────────────────────────────────────────── diff --git a/tests/js/career_plugin.test.js b/tests/js/career_plugin.test.js index 98da433..30a1dc0 100644 --- a/tests/js/career_plugin.test.js +++ b/tests/js/career_plugin.test.js @@ -33,6 +33,24 @@ test('venues.json defines the 3 ascending tiers with star thresholds', () => { } }); +test('bar venue pack ships with intro media in the plugin checkout', () => { + const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'bar'); + const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8')); + assert.deepEqual(Object.keys(manifest.loops).sort(), + ['bored', 'ecstatic', 'engaged', 'neutral']); + assert.equal(manifest.intro.video, 'intro.mp4'); + assert.equal(manifest.intro.audio, 'bar-ambience.mp3'); + for (const f of [ + ...Object.values(manifest.loops), + ...Object.values(manifest.stingers), + manifest.intro.video, + manifest.intro.audio, + ]) { + const stat = fs.statSync(path.join(packDir, f)); + assert.ok(stat.size > 0, `${f} must be present`); + } +}); + test('shell promotes the career plugin into the sidebar', () => { const src = fs.readFileSync(SHELL_JS, 'utf8'); assert.match(src, /key: 'career',\s*screen: 'plugin-career'/); diff --git a/tests/js/no_showscreen_monkeypatch.test.js b/tests/js/no_showscreen_monkeypatch.test.js new file mode 100644 index 0000000..97e81b9 --- /dev/null +++ b/tests/js/no_showscreen_monkeypatch.test.js @@ -0,0 +1,95 @@ +// Nobody may monkey-patch window.showScreen. (#924) +// +// It used to be wrapped by THREE independent parties, each capturing whatever happened to be +// there at the time: +// +// app.js publishes the raw function +// -> static/v3/shell.js wrapped it (to call syncActive, and to map home -> v3-songs) +// -> the stems plugin wrapped it AGAIN (to tear down on leaving the player) +// +// Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled. A +// capture taken before shell.js installed silently dropped the mapping it carried — and the +// library opened on the dead legacy #home screen. Testers saw that as "randomly, the library +// shows the old interface" (#923). +// +// Neither wrapper ever needed to be one. showScreen already EMITS screen:changed, and that is +// already how app.js, audio-mixer.js and tour-engine.js do it. Both are listeners now, and +// window.showScreen is a plain function again — so the ordering hazard is structurally +// impossible rather than merely avoided. +// +// This test is the thing that keeps it that way. A wrapper reintroduced anywhere in static/ +// fails CI. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..', '..'); + +function jsFiles(dir) { + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...jsFiles(p)); + else if (e.name.endsWith('.js')) out.push(p); + } + return out; +} + +// strip comments so the prose above (and in shell.js) isn't read as an assignment +const scrub = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/[^\n]*$/gm, ''); + +test('nothing in static/ assigns window.showScreen', () => { + const offenders = []; + for (const f of jsFiles(path.join(ROOT, 'static'))) { + const src = scrub(fs.readFileSync(f, 'utf8')); + // `window.showScreen = ...` — an assignment, not a call or a typeof guard + if (/window\.showScreen\s*=(?!=)/.test(src)) offenders.push(path.relative(ROOT, f)); + } + assert.deepEqual( + offenders, [], + 'these files monkey-patch window.showScreen. Do not: three wrappers racing over one ' + + 'global is what made the library open on the legacy screen (#923). Listen to ' + + 'screen:changed instead — showScreen already emits it, with { id, from }.', + ); +}); + +test('showScreen emits screen:changed with the screen it LEFT', () => { + const src = fs.readFileSync(path.join(ROOT, 'static', 'js', 'session.js'), 'utf8'); + assert.match( + src, + /emit\('screen:changed',\s*\{\s*id,\s*from:/, + "screen:changed must carry `from` — without it, \"I am leaving the player\" is not " + + 'expressible from an event, and the only way to say it is to wrap showScreen, which is ' + + 'the bug this exists to prevent', + ); +}); + +test('screen:changing fires BEFORE the navigation work, screen:changed after', () => { + // The distinction is the whole point, and Codex caught me collapsing it. + // + // The stems plugin's wrapper tore down its audio graph BEFORE showScreen did anything. + // screen:changed fires at the very END — after core awaits library and provider loads — so + // moving the plugin onto it would delay teardown behind a slow fetch, or skip it if that + // fetch threw, and stems would keep playing on a non-player screen. + // + // screen:changing before anything happens. "I am leaving `from`." Cancel/teardown here. + // screen:changed after the DOM and data settle. "I am on `id`." + const src = fs.readFileSync(path.join(ROOT, 'static', 'js', 'session.js'), 'utf8'); + const changing = src.indexOf("emit('screen:changing'"); + const changed = src.indexOf("emit('screen:changed'"); + assert.ok(changing !== -1, 'screen:changing must be emitted'); + assert.ok(changed !== -1, 'screen:changed must be emitted'); + assert.ok(changing < changed, 'screen:changing must come first'); + + // and `changing` must precede the first await, or it is no earlier than `changed` in practice + const firstAwait = src.indexOf('await ', changing); + assert.ok(firstAwait === -1 || changing < firstAwait, + 'screen:changing must fire before showScreen awaits anything — that is its entire purpose'); +}); + +test('the v3 shell reacts to screen:changed rather than wrapping showScreen', () => { + const src = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'shell.js'), 'utf8'); + assert.match(scrub(src), /on\('screen:changed'/, 'shell.js must listen, not patch'); +}); diff --git a/tests/js/show_screen_legacy_home.test.js b/tests/js/show_screen_legacy_home.test.js new file mode 100644 index 0000000..7f0a1cf --- /dev/null +++ b/tests/js/show_screen_legacy_home.test.js @@ -0,0 +1,85 @@ +// showScreen('home') must never land on the LEGACY library screen when v3 is present. +// +// Testers: "randomly, when moving to the library from another menu option, the library shows the +// old interface — never when a song ends." +// +// #home is the pre-v3 library screen. The v3 shell replaced it with #v3-songs, and the mapping +// DID exist — but only inside wrappers on `window.showScreen`, which fail two ways: +// +// 1. ORDER. THREE independent parties monkey-patch window.showScreen, each capturing whatever +// is there at the time: app.js publishes the raw function, shell.js wraps it to add the +// mapping, and the stems plugin wraps it again. Plugins load ASYNCHRONOUSLY, so the chain +// links up in whatever order the race settles. A capture taken before shell.js installs — +// or any re-assignment after it — silently drops the mapping. Hence "randomly". +// +// 2. THE INTERNAL CALLERS BYPASS window.showScreen ENTIRELY. closeCurrentSong and the +// Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees. +// Verified in a browser: the unwrapped function with 'home' lands on #home, always. +// +// "Never when a song ends" is the tell: closeCurrentSong resolves its target through +// _resolvePlayerOrigin(), which already applied the mapping — so that one path was fine. +// +// The guard now lives inside showScreen itself: one place every caller routes through. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SESSION_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js'); +const src = () => fs.readFileSync(SESSION_JS, 'utf8'); + +function bodyOf(name) { + const s = src(); + const at = s.indexOf(`export async function ${name}(`); + assert.notEqual(at, -1, `${name} not found`); + let depth = 0; + for (let i = s.indexOf('{', at); i < s.length; i++) { + if (s[i] === '{') depth++; + else if (s[i] === '}' && --depth === 0) return s.slice(at, i + 1); + } + throw new Error('unbalanced'); +} + +test('showScreen maps the legacy #home library to #v3-songs', () => { + const fn = bodyOf('showScreen'); + assert.match( + fn, + /id\s*===\s*'home'[\s\S]{0,80}getElementById\('v3-songs'\)[\s\S]{0,60}id\s*=\s*'v3-songs'/, + "showScreen must route 'home' to 'v3-songs' ITSELF — relying on a wrapper over " + + 'window.showScreen loses the mapping whenever a plugin wraps it first, and misses the ' + + 'module-internal callers (closeCurrentSong, Esc-from-settings) altogether', + ); +}); + +test('the guard runs BEFORE the screen is activated', () => { + const fn = bodyOf('showScreen'); + const guard = fn.search(/id\s*=\s*'v3-songs'/); + const activate = fn.indexOf('classList.add(\'active\')'); + assert.ok(guard !== -1 && activate !== -1); + assert.ok(guard < activate, + 'the mapping must be applied before the screen is activated, or #home is shown first'); +}); + +test('the guard is conditional on v3 actually being present', () => { + const fn = bodyOf('showScreen'); + assert.match(fn, /getElementById\('v3-songs'\)/, + 'the mapping must check #v3-songs exists — without it there is nowhere to route to'); +}); + +test('it does NOT redirect v3-home — the dashboard is a real screen', () => { + // Codex [P1] on the first cut. _resolvePlayerOrigin() maps BOTH 'home' and 'v3-home' — + // correctly, because it computes where to RETURN TO after a song, and landing on the Songs + // list from the dashboard is right. Copying that condition into showScreen is NOT: #v3-home + // is the v3 DASHBOARD, which the shell's Home nav, the onboarding tour and the dashboard + // re-render listener all target. Redirecting it makes Home unreachable. + // + // A legacy alias is not the same thing as a return target. + const fn = bodyOf('showScreen'); + // the condition, i.e. everything between `if (` and the `{` that opens `id = 'v3-songs'` + const m = fn.match(/if \(([\s\S]*?)\)\s*\{\s*id = 'v3-songs';/); + assert.ok(m, 'the legacy-home guard was not found'); + assert.doesNotMatch(m[1], /v3-home/, + "showScreen must NOT redirect 'v3-home' — that is the dashboard, not the legacy library"); + assert.match(m[1], /id === 'home'/, "it must still redirect the legacy 'home'"); +}); diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py index 581d553..e104740 100644 --- a/tests/plugins/career/test_routes.py +++ b/tests/plugins/career/test_routes.py @@ -76,7 +76,8 @@ def test_download_unknown_venue_404s(client): def test_download_without_published_pack_404s(client): - # venues.json ships pack: null until packs are released. + # Bundled packs are already installed; download still requires a published + # remote pack entry. assert client.post("/api/plugins/career/packs/bar/download").status_code == 404 @@ -86,28 +87,46 @@ def test_download_locked_venue_403s(client, monkeypatch): assert client.post("/api/plugins/career/packs/club/download").status_code == 403 -def test_pack_file_serving_and_traversal_guard(client): - _install_fake_pack("bar") +def test_bundled_bar_pack_is_installed_and_served(client): + state = client.get("/api/plugins/career/state").json() + bar = {v["id"]: v for v in state["venues"]}["bar"] + assert bar["installed"] is True + assert bar["bundled"] is True + assert bar["has_pack"] is True + ok = client.get("/api/plugins/career/venues/bar/manifest.json") assert ok.status_code == 200 + manifest = ok.json() + assert manifest["loops"]["ecstatic"] == "ecstatic.mp4" + assert manifest["intro"] == {"video": "intro.mp4", "audio": "bar-ambience.mp3"} + assert client.get("/api/plugins/career/venues/bar/intro.mp4").status_code == 200 + audio = client.get("/api/plugins/career/venues/bar/bar-ambience.mp3") + assert audio.status_code == 200 + assert audio.headers["content-type"].startswith("audio/mpeg") + + +def test_pack_file_serving_and_traversal_guard(client): + _install_fake_pack("club") + ok = client.get("/api/plugins/career/venues/club/manifest.json") + assert ok.status_code == 200 assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4" - video = client.get("/api/plugins/career/venues/bar/bored.mp4") + video = client.get("/api/plugins/career/venues/club/bored.mp4") assert video.status_code == 200 assert video.headers["content-type"].startswith("video/mp4") assert video.headers["x-content-type-options"] == "nosniff" # Traversal / junk shapes never resolve. for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"): - assert client.get(f"/api/plugins/career/venues/bar/{bad}").status_code == 404 - assert client.get("/api/plugins/career/venues/../bar/manifest.json").status_code == 404 + assert client.get(f"/api/plugins/career/venues/club/{bad}").status_code == 404 + assert client.get("/api/plugins/career/venues/../club/manifest.json").status_code == 404 def test_state_reports_installed_and_delete_removes(client): - _install_fake_pack("bar") + _install_fake_pack("club") state = client.get("/api/plugins/career/state").json() - assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True - assert client.delete("/api/plugins/career/packs/bar").status_code == 200 + assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True + assert client.delete("/api/plugins/career/packs/club").status_code == 200 state = client.get("/api/plugins/career/state").json() - assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is False + assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is False def test_download_worker_end_to_end(client, tmp_path):