mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 14:34:29 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12b459fcc | ||
|
|
36d984fffd | ||
|
|
f8fc5e6a5f | ||
|
|
803193046e | ||
|
|
702a9c6daa | ||
|
|
99b6d3c384 | ||
|
|
0cc08ebebf | ||
|
|
a2a48b3912 | ||
|
|
05a9bee38f | ||
|
|
f5d448af5c | ||
|
|
8f014e6a30 | ||
|
|
6b8f79dd9a | ||
|
|
70dbe45e27 | ||
|
|
1cef01d02c | ||
|
|
756588678b | ||
|
|
bd830328f0 | ||
|
|
09f7e450a5 | ||
|
|
8bec8d2466 | ||
|
|
8d0e270345 | ||
|
|
dc429ecd16 | ||
|
|
11f8c36b61 | ||
|
|
5fb28d5c5a | ||
|
|
cb236e6c04 | ||
|
|
64f04565e2 | ||
|
|
f53d566dbc | ||
|
|
b5dd585d25 | ||
|
|
d47883c5e5 | ||
|
|
ebbfc8da6f | ||
|
|
14b4058bc6 | ||
|
|
bfb31a8b89 | ||
|
|
a222b45c02 | ||
|
|
5b904706d0 | ||
|
|
38772f604a | ||
|
|
92c86f5393 | ||
|
|
c223ace419 | ||
|
|
ff7e855e35 | ||
|
|
4b4c156fce | ||
|
|
9d0bf95716 | ||
|
|
5e30138c87 |
@@ -24,6 +24,9 @@ plugins/*/
|
||||
!plugins/achievements/
|
||||
!plugins/achievements/**
|
||||
plugins/achievements/__pycache__/
|
||||
!plugins/career/
|
||||
!plugins/career/**
|
||||
plugins/career/__pycache__/
|
||||
!plugins/highway_3d/
|
||||
!plugins/highway_3d/**
|
||||
plugins/highway_3d/__pycache__/
|
||||
|
||||
@@ -34,7 +34,7 @@ but not the primary supported path.
|
||||
|
||||
### II. Vanilla Frontend — No Frameworks
|
||||
|
||||
The frontend (`static/app.js`, `static/highway.js`, `static/index.html`,
|
||||
The frontend (`static/app.js`, `static/highway.js`, `static/v3/index.html`,
|
||||
`static/style.css`) is plain JavaScript with the `fetch` API, direct DOM
|
||||
manipulation, and the Canvas 2D / WebGL2 APIs. The only style framework
|
||||
is Tailwind CSS, served as a prebuilt static stylesheet
|
||||
@@ -284,4 +284,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
**Version**: 1.3.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-11
|
||||
|
||||
+20
-1
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
|
||||
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
|
||||
`static/v3/index.html`, which has been the default since 0.3.0. This is the first step of
|
||||
the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so
|
||||
every subsequent step of that migration would otherwise have to be made, and verified,
|
||||
twice. Removing the fallback now halves that surface before any of it is touched.
|
||||
Incidentally fixes a latent bug in the old `index()` route — its guard read
|
||||
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
|
||||
whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served
|
||||
the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the
|
||||
deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0:
|
||||
Principle II's frontend file list now names `static/v3/index.html`.
|
||||
**Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there
|
||||
is no longer a classic shell to fall back to — unset the variable and use `/`. The env var
|
||||
itself is no longer read; the `SLOPSMITH_*`→`FEEDBACK_*` compat shim is unaffected. No
|
||||
chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same
|
||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||
|
||||
### Fixed
|
||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||
@@ -27,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
|
||||
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
|
||||
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||
|
||||
@@ -125,13 +125,13 @@ Notes:
|
||||
|
||||
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
|
||||
|
||||
v0.3.0 ships a redesigned UI behind a flag (`FEEDBACK_UI=v3` or the `/v3` route);
|
||||
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
|
||||
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
v0.3.0's redesigned UI is **the only UI** — the classic v2 shell and its
|
||||
`FEEDBACK_UI` / `/v2` opt-outs are gone, so there is no second shell to support.
|
||||
v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
`showScreen`, capabilities, library providers, the `window.feedBackViz_<id>` /
|
||||
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
|
||||
visualization renderers, diagnostics, and settings export work unchanged** — v3
|
||||
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
|
||||
surfaces `nav` in its sidebar and mounts screens as before.
|
||||
|
||||
**The only thing that changed is the player chrome.** If your plugin injects a
|
||||
control into it, you must adapt:
|
||||
@@ -157,7 +157,7 @@ control into it, you must adapt:
|
||||
popovers 40).
|
||||
|
||||
Full guide + the canonical snippet: **[docs/plugin-v3-ui.md](docs/plugin-v3-ui.md)**.
|
||||
Verify any player-injecting plugin in **both** `/` (v2) and `/v3`.
|
||||
Verify any player-injecting plugin at `/` — it and `/v3` serve the same v3 shell.
|
||||
|
||||
### Performance — never run DOM queries on a per-frame path
|
||||
|
||||
@@ -566,7 +566,7 @@ a local pointer + code map.
|
||||
- **Storage** — `localStorage` for all user preferences
|
||||
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
||||
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable.
|
||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Plugin styling — the `styles` capability
|
||||
|
||||
> Building for the redesigned **v3 UI** (`FEEDBACK_UI=v3` / `/v3`)? v3 uses `fb-*`
|
||||
> design tokens and a restructured player chrome with a dedicated plugin-control
|
||||
> slot. See **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract
|
||||
> plugins must follow in v3.
|
||||
> The **v3 UI** is the only UI — it uses `fb-*` design tokens and a restructured
|
||||
> player chrome with a dedicated plugin-control slot. See
|
||||
> **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract plugins
|
||||
> must follow.
|
||||
|
||||
FeedBack serves Tailwind as a **prebuilt** stylesheet
|
||||
(`static/tailwind.min.css`), never the runtime Play CDN. The CDN's on-the-fly
|
||||
|
||||
+11
-11
@@ -1,16 +1,16 @@
|
||||
# Building plugins for the v3 UI (fee[dB]ack v0.3.0)
|
||||
|
||||
v0.3.0 ("fee[dB]ack") ships a redesigned UI **behind a flag** — `FEEDBACK_UI=v3`
|
||||
or the `/v3` route. The classic UI (v2) remains the default until 0.3.0 ships, so
|
||||
plugins must work in **both**.
|
||||
v0.3.0 ("fee[dB]ack") ships a redesigned UI. It is **the only UI** — the classic v2
|
||||
shell and its `FEEDBACK_UI` / `/v2` opt-outs have been removed, so there is no
|
||||
longer a second shell to support.
|
||||
|
||||
The good news: v3 **reuses the same engine** as v2 — same `server.py`, `app.js`,
|
||||
`highway.js`, `playSong`, `showScreen`, capability registry, library providers,
|
||||
and the `window.feedBackViz_<id>` / `setRenderer` visualization contract. So your
|
||||
plugin's **backend, capabilities, library providers, `nav`/`screen`, visualization
|
||||
renderers, diagnostics, and settings export all work unchanged in v3.** v3 surfaces
|
||||
your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and your
|
||||
screen mounts exactly as before.
|
||||
The good news: v3 **reuses the same engine** the classic UI did — same `server.py`,
|
||||
`app.js`, `highway.js`, `playSong`, `showScreen`, capability registry, library
|
||||
providers, and the `window.feedBackViz_<id>` / `setRenderer` visualization contract.
|
||||
So your plugin's **backend, capabilities, library providers, `nav`/`screen`,
|
||||
visualization renderers, diagnostics, and settings export all work unchanged.** v3
|
||||
surfaces your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and
|
||||
your screen mounts exactly as before.
|
||||
|
||||
**The one thing that changed is the player chrome** — and only if your plugin
|
||||
injects controls into it.
|
||||
@@ -188,4 +188,4 @@ out of the capability graph.
|
||||
- [ ] Dropdowns positioned via `getBoundingClientRect()`, not `#player-controls`.
|
||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||
rail 30, popovers 40).
|
||||
- [ ] Verify in **both** `/` (v2) and `/v3`.
|
||||
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
||||
|
||||
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||
(2,507 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and twenty-one `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
|
||||
(2,413 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and twenty-two `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
|
||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
|
||||
+12
-5
@@ -43,12 +43,19 @@ module.exports = [
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests, core's own static/js/
|
||||
// tree): module parsing + the acyclic-imports hard gate + the size norm. A
|
||||
// migrated bundled plugin's entry `import './src/main.js'` screen.js must
|
||||
// parse as a module — add its glob here in that plugin's migration PR
|
||||
// (classic screen.js stays a script).
|
||||
//
|
||||
// `static/app.js` is listed explicitly: it is served as
|
||||
// <script type="module"> (R3a) and now `import`s its carved-out modules, so
|
||||
// parsing it as a script would be a syntax error. It is the ENTRY of core's
|
||||
// module graph, which is what makes no-cycle meaningful here — a carved
|
||||
// module that imports app.js back would close a cycle and fail this gate.
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
|
||||
@@ -117,6 +117,16 @@ invalidate_song_caches = None
|
||||
stat_for_cache = None
|
||||
scan_status = None
|
||||
|
||||
# The directory containing server.py: the repo root in dev, resources/feedBack when
|
||||
# bundled — the tree that actually holds docs/ and data/.
|
||||
#
|
||||
# It is published HERE, by server.py, precisely so no module under lib/ ever computes it.
|
||||
# `Path(__file__).resolve().parent` is correct in server.py and silently WRONG anywhere in
|
||||
# lib/ (it yields lib/, which has no docs/ or data/), and it fails by finding nothing
|
||||
# rather than by raising — the builtin-content seeds would just quietly never run. See
|
||||
# lib/builtin_content.py's header. Read it; never re-derive it.
|
||||
server_root = None
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings", "tuning_providers",
|
||||
"library_providers", "local_library_provider",
|
||||
@@ -127,6 +137,7 @@ _SLOTS = frozenset({
|
||||
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
|
||||
"default_settings",
|
||||
"kick_scan", "invalidate_song_caches", "stat_for_cache", "scan_status",
|
||||
"server_root",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Builtin content seeding: the calibration/diagnostic sloppaks and the starter library.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b) — with ONE deliberate signature change, and it is
|
||||
the whole reason this module is safe.
|
||||
|
||||
━━━ WHY THE ROOT IS A PARAMETER ━━━
|
||||
|
||||
server.py had `_feedBack_server_root()` = `Path(__file__).resolve().parent`. That is
|
||||
correct *in server.py*: the repo root in dev, resources/feedBack when bundled — the tree
|
||||
that actually holds docs/ and data/.
|
||||
|
||||
Move that body here unchanged and it keeps working, silently, and returns `lib/`. There is
|
||||
no docs/diagnostics under lib/, so every seed would quietly find nothing and log "source
|
||||
missing" — a verbatim move whose meaning changed because `__file__` did. Nothing would
|
||||
fail; the starter library would just never appear.
|
||||
|
||||
So this module CANNOT compute a root: it takes `server_root` as a parameter, and server.py
|
||||
— the only place that legitimately knows where it lives — passes it in. The trap is now
|
||||
structurally impossible rather than merely avoided. (_copy_builtin_packs already took the
|
||||
root this way; the two seed helpers now do too.)
|
||||
|
||||
Everything else is byte-identical. `log` is this module's own logger under the same
|
||||
`feedBack.` hierarchy, and CONFIG_DIR is read late as `appstate.config_dir` — see appstate.py
|
||||
for why those reads must be late-bound (tests monkeypatch it).
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir
|
||||
|
||||
log = logging.getLogger("feedBack.builtin_content")
|
||||
|
||||
|
||||
BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
|
||||
|
||||
|
||||
BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"feedBack-diagnostic-basic-guitar.sloppak",
|
||||
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def builtin_diagnostic_filename() -> str:
|
||||
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
|
||||
the onboarding challenge target (spec 010)."""
|
||||
return f"{BUILTIN_DIAGNOSTIC_SUBDIR}/{BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
|
||||
|
||||
|
||||
def _copy_builtin_packs(
|
||||
root: Path,
|
||||
dest_dir: Path,
|
||||
sources: list[tuple[str, str]],
|
||||
label: str,
|
||||
update_existing: bool = True,
|
||||
) -> int:
|
||||
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
|
||||
|
||||
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
|
||||
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
|
||||
bundled). A pack is copied when its destination is missing. Never deletes
|
||||
user files; refuses to follow a symlinked seed directory or destination and
|
||||
refuses to clobber a non-regular destination (any would let a copy escape
|
||||
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
|
||||
prefixes every log line.
|
||||
|
||||
``update_existing`` controls what happens when a *regular* destination file
|
||||
already exists: when True (diagnostic seed) a bundle copy newer than the
|
||||
destination refreshes it; when False (one-time starter content) an existing
|
||||
file is always left as-is so the user's copy is never overwritten.
|
||||
|
||||
Returns the number of ``sources`` that are present at their destination
|
||||
afterwards (freshly seeded, refreshed, or already current) — so callers can
|
||||
tell whether every pack made it. A skip (missing source, symlink/non-regular
|
||||
refusal, copy error) does not count.
|
||||
"""
|
||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
|
||||
# and copies would land at the link target, outside the DLC tree. The
|
||||
# per-file symlink guard below cannot catch this.
|
||||
if dest_dir.is_symlink():
|
||||
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
|
||||
return 0
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
|
||||
# dest_dir *after* the check above cannot redirect the per-file stat /
|
||||
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
|
||||
# os.replace accepts dir_fd on POSIX even though it isn't listed in
|
||||
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
|
||||
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
|
||||
dir_fd = None
|
||||
if (
|
||||
hasattr(os, "O_NOFOLLOW")
|
||||
and hasattr(os, "O_DIRECTORY")
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.rename in os.supports_dir_fd
|
||||
):
|
||||
try:
|
||||
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
|
||||
return 0
|
||||
|
||||
try:
|
||||
present = 0
|
||||
for dest_name, rel_source in sources:
|
||||
source = root / rel_source
|
||||
if not source.is_file():
|
||||
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
|
||||
continue
|
||||
|
||||
# lstat the destination without following symlinks. Pinned by dir_fd
|
||||
# this resolves within the real seed dir, immune to a parent swap.
|
||||
try:
|
||||
if dir_fd is not None:
|
||||
dstat = os.lstat(dest_name, dir_fd=dir_fd)
|
||||
else:
|
||||
dstat = os.lstat(dest_dir / dest_name)
|
||||
dest_exists = True
|
||||
dest_islink = stat.S_ISLNK(dstat.st_mode)
|
||||
except FileNotFoundError:
|
||||
dest_exists = False
|
||||
dest_islink = False
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
|
||||
continue
|
||||
|
||||
# Refuse to seed through a symlink at the destination name.
|
||||
if dest_islink:
|
||||
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
# A non-regular destination (directory, fifo, …) the user placed
|
||||
# there: never clobber it, and never count it as present — otherwise
|
||||
# a one-time seed would mark itself done without a real pack on disk.
|
||||
if dest_exists and not stat.S_ISREG(dstat.st_mode):
|
||||
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
if dest_exists:
|
||||
# A regular file is already there. One-time seeds (starter
|
||||
# content) must never overwrite the user's copy; refreshing
|
||||
# seeds (diagnostics) replace it only when the bundle is newer.
|
||||
if not update_existing:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
try:
|
||||
src_mtime = source.stat().st_mtime
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat source %s: %s", label, source, exc)
|
||||
continue
|
||||
if src_mtime <= dstat.st_mtime:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
action = "updated"
|
||||
else:
|
||||
action = "seeded"
|
||||
|
||||
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
|
||||
present += 1
|
||||
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
|
||||
else:
|
||||
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
|
||||
|
||||
return present
|
||||
finally:
|
||||
if dir_fd is not None:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def _write_builtin_pack(
|
||||
source: Path,
|
||||
dest_dir: Path,
|
||||
dest_name: str,
|
||||
dir_fd: int | None,
|
||||
) -> bool:
|
||||
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
|
||||
|
||||
Writes to a temp file then ``os.replace()``s onto the final name so a
|
||||
symlink raced in at the destination is overwritten (rename semantics), not
|
||||
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
|
||||
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
|
||||
replace), closing the parent-directory TOCTOU; otherwise falls back to
|
||||
path-based temp+replace. Returns True on success. Never raises.
|
||||
"""
|
||||
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
|
||||
# can't permanently block later seeds via an EEXIST collision.
|
||||
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
||||
try:
|
||||
src_stat = source.stat()
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
|
||||
return False
|
||||
if dir_fd is not None:
|
||||
tmp_fd = None
|
||||
try:
|
||||
tmp_fd = os.open(
|
||||
tmp_name,
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
|
||||
0o644,
|
||||
dir_fd=dir_fd,
|
||||
)
|
||||
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
|
||||
tmp_fd = None # fdopen now owns the descriptor
|
||||
shutil.copyfileobj(sf, tf)
|
||||
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
||||
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
|
||||
# refresh check matches the shutil.copy2 fallback path. Best-effort.
|
||||
try:
|
||||
os.utime(
|
||||
dest_name,
|
||||
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
|
||||
dir_fd=dir_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
|
||||
if tmp_fd is not None:
|
||||
try:
|
||||
os.close(tmp_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp_name, dir_fd=dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
|
||||
os.close(fd)
|
||||
shutil.copy2(source, tmp)
|
||||
os.replace(tmp, dest_dir / dest_name)
|
||||
tmp = None
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def seed_builtin_diagnostic_sloppaks(server_root: Path, dlc: Path | None = None) -> None:
|
||||
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
||||
|
||||
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
||||
when the destination is missing or older than the repo/bundle source.
|
||||
Never deletes user files or touches manually copied paths (e.g.
|
||||
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
|
||||
diagnostic target is always available. Logs and continues on errors.
|
||||
"""
|
||||
try:
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||||
return
|
||||
_copy_builtin_packs(
|
||||
server_root,
|
||||
dlc / BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
BUILTIN_DIAGNOSTIC_SOURCES,
|
||||
"Builtin diagnostic seed",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
|
||||
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
|
||||
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
|
||||
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
|
||||
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
|
||||
# seeded packs surface as ordinary library songs.
|
||||
BUILTIN_STARTER_SUBDIR = "starter"
|
||||
|
||||
|
||||
BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"beethoven-fur_elise.feedpak",
|
||||
"content/starter/beethoven-fur_elise.feedpak",
|
||||
),
|
||||
(
|
||||
"star_spangled_banner.feedpak",
|
||||
"content/starter/star_spangled_banner.feedpak",
|
||||
),
|
||||
(
|
||||
"the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
STARTER_SEED_MARKER = ".starter-content-seeded"
|
||||
|
||||
|
||||
def seed_builtin_starter_content(server_root: Path, dlc: Path | None = None) -> None:
|
||||
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
|
||||
|
||||
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
|
||||
folder configured seeds the packs and writes the marker; subsequent runs are
|
||||
no-ops, so a user who deletes the starter song does not get it back on the
|
||||
next launch. Symlink-safe; never deletes user files. Logs, never raises.
|
||||
"""
|
||||
try:
|
||||
marker = appstate.config_dir / STARTER_SEED_MARKER
|
||||
# Already seeded? The marker is a sentinel: any existing path there
|
||||
# (regular file, or a symlink/dir a user deliberately planted to opt
|
||||
# out) means "done" — lstat so we detect it without following a symlink.
|
||||
# Worst case of a planted marker is simply no starter content, never a
|
||||
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
|
||||
# *through* a symlink regardless.
|
||||
try:
|
||||
os.lstat(marker)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
|
||||
return
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
# No DLC yet — leave the marker unwritten so we retry once a
|
||||
# library folder is configured.
|
||||
log.debug("Starter content seed: no DLC folder configured, skipping")
|
||||
return
|
||||
present = _copy_builtin_packs(
|
||||
server_root,
|
||||
dlc / BUILTIN_STARTER_SUBDIR,
|
||||
BUILTIN_STARTER_SOURCES,
|
||||
"Starter content seed",
|
||||
update_existing=False,
|
||||
)
|
||||
# Only mark seeding complete once every starter pack is actually in
|
||||
# place. If a source was missing or a copy failed, leave the marker
|
||||
# unwritten so the next launch retries rather than permanently skipping.
|
||||
if present < len(BUILTIN_STARTER_SOURCES):
|
||||
log.info(
|
||||
"Starter content seed: %d/%d packs present, will retry next launch",
|
||||
present,
|
||||
len(BUILTIN_STARTER_SOURCES),
|
||||
)
|
||||
return
|
||||
# Record completion with an exclusive, no-follow create so a planted or
|
||||
# raced symlink at the marker path can't redirect the write outside
|
||||
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
|
||||
# symlink, so we never write through one.
|
||||
try:
|
||||
appstate.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(marker, flags, 0o644)
|
||||
try:
|
||||
os.write(fd, b"1\n")
|
||||
finally:
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
pass # already marked (or a non-regular path is squatting) — fine
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
|
||||
except Exception:
|
||||
log.warning("Starter content seed: unexpected error", exc_info=True)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Demo mode: the read-only request guard and the hourly session janitor.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b). Bodies are byte-identical — including a bug, see
|
||||
below.
|
||||
|
||||
━━━ THE MIDDLEWARE NEEDS `app`, SO THIS MODULE TAKES IT ━━━
|
||||
|
||||
`_demo_mode_guard` is an @app.middleware("http"), and a middleware has to be attached to an
|
||||
app object. Rather than reach for a global, this module exposes install(app): server.py
|
||||
owns the app and hands it over. Same direction as every other seam here — server.py knows
|
||||
things lib/ must not have to guess.
|
||||
|
||||
The janitor is symmetrical: start_janitor() / stop_janitor(), called from server.py's
|
||||
startup and shutdown hooks, which is where the process lifecycle actually lives.
|
||||
|
||||
━━━ register_demo_janitor_hook IS PART OF THE PLUGIN CONTRACT ━━━
|
||||
|
||||
It is a key in plugin_context, so plugins hold it as a LIVE REFERENCE from setup(). Moving
|
||||
the function is fine; wrapping or renaming it is not. server.py imports this exact object
|
||||
and puts it in the dict unchanged, so callable identity is preserved —
|
||||
tests/test_plugin_context_contract.py (#898) fails if that ever stops being true.
|
||||
|
||||
━━━ A BUG MOVED VERBATIM, ON PURPOSE ━━━
|
||||
|
||||
The janitor start guard in server.py reads:
|
||||
|
||||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
|
||||
and not _DEMO_JANITOR_STARTED:
|
||||
|
||||
`and` binds tighter than `or`, so that is `A or (B and C)` — the `not _DEMO_JANITOR_STARTED`
|
||||
re-entry guard is DEAD whenever the env var is truthy, which is the only case that runs. A
|
||||
second startup leaks a janitor thread (the handle is overwritten, so shutdown joins only
|
||||
the last). Preserved exactly as-is here and filed as issue #902: a carve whose value is
|
||||
being provably behaviour-neutral is not the place to change behaviour.
|
||||
"""
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.demo_mode")
|
||||
|
||||
|
||||
# Plugins that maintain session stores can register a cleanup callback here.
|
||||
# The demo-mode janitor calls every registered hook once per hour so stale
|
||||
# sessions are swept without the core needing to know plugin internals.
|
||||
_DEMO_JANITOR_HOOKS: list = []
|
||||
|
||||
|
||||
_DEMO_JANITOR_HOOKS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
_DEMO_JANITOR_STARTED = False
|
||||
|
||||
|
||||
_DEMO_JANITOR_STOP = threading.Event()
|
||||
|
||||
|
||||
_DEMO_JANITOR_THREAD: threading.Thread | None = None
|
||||
|
||||
|
||||
def register_demo_janitor_hook(fn) -> None:
|
||||
"""Register a zero-argument callable to be invoked hourly by the demo
|
||||
janitor. Plugins call this from their ``setup(app, context)`` when they
|
||||
want to participate in session cleanup under demo mode.
|
||||
|
||||
The callable must accept no required arguments. Async (coroutine)
|
||||
functions are rejected: the janitor runs in a plain thread and cannot
|
||||
await coroutines.
|
||||
"""
|
||||
if not callable(fn):
|
||||
raise TypeError(
|
||||
f"register_demo_janitor_hook expects a callable, got {type(fn).__name__!r}"
|
||||
)
|
||||
# Reject coroutine functions — check both the callable itself and its
|
||||
# __call__ method so objects with an async __call__ (e.g. class instances,
|
||||
# functools.partial wrappers around async functions) are also caught.
|
||||
_call = getattr(fn, "__call__", None)
|
||||
if inspect.iscoroutinefunction(fn) or (
|
||||
_call is not None and inspect.iscoroutinefunction(_call)
|
||||
):
|
||||
raise TypeError(
|
||||
"register_demo_janitor_hook does not accept async functions; "
|
||||
"the janitor runs in a plain thread and cannot await coroutines"
|
||||
)
|
||||
# Validate that the callable accepts zero required arguments so it won't
|
||||
# crash at sweep time (hourly, far from the registration site).
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
except ValueError:
|
||||
# inspect.signature() raises ValueError for built-in C callables whose
|
||||
# signature cannot be determined. Accept them as-is; if they fail at
|
||||
# runtime the janitor will catch and log the exception.
|
||||
pass
|
||||
else:
|
||||
required = [
|
||||
p for p in sig.parameters.values()
|
||||
if p.default is inspect.Parameter.empty
|
||||
and p.kind not in (
|
||||
inspect.Parameter.VAR_POSITIONAL,
|
||||
inspect.Parameter.VAR_KEYWORD,
|
||||
)
|
||||
]
|
||||
if required:
|
||||
raise TypeError(
|
||||
f"register_demo_janitor_hook expects a zero-argument callable; "
|
||||
f"{fn!r} has {len(required)} required parameter(s): "
|
||||
+ ", ".join(p.name for p in required)
|
||||
)
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
_DEMO_JANITOR_HOOKS.append(fn)
|
||||
|
||||
|
||||
def _run_janitor_hook(hook) -> None:
|
||||
"""Run a single janitor hook inline, swallowing and logging any exception.
|
||||
|
||||
If the hook returns an awaitable (e.g. a coroutine slipped through the
|
||||
async-function guard), the coroutine is closed immediately to avoid
|
||||
``RuntimeWarning: coroutine was never awaited`` noise, and a warning is
|
||||
emitted so the plugin author knows to fix their hook.
|
||||
"""
|
||||
try:
|
||||
result = hook()
|
||||
except Exception:
|
||||
log.exception("janitor hook %r raised", hook)
|
||||
return
|
||||
if inspect.iscoroutine(result):
|
||||
# A coroutine slipped through the async-function guard (e.g. via a
|
||||
# wrapper/partial). Close it to suppress "coroutine never awaited",
|
||||
# then warn so the plugin author knows to fix their hook.
|
||||
try:
|
||||
result.close()
|
||||
except Exception:
|
||||
log.exception("error closing coroutine from janitor hook %r", hook)
|
||||
warnings.warn(
|
||||
f"janitor hook {hook!r} returned a coroutine; "
|
||||
"hooks must be plain synchronous callables — "
|
||||
"register_demo_janitor_hook does not accept async functions",
|
||||
RuntimeWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
elif inspect.isawaitable(result):
|
||||
# Future/Task: no .close() method; just warn and leave it alone.
|
||||
warnings.warn(
|
||||
f"janitor hook {hook!r} returned an awaitable (Future/Task); "
|
||||
"hooks must be plain synchronous callables",
|
||||
RuntimeWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
|
||||
_DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("POST", re.compile(r"^/api/settings$")),
|
||||
("POST", re.compile(r"^/api/settings/import$")),
|
||||
("POST", re.compile(r"^/api/settings/reset$")),
|
||||
("POST", re.compile(r"^/api/rescan$")),
|
||||
("POST", re.compile(r"^/api/rescan/full$")),
|
||||
("POST", re.compile(r"^/api/songs/upload$")),
|
||||
("DELETE", re.compile(r"^/api/song/.+$")),
|
||||
("POST", re.compile(r"^/api/favorites/toggle$")),
|
||||
("POST", re.compile(r"^/api/loops$")),
|
||||
("DELETE", re.compile(r"^/api/loops/[^/]+$")),
|
||||
("POST", re.compile(r"^/api/audio-effects/mappings$")),
|
||||
("DELETE", re.compile(r"^/api/audio-effects/mappings/[^/]+$")),
|
||||
("POST", re.compile(r"^/api/audio-effects/mappings/[^/]+/activate$")),
|
||||
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
|
||||
("POST", re.compile(r"^/api/song/.*/meta$")),
|
||||
("POST", re.compile(r"^/api/song/.*/art/upload$")),
|
||||
("PUT", re.compile(r"^/api/song/.+/overrides$")),
|
||||
("GET", re.compile(r"^/api/plugins/updates$")),
|
||||
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/save$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/build$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/upload-art$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/upload-audio$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/youtube-audio$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/import-gp$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/import-midi$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/align$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/generate-pitch$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/save-lyrics$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_sync/align$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_sync/save$")),
|
||||
("POST", re.compile(r"^/api/plugins/studio/sessions/[^/]+/extract-drums$")),
|
||||
("POST", re.compile(r"^/api/diagnostics/export$")),
|
||||
("GET", re.compile(r"^/api/diagnostics/preview$")),
|
||||
("GET", re.compile(r"^/api/diagnostics/hardware$")),
|
||||
# Bundled core plugin — video background upload/delete
|
||||
("POST", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||||
("DELETE", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||||
# fee[dB]ack v0.3.0 write endpoints — demo mode is read-only, so block the
|
||||
# new profile / XP / stats / playlists / saved mutators too.
|
||||
("POST", re.compile(r"^/api/profile$")),
|
||||
("POST", re.compile(r"^/api/profile/avatar$")),
|
||||
("POST", re.compile(r"^/api/xp/award$")),
|
||||
("POST", re.compile(r"^/api/stats$")),
|
||||
("POST", re.compile(r"^/api/playlists$")),
|
||||
("PATCH", re.compile(r"^/api/playlists/[^/]+$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("POST", re.compile(r"^/api/saved/toggle$")),
|
||||
# Progression (spec 010) write endpoints — demo mode stays read-only.
|
||||
("POST", re.compile(r"^/api/progression/paths$")),
|
||||
("POST", re.compile(r"^/api/progression/onboarding$")),
|
||||
("POST", re.compile(r"^/api/progression/events$")),
|
||||
("POST", re.compile(r"^/api/shop/buy$")),
|
||||
("POST", re.compile(r"^/api/shop/equip$")),
|
||||
# Enrichment (P8): review writes mutate the local match cache, and the
|
||||
# search proxy / manual kick relay to MusicBrainz — none of it belongs to
|
||||
# anonymous demo visitors (they'd spend the shared rate limit).
|
||||
("POST", re.compile(r"^/api/enrichment/review/.+$")),
|
||||
("POST", re.compile(r"^/api/enrichment/kick$")),
|
||||
("POST", re.compile(r"^/api/enrichment/cancel$")),
|
||||
("POST", re.compile(r"^/api/enrichment/rematch$")),
|
||||
("GET", re.compile(r"^/api/enrichment/search$")),
|
||||
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
|
||||
# and spend the shared AcoustID rate budget on the caller's behalf — same
|
||||
# rule as the search/kick relays above; not for anonymous demo visitors.
|
||||
("POST", re.compile(r"^/api/enrichment/identify$")),
|
||||
("POST", re.compile(r"^/api/enrichment/identify/.+$")),
|
||||
# Context menus (R2): the per-song re-match mutates the cache + spends
|
||||
# rate limit; Get-info exposes filesystem paths.
|
||||
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
|
||||
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
|
||||
# Gap-fill (R4a) rewrites pack files on disk — never for demo visitors.
|
||||
("POST", re.compile(r"^/api/song/.+/gap-fill$")),
|
||||
# Art layer (R3): all three mutate server state / touch the network on a
|
||||
# visitor's behalf — the base64 upload writes files, the URL fetch makes the
|
||||
# server request arbitrary images, and the override delete removes files.
|
||||
("POST", re.compile(r"^/api/song/.+/art/upload$")),
|
||||
("POST", re.compile(r"^/api/song/.+/art/url$")),
|
||||
("DELETE", re.compile(r"^/api/art/.+/override$")),
|
||||
# Cover picker (PR-C): read-only, but a cache-miss open spends 1-3
|
||||
# throttled Cover Art Archive calls — anonymous demo visitors don't get
|
||||
# to spend the shared rate budget (same rule as enrichment search/kick).
|
||||
("GET", re.compile(r"^/api/song/.+/art/candidates$")),
|
||||
# Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a
|
||||
# visitor's behalf AND writes the artist_enrichment cache; refresh
|
||||
# re-spends the shared rate limit. The /page route stays open (all-local
|
||||
# read). Same rationale as /api/enrichment/search above.
|
||||
("GET", re.compile(r"^/api/artist/.+/links$")),
|
||||
("POST", re.compile(r"^/api/artist/.+/links/refresh$")),
|
||||
]
|
||||
|
||||
|
||||
async def _demo_mode_guard(request: Request, call_next):
|
||||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1":
|
||||
path = request.url.path
|
||||
for method, pattern in _DEMO_BLOCKED:
|
||||
if request.method == method and pattern.match(path):
|
||||
return JSONResponse({"error": "demo mode: read-only"}, status_code=403)
|
||||
response = await call_next(request)
|
||||
if request.method == "GET" and path == "/" and "feedBack_demo_session" not in request.cookies:
|
||||
forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip()
|
||||
is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
||||
response.set_cookie(
|
||||
"feedBack_demo_session", str(uuid.uuid4()),
|
||||
max_age=86400, httponly=True, samesite="lax",
|
||||
secure=is_secure,
|
||||
)
|
||||
return response
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def install(app) -> None:
|
||||
"""Attach the demo-mode request guard to `app`.
|
||||
|
||||
Called by server.py, which owns the app. A middleware cannot exist without one, and a
|
||||
module under lib/ should not be reaching for a global to find it.
|
||||
"""
|
||||
app.middleware("http")(_demo_mode_guard)
|
||||
|
||||
|
||||
def demo_mode_enabled() -> bool:
|
||||
"""True when demo mode is on. Read at CALL time, never captured — tests set and unset
|
||||
FEEDBACK_DEMO_MODE with monkeypatch, so a value cached at import pins the wrong one."""
|
||||
return bool(getenv_compat("FEEDBACK_DEMO_MODE"))
|
||||
|
||||
|
||||
def start_janitor() -> None:
|
||||
"""Start the hourly session janitor. Called from server.py's startup hook.
|
||||
|
||||
NB the caller's guard is the buggy one described in this module's header (issue #902).
|
||||
Behaviour is preserved verbatim: this starts a thread every time it is called.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||||
_DEMO_JANITOR_STARTED = True
|
||||
_DEMO_JANITOR_STOP.clear()
|
||||
|
||||
def _janitor():
|
||||
while not _DEMO_JANITOR_STOP.wait(timeout=3600):
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
hooks = list(_DEMO_JANITOR_HOOKS)
|
||||
for hook in hooks:
|
||||
_run_janitor_hook(hook)
|
||||
|
||||
_DEMO_JANITOR_THREAD = threading.Thread(target=_janitor, daemon=True, name="demo-janitor")
|
||||
_DEMO_JANITOR_THREAD.start()
|
||||
|
||||
|
||||
def janitor_started() -> bool:
|
||||
return _DEMO_JANITOR_STARTED
|
||||
|
||||
|
||||
def stop_janitor(timeout: float = 5) -> bool:
|
||||
"""Signal the janitor to stop, join it, and drop the registered hooks.
|
||||
|
||||
Returns True if it stopped, False if it outlived the join (the caller warns).
|
||||
|
||||
THE ORDER HERE IS LOAD-BEARING and preserved exactly from server.py. When the thread
|
||||
does NOT die within the timeout we return WITHOUT clearing _DEMO_JANITOR_STARTED and
|
||||
WITHOUT dropping the thread handle — deliberately — so a subsequent startup does not
|
||||
spawn a SECOND janitor alongside the one still running. Clearing the flag first (the
|
||||
obvious way to write this) would quietly reintroduce exactly the double-janitor leak
|
||||
the flag exists to prevent.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||||
if not _DEMO_JANITOR_STARTED:
|
||||
return True
|
||||
_DEMO_JANITOR_STOP.set()
|
||||
thread = _DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
if thread.is_alive():
|
||||
# Leave _DEMO_JANITOR_STARTED True so a new janitor is not spawned by a
|
||||
# subsequent startup while the old one is alive.
|
||||
return False
|
||||
_DEMO_JANITOR_THREAD = None
|
||||
_DEMO_JANITOR_STARTED = False
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
_DEMO_JANITOR_HOOKS.clear()
|
||||
return True
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Artist routes: the artist page + external-links payload
|
||||
(/api/artist/{name}/page, /links, /links/refresh).
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the seam reads
|
||||
(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir, _default_settings->
|
||||
appstate.default_settings). MusicBrainz link enrichment is reached as
|
||||
enrichment.X; the shared URL-safety validator lives in lib/library_registry.py.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
import enrichment
|
||||
from appconfig import _load_config
|
||||
from library_registry import _safe_art_redirect_url
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
# MB artist url-relation types → the page's link slots (locked position 4:
|
||||
# whitelist only, links-only forever). Everything not listed is dropped.
|
||||
_ARTIST_URL_REL_SLOTS = {
|
||||
"official homepage": "official",
|
||||
"setlistfm": "tour",
|
||||
"concerts": "tour",
|
||||
"youtube": "video",
|
||||
"video channel": "video",
|
||||
"social network": "social",
|
||||
"bandcamp": "social",
|
||||
"soundcloud": "social",
|
||||
"wikipedia": "wikipedia",
|
||||
"wikidata": "wikipedia",
|
||||
}
|
||||
|
||||
|
||||
def _artist_links_from_mb(body: dict) -> tuple[dict, list]:
|
||||
"""Whitelist an MB artist doc's url-relations into the page's link slots:
|
||||
{official, tour, video, social: [...], wikipedia}. Every URL passes the
|
||||
same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a
|
||||
hostile javascript:/data:/file: resource can never reach an href. First
|
||||
URL wins per single slot; social collects up to 5; wikipedia is preferred
|
||||
over wikidata when both exist. Also returns MB's genre names (capped)."""
|
||||
links: dict = {}
|
||||
social: list = []
|
||||
wikidata_url = None
|
||||
for rel in (body or {}).get("relations") or []:
|
||||
if not isinstance(rel, dict):
|
||||
continue
|
||||
rtype = str(rel.get("type") or "").strip().lower()
|
||||
slot = _ARTIST_URL_REL_SLOTS.get(rtype)
|
||||
if not slot:
|
||||
continue
|
||||
url = rel.get("url")
|
||||
url = url.get("resource") if isinstance(url, dict) else url
|
||||
if _safe_art_redirect_url(url) is None:
|
||||
continue
|
||||
if slot == "social":
|
||||
if url not in social and len(social) < 5:
|
||||
social.append(url)
|
||||
elif rtype == "wikidata":
|
||||
wikidata_url = wikidata_url or url
|
||||
elif slot not in links:
|
||||
links[slot] = url
|
||||
if social:
|
||||
links["social"] = social
|
||||
if "wikipedia" not in links and wikidata_url:
|
||||
links["wikipedia"] = wikidata_url
|
||||
genres = [str(g.get("name")) for g in (body or {}).get("genres") or []
|
||||
if isinstance(g, dict) and g.get("name")]
|
||||
return links, genres[:8]
|
||||
|
||||
|
||||
def _artist_links_payload(name: str, force: bool = False) -> dict:
|
||||
"""Shared by GET links + POST refresh. Order of gates: the user's opt-in
|
||||
setting (external links are OFF by default — the dev-chat thread's call),
|
||||
then a known mb_artist_id (no id → nothing to look up), then the cache
|
||||
(unless force), then the offline guard, then ONE throttled fetch."""
|
||||
cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings()
|
||||
if cfg.get("artist_external_links") is not True:
|
||||
return {"links": {}, "matched": False, "disabled": True}
|
||||
canonical = appstate.meta_db._terminal_canonical((name or "").strip())
|
||||
mbid = appstate.meta_db.artist_known_mb_id(appstate.meta_db._raw_variants_for(canonical))
|
||||
mbid = (mbid or "").strip().lower()
|
||||
# The id is interpolated into the MB request path — same strict-shape rule
|
||||
# as the manifest identity keys (_MBID_RE), so a junk/hostile value stored
|
||||
# via a hand-rolled /pick body can never reach the request line.
|
||||
if not mbid or not enrichment._MBID_RE.match(mbid):
|
||||
return {"links": {}, "matched": False}
|
||||
if not force:
|
||||
cached = appstate.meta_db.get_artist_enrichment(mbid)
|
||||
if cached:
|
||||
return {"links": cached["url_rels"], "genres": cached["genres"],
|
||||
"matched": True, "cached": True, "mb_artist_id": mbid}
|
||||
if not enrichment._enrich_network_enabled():
|
||||
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
|
||||
try:
|
||||
body = enrichment._mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"})
|
||||
except enrichment.EnrichTransportError:
|
||||
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
|
||||
links, genres = _artist_links_from_mb(body or {})
|
||||
appstate.meta_db.put_artist_enrichment(mbid, links, genres)
|
||||
return {"links": links, "genres": genres, "matched": True, "cached": False,
|
||||
"mb_artist_id": mbid}
|
||||
|
||||
|
||||
@router.get("/api/artist/{name:path}/page")
|
||||
def api_artist_page(name: str):
|
||||
"""The artist page's all-LOCAL payload — counts, albums, aliases, similar-
|
||||
in-library, mosaic art, play-all seed. Never touches the network; an
|
||||
unmatched or even unknown artist still returns a functional page."""
|
||||
return appstate.meta_db.artist_page(name)
|
||||
|
||||
|
||||
@router.get("/api/artist/{name:path}/links")
|
||||
def api_artist_links(name: str):
|
||||
"""External links for a matched artist — cached after the first call.
|
||||
Sync route on purpose (like /api/enrichment/search): FastAPI runs it in
|
||||
the threadpool so the MB throttle's sleep never blocks the event loop."""
|
||||
return _artist_links_payload(name)
|
||||
|
||||
|
||||
@router.post("/api/artist/{name:path}/links/refresh")
|
||||
def api_artist_links_refresh(name: str):
|
||||
"""Explicit re-fetch of the cached links (the page's manual Refresh)."""
|
||||
return _artist_links_payload(name, force=True)
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"""The library scanner: the background scan, its process pool, and the kick/runner
|
||||
plumbing that serialises passes.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b) except the seam reads. Everything shared is read
|
||||
LATE off appstate — the same contract every module in lib/routers/ uses, and it is not
|
||||
cosmetic: tests monkeypatch CONFIG_DIR and swap meta_db, so a value captured at import
|
||||
time would pin the wrong one for the life of the process.
|
||||
|
||||
CONFIG_DIR -> appstate.config_dir
|
||||
meta_db -> appstate.meta_db
|
||||
_default_settings -> appstate.default_settings()
|
||||
_stat_for_cache -> appstate.stat_for_cache()
|
||||
_feedBack_server_root() -> appstate.server_root <- see below
|
||||
|
||||
━━━ THE SCAN STATUS IS REBOUND, NOT MUTATED ━━━
|
||||
|
||||
`_background_scan` does `global _scan_status; _scan_status = {**INIT, ...}` at every stage
|
||||
transition. It REPLACES the dict; it does not update it in place. So nothing may hold the
|
||||
dict by value — a reference captured once goes permanently stale at the first stage change,
|
||||
and would report "listing" forever while the scan ran to completion.
|
||||
|
||||
That is why this module exports `status()`, a getter, and why appstate publishes
|
||||
`scan_status` as a CALLABLE rather than a dict. appstate.py already says so in a comment;
|
||||
this is the code that makes it true.
|
||||
|
||||
━━━ AND WHY THE SERVER ROOT IS READ, NEVER DERIVED ━━━
|
||||
|
||||
`_background_scan` seeds the builtin content, which needs the directory holding server.py.
|
||||
`Path(__file__).resolve().parent` is correct in server.py and silently WRONG here (it
|
||||
yields lib/, which has no docs/ or data/) — and it fails by finding nothing rather than by
|
||||
raising, so the seeds would just quietly never run. server.py publishes the root once, as
|
||||
appstate.server_root. Read it; never re-derive it.
|
||||
"""
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
import builtin_content
|
||||
import enrichment
|
||||
import loosefolder as loosefolder_mod
|
||||
import sloppak as sloppak_mod
|
||||
from appconfig import _load_config
|
||||
from dlc_paths import _get_dlc_dir
|
||||
from env_compat import getenv_compat
|
||||
from scan_worker import _relpath, _scan_one
|
||||
|
||||
log = logging.getLogger("feedBack.scan")
|
||||
|
||||
|
||||
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
|
||||
|
||||
|
||||
_scan_status = dict(_SCAN_STATUS_INIT)
|
||||
|
||||
|
||||
def _make_scan_executor():
|
||||
"""Build the executor for the background metadata scan.
|
||||
|
||||
A `spawn` ProcessPoolExecutor in production. `spawn` (not the platform
|
||||
default) is mandatory: _background_scan runs on a non-main daemon
|
||||
thread, and forking a multithreaded process from a non-main thread can
|
||||
deadlock on locks held by other threads at fork time (the default on
|
||||
Linux). `spawn` boots a clean interpreter that imports only scan_worker
|
||||
(+ its pure lib deps) to unpickle the worker — never this module — so
|
||||
workers don't re-run server.py's import-time side effects (reopening
|
||||
SQLite, attaching a second RotatingFileHandler, re-registering routes).
|
||||
|
||||
Tests monkeypatch this to a ThreadPoolExecutor so the scan runs
|
||||
in-process and metadata extraction can be mocked.
|
||||
"""
|
||||
mp_ctx = multiprocessing.get_context("spawn")
|
||||
# Default to one worker per core so CPU-bound metadata parsing uses the
|
||||
# whole machine (the point of moving to processes).
|
||||
# FEEDBACK_MAX_SCAN_WORKERS (set by the Desktop launcher to cap memory
|
||||
# usage on low-RAM machines — e.g. 8 GB M2 MacBook Air) takes priority;
|
||||
# SCAN_MAX_WORKERS is a legacy override for Docker/bare installs.
|
||||
# A malformed override falls back to the core count rather than crashing.
|
||||
try:
|
||||
max_workers = int(
|
||||
getenv_compat("FEEDBACK_MAX_SCAN_WORKERS")
|
||||
or os.environ.get("SCAN_MAX_WORKERS")
|
||||
or (os.cpu_count() or 1)
|
||||
)
|
||||
except ValueError:
|
||||
max_workers = os.cpu_count() or 1
|
||||
# ProcessPoolExecutor raises ValueError on Windows when max_workers > 61
|
||||
# (the WaitForMultipleObjects handle limit), so clamp there — otherwise
|
||||
# a high-core Windows host can't construct the pool and the scan never
|
||||
# starts.
|
||||
if sys.platform == "win32":
|
||||
max_workers = min(max_workers, 61)
|
||||
return concurrent.futures.ProcessPoolExecutor(
|
||||
max_workers=max(1, max_workers), mp_context=mp_ctx,
|
||||
)
|
||||
|
||||
|
||||
def background_scan():
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
Never sets `_scan_status["running"] = False` — ownership of that flag
|
||||
lives in `_scan_runner` so a `kick_scan()` racing this function's
|
||||
terminal write cannot observe a stale False and start a second runner.
|
||||
"""
|
||||
global _scan_status
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "listing"}
|
||||
|
||||
# Load config once so both the DLC-dir lookup and the platform filter
|
||||
# read from the same snapshot, avoiding a redundant parse of config.json.
|
||||
_cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings()
|
||||
dlc = _get_dlc_dir(_cfg)
|
||||
if not dlc:
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "idle", "error": "DLC folder not configured"}
|
||||
log.warning("Scan: no DLC folder configured")
|
||||
return
|
||||
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
|
||||
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
|
||||
|
||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||
# path isn't shared. Report the failure explicitly rather than silently
|
||||
# appearing to scan nothing.
|
||||
try:
|
||||
# Generated-content sloppaks that the highway WS must resolve by path
|
||||
# but that are NOT library songs. Two conventions share this carve-out:
|
||||
# - tutorials-builtin/ — lesson drills seeded by the tutorials plugin
|
||||
# (see plugins/tutorials/routes.py::_seed_builtin_packs).
|
||||
# - minigames-builtin/ — exercise charts generated on demand by
|
||||
# minigame plugins (e.g. Chord Sprint writes alternating-chord
|
||||
# drills here). Cached/reused per exercise, never browsed.
|
||||
# Both are kept out of the scan; _resolve_dlc_path still loads them by
|
||||
# path for playback.
|
||||
def _is_excluded_from_library(p: Path) -> bool:
|
||||
return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts
|
||||
# Sloppaks: match both file (zip) and directory form, across both the
|
||||
# `.feedpak` and legacy `.sloppak` suffixes.
|
||||
_cands = sorted(p for ext in sloppak_mod.SONG_EXTS for p in dlc.rglob(f"*{ext}"))
|
||||
sloppaks = [f for f in _cands
|
||||
if sloppak_mod.is_sloppak(f)
|
||||
and not _is_excluded_from_library(f)]
|
||||
|
||||
# Loose song folders: any directory containing a non-preview *.wem + *.xml.
|
||||
# Skip directories that are actually sloppak bundles — those are
|
||||
# already in `sloppaks`; the dispatcher's sloppak-first precedence
|
||||
# would route them to the sloppak path anyway, but adding them
|
||||
# here would inflate the scan queue and over-count the total.
|
||||
loose_songs = []
|
||||
seen_loose = set()
|
||||
sloppak_dirs = {p for p in sloppaks if p.is_dir()}
|
||||
for wem in sorted(dlc.rglob("*.wem")):
|
||||
if "preview" in wem.stem.lower():
|
||||
continue
|
||||
if _is_excluded_from_library(wem):
|
||||
continue
|
||||
d = wem.parent
|
||||
if d in sloppak_dirs or d.name.lower().endswith(sloppak_mod.SONG_EXTS):
|
||||
continue
|
||||
if d not in seen_loose and loosefolder_mod.is_loose_song(d):
|
||||
loose_songs.append(d)
|
||||
seen_loose.add(d)
|
||||
except PermissionError as e:
|
||||
msg = (f"Permission denied reading {dlc}. "
|
||||
"On macOS: grant Full Disk Access to the app in System Settings → Privacy & Security. "
|
||||
"With Docker: share this path in Docker Desktop → Settings → Resources → File Sharing.")
|
||||
log.error("Scan failed: %s (%s)", msg, e)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": msg}
|
||||
return
|
||||
except OSError as e:
|
||||
log.error("Scan failed listing %s: %s", dlc, e)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": f"Unable to list {dlc}: {e}"}
|
||||
return
|
||||
|
||||
all_songs = sloppaks + loose_songs
|
||||
log.info("Scan: listed %d sloppaks and %d loose folders in %s",
|
||||
len(sloppaks), len(loose_songs), dlc)
|
||||
|
||||
current_files = {_relpath(f, dlc) for f in all_songs}
|
||||
|
||||
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned
|
||||
# + genuinely-new files) so the scan can surface an added/removed summary.
|
||||
_delta = appstate.meta_db.delete_missing(current_files)
|
||||
removed, added = _delta["removed"], _delta["added"]
|
||||
if removed:
|
||||
log.info("Removed %d stale DB entries", removed)
|
||||
|
||||
# Figure out which need scanning
|
||||
to_scan = []
|
||||
for f in all_songs:
|
||||
# Skip entries that vanish or become unreadable between listing
|
||||
# and stat. Without this, one concurrent move/delete in DLC_DIR
|
||||
# would crash the scan thread and leave `_scan_status["running"]`
|
||||
# stuck true with no path to recover.
|
||||
try:
|
||||
mtime, size = appstate.stat_for_cache(f)
|
||||
except OSError as e:
|
||||
log.debug("scan: skipping %s (%s)", f, e)
|
||||
continue
|
||||
cache_key = _relpath(f, dlc)
|
||||
try:
|
||||
cached = appstate.meta_db.get(cache_key, mtime, size)
|
||||
except Exception as e:
|
||||
# Keep scanning even if a single metadata lookup fails.
|
||||
# The file will be re-scanned and cache repaired by put().
|
||||
log.warning("scan cache lookup failed for %s: %s", cache_key, e)
|
||||
cached = None
|
||||
if not cached:
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
elif cached.get("arrangements") and any(
|
||||
"smart_name" not in a for a in cached["arrangements"]
|
||||
):
|
||||
# Row was scanned before smart naming was introduced — force a
|
||||
# rescan so the DB picks up authoritative path flags from the
|
||||
# manifest JSON and stores correct smart_name values. Don't
|
||||
# re-queue rows where smart_name is explicitly null: the writer
|
||||
# only emits that when compute_smart_names truly can't classify
|
||||
# the arrangement (e.g. a name outside the recognised set with
|
||||
# zero path flags), so rescanning would produce the same null
|
||||
# forever and never converge.
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
|
||||
if not to_scan:
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
|
||||
return
|
||||
|
||||
# Refine: all discovered songs need scanning → treat as first-time import
|
||||
# (covers moved DLC folder / fully-stale DB as well as a genuinely empty DB).
|
||||
is_first_scan = bool(all_songs) and len(to_scan) == len(all_songs)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "scanning", "total": len(to_scan),
|
||||
"is_first_scan": is_first_scan}
|
||||
log.info("Library: %d sloppaks + %d loose folders, %d cached, %d to scan",
|
||||
len(sloppaks), len(loose_songs), len(all_songs) - len(to_scan), len(to_scan))
|
||||
|
||||
with _make_scan_executor() as executor:
|
||||
futures = {executor.submit(_scan_one, item): item[0].name for item in to_scan}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
fname = futures[future]
|
||||
try:
|
||||
name, mtime, size, meta = future.result()
|
||||
appstate.meta_db.put(name, mtime, size, meta)
|
||||
except Exception as e:
|
||||
log.warning("scan failed for %s: %s", fname, e)
|
||||
_scan_status["done"] += 1
|
||||
_scan_status["current"] = fname
|
||||
|
||||
log.info("Scan complete: %d songs cached", len(to_scan))
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
|
||||
|
||||
_scan_kick_lock = threading.Lock()
|
||||
|
||||
|
||||
_scan_rescan_pending = False
|
||||
|
||||
|
||||
# Handles to the running scan / enrichment worker threads. Both use the shared
|
||||
# MetadataDB connection, so teardown/shutdown MUST join them before closing that
|
||||
# connection — a daemon thread mid-query on a closed SQLite conn is a native
|
||||
# use-after-free that segfaults the process (seen flaky in CI). Set by
|
||||
# _kick_scan / _kick_enrich; joined by _join_background_db_threads().
|
||||
_scan_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def kick_scan() -> bool:
|
||||
"""Request a library rescan, single-flight + coalescing.
|
||||
|
||||
Returns True if a new scan thread was started, False if one was already
|
||||
running. In the latter case a follow-up pass is queued and runs as soon
|
||||
as the current scan finishes so files landing mid-scan (e.g. an upload
|
||||
that finalizes after the scan has already listed DLC_DIR) are not lost
|
||||
until the next periodic pass. Multiple late-arriving requests coalesce
|
||||
into a single follow-up.
|
||||
"""
|
||||
global _scan_rescan_pending, _scan_thread
|
||||
with _scan_kick_lock:
|
||||
if _scan_status["running"]:
|
||||
_scan_rescan_pending = True
|
||||
return False
|
||||
# Mark running synchronously so a parallel kick_scan() observes it
|
||||
# before the worker thread has a chance to reassign _scan_status.
|
||||
_scan_status["running"] = True
|
||||
_scan_thread = threading.Thread(target=_scan_runner, daemon=True)
|
||||
_scan_thread.start()
|
||||
return True
|
||||
|
||||
|
||||
def _scan_runner():
|
||||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||||
global _scan_rescan_pending
|
||||
while True:
|
||||
try:
|
||||
background_scan()
|
||||
except Exception:
|
||||
log.exception("background scan failed unexpectedly")
|
||||
|
||||
with _scan_kick_lock:
|
||||
if not _scan_rescan_pending:
|
||||
_scan_status["running"] = False
|
||||
break
|
||||
_scan_rescan_pending = False
|
||||
_scan_status["running"] = True
|
||||
# Enrichment rides scan completion (library-metadata design §6): the scan
|
||||
# pool is a side-effect-free, no-network process pool by design, so
|
||||
# enrichment is a SEPARATE post-scan pass — non-blocking, the library is
|
||||
# usable immediately. The 5-minute periodic rescan re-kicks it, which is
|
||||
# the natural low-priority retry hook.
|
||||
enrichment._kick_enrich()
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""The live scan status.
|
||||
|
||||
A GETTER, deliberately. `_scan_status` is REBOUND on every stage transition, so a
|
||||
caller holding the dict would be reading a snapshot frozen at whatever stage it
|
||||
happened to grab — see the module header.
|
||||
"""
|
||||
return _scan_status
|
||||
|
||||
|
||||
def scan_thread():
|
||||
"""The background scan thread, or None. Read by shutdown to join it."""
|
||||
return _scan_thread
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -2384,6 +2385,53 @@ def register_plugin_api(app: FastAPI):
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
# ── Module-graph cache busting (#879) ────────────────────────────────
|
||||
#
|
||||
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||
# <script type="module"> whose src the module map has already seen fires
|
||||
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
|
||||
# and (see below) an upgrade too — silently kept the OLD module live while
|
||||
# the loader recorded success: a no-op that reported it worked.
|
||||
#
|
||||
# Busting the ENTRY url does not help. A module plugin's screen.js is a
|
||||
# one-line `import './src/main.js'`, and a relative specifier resolves
|
||||
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
|
||||
# the graph. Driving a real browser through install -> upgrade -> rollback and
|
||||
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
|
||||
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
|
||||
# same URL; the module map returns the already-evaluated old module.
|
||||
#
|
||||
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
|
||||
# relative import inherits it at every depth — for free, with no
|
||||
# import-specifier rewriting (which could never see `import(expr)` anyway).
|
||||
#
|
||||
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
|
||||
# URL, so EVERYTHING a module resolves relatively moves with it — not just
|
||||
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
|
||||
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
|
||||
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
|
||||
# worklet and wasm file the graph reaches — and would silently break again the
|
||||
# next time someone adds a plugin route. Stripping the segment before routing
|
||||
# makes every plugin route, present and future, work under the prefix.
|
||||
#
|
||||
# The token is opaque: it is never joined into a filesystem path (and is gone
|
||||
# by the time any handler runs), so containment still rests entirely on the
|
||||
# same safe_join the un-prefixed routes use.
|
||||
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
|
||||
|
||||
@app.middleware("http")
|
||||
async def _strip_plugin_generation_prefix(request: Request, call_next):
|
||||
m = _GEN_PREFIX.match(request.scope.get("path", ""))
|
||||
if m:
|
||||
# Starlette routes on scope["path"] alone. raw_path is deliberately left
|
||||
# ALONE: it is informational, and re-encoding the rewritten str back to
|
||||
# bytes would have to guess a codec — `.encode("latin-1")` raises
|
||||
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
|
||||
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
|
||||
# the client actually sent it is also simply more truthful for logs.
|
||||
request.scope["path"] = m.group(1) + m.group(2)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
def plugin_settings_html(plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Career plugin — only what the prebuilt core Tailwind doesn't ship
|
||||
(plugin files are outside the core content glob, so responsive grid
|
||||
variants and cyan button shades live here under plugin-prefixed names). */
|
||||
|
||||
.career-venues {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.career-btn {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.career-btn-primary { background-color: #0891b2; color: #fff; }
|
||||
.career-btn-primary:hover { background-color: #06b6d4; }
|
||||
.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; }
|
||||
.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||
|
||||
.career-bar-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(31, 41, 55, 0.9);
|
||||
overflow: hidden;
|
||||
}
|
||||
.career-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #06b6d4;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.career-star-list {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.career-star-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.career-star-row .stars {
|
||||
color: #facc15;
|
||||
letter-spacing: 0.1em;
|
||||
min-width: 3.2em;
|
||||
}
|
||||
.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); }
|
||||
.career-star-row .song {
|
||||
color: #e5e7eb;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.career-star-row .song .artist { color: #9ca3af; }
|
||||
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
||||
.career-star-row .hint.close { color: #22d3ee; }
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "career",
|
||||
"name": "Career",
|
||||
"version": "0.1.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"description": "Career mode \u2014 gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/career.css",
|
||||
"routes": "routes.py",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "system"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Career mode — venue progression driven by per-song stars.
|
||||
|
||||
Stars come straight from ``song_stats`` (meta.db): per song, the best
|
||||
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/
|
||||
<id>/`` 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.
|
||||
|
||||
Endpoints (all under /api/plugins/career/):
|
||||
GET /state stars + per-venue unlock/install/download status
|
||||
POST /packs/{venue_id}/download start background pack download (409 if running)
|
||||
DELETE /packs/{venue_id} remove an installed pack
|
||||
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
PLUGIN_ID = "career"
|
||||
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
||||
DOWNLOAD_CHUNK = 1024 * 256
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state = {
|
||||
"content": None, # parsed venues.json
|
||||
"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"),
|
||||
"downloads": {}, # venue_id -> {status, bytes_done, bytes_total, error}
|
||||
}
|
||||
|
||||
|
||||
def _venue(venue_id):
|
||||
for v in _state["content"]["venues"]:
|
||||
if v["id"] == venue_id:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _venue_dir(venue_id) -> Path:
|
||||
return _state["venues_dir"] / venue_id
|
||||
|
||||
|
||||
def _installed(venue_id):
|
||||
return (_venue_dir(venue_id) / "manifest.json").is_file()
|
||||
|
||||
|
||||
def _stars():
|
||||
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return 0, {}, []
|
||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||
# Existing-song filter: a scan hides (not deletes) stats of songs removed
|
||||
# from the library, so orphaned rows must not keep counting toward stars.
|
||||
rows = db.conn.execute(
|
||||
"SELECT s.filename, MAX(s.best_accuracy), "
|
||||
" COALESCE(MAX(sg.title), ''), COALESCE(MAX(sg.artist), '') "
|
||||
"FROM song_stats s JOIN songs sg ON sg.filename = s.filename "
|
||||
"GROUP BY s.filename"
|
||||
).fetchall()
|
||||
per_song = {}
|
||||
detail = []
|
||||
for filename, acc, title, artist in rows:
|
||||
acc = acc or 0.0
|
||||
stars = sum(1 for t in thresholds if acc >= t)
|
||||
if stars:
|
||||
per_song[filename] = stars
|
||||
next_at = next((t for t in thresholds if acc < t), None)
|
||||
detail.append({
|
||||
"filename": filename,
|
||||
"title": title or filename,
|
||||
"artist": artist,
|
||||
"stars": stars,
|
||||
"best_accuracy": round(acc, 4),
|
||||
"next_star_at": next_at,
|
||||
})
|
||||
# closest-to-next-star first (a practice worklist), maxed songs last
|
||||
detail.sort(key=lambda r: (r["next_star_at"] is None,
|
||||
(r["next_star_at"] or 1.0) - r["best_accuracy"]))
|
||||
return sum(per_song.values()), per_song, detail
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||
manifest_path = pack_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
raise ValueError("pack has no manifest.json")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
loops = manifest.get("loops") or {}
|
||||
for state in REQUIRED_LOOPS:
|
||||
name = loops.get(state)
|
||||
if not name or not PACK_FILENAME_RE.fullmatch(name):
|
||||
raise ValueError(f"manifest is missing the '{state}' loop")
|
||||
if not (pack_dir / name).is_file():
|
||||
raise ValueError(f"loop file '{name}' missing from pack")
|
||||
for name in (manifest.get("stingers") or {}).values():
|
||||
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
|
||||
raise ValueError(f"stinger file '{name}' invalid or missing")
|
||||
for block in ("intro", "sfx"):
|
||||
for name in (manifest.get(block) or {}).values():
|
||||
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
|
||||
raise ValueError(f"{block} file '{name}' invalid or missing")
|
||||
|
||||
|
||||
def _download_pack(venue_id, pack, progress):
|
||||
"""Worker thread: stream → sha256 verify → extract → validate → swap in."""
|
||||
log = _state["log"]
|
||||
final_dir = _venue_dir(venue_id)
|
||||
staging = Path(tempfile.mkdtemp(prefix=f"career-{venue_id}-",
|
||||
dir=str(_state["venues_dir"])))
|
||||
zip_path = staging / "pack.zip"
|
||||
try:
|
||||
digest = hashlib.sha256()
|
||||
req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-career"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out:
|
||||
total = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0)
|
||||
progress["bytes_total"] = total
|
||||
while True:
|
||||
chunk = resp.read(DOWNLOAD_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
out.write(chunk)
|
||||
progress["bytes_done"] += len(chunk)
|
||||
if digest.hexdigest() != pack["sha256"]:
|
||||
raise ValueError("sha256 mismatch — corrupt or tampered download")
|
||||
|
||||
extract_dir = staging / "pack"
|
||||
extract_dir.mkdir()
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for info in zf.infolist():
|
||||
# Zip-slip guard: only flat, whitelisted names get extracted.
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = Path(info.filename).name
|
||||
if name != info.filename or not PACK_FILENAME_RE.fullmatch(name):
|
||||
raise ValueError(f"unexpected file in pack: {info.filename!r}")
|
||||
with zf.open(info) as src, open(extract_dir / name, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
zip_path.unlink()
|
||||
_validate_pack_dir(extract_dir)
|
||||
|
||||
if final_dir.exists():
|
||||
shutil.rmtree(final_dir)
|
||||
extract_dir.rename(final_dir)
|
||||
progress["status"] = "done"
|
||||
log.info("career: venue pack '%s' installed", venue_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
|
||||
progress["status"] = "error"
|
||||
progress["error"] = str(exc)
|
||||
log.warning("career: venue pack '%s' download failed: %s", venue_id, exc)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def setup(app, context):
|
||||
plugin_dir = Path(__file__).resolve().parent
|
||||
_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"]
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
|
||||
def get_state():
|
||||
stars_total, per_song, star_detail = _stars()
|
||||
venues = []
|
||||
for v in _state["content"]["venues"]:
|
||||
with _lock:
|
||||
dl = dict(_state["downloads"].get(v["id"]) or {"status": "idle"})
|
||||
venues.append({
|
||||
"id": v["id"],
|
||||
"name": v["name"],
|
||||
"description": v.get("description", ""),
|
||||
"star_threshold": v["star_threshold"],
|
||||
"unlocked": stars_total >= v["star_threshold"],
|
||||
"installed": _installed(v["id"]),
|
||||
"has_pack": bool(v.get("pack")),
|
||||
"download": dl,
|
||||
})
|
||||
return {
|
||||
"stars_total": stars_total,
|
||||
"stars_per_song": per_song,
|
||||
"star_detail": star_detail,
|
||||
"star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"],
|
||||
"venues": venues,
|
||||
}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||
def start_download(venue_id: str):
|
||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||
if venue is None:
|
||||
raise HTTPException(404, "Unknown venue.")
|
||||
pack = venue.get("pack")
|
||||
if not pack:
|
||||
raise HTTPException(404, "No pack published for this venue yet.")
|
||||
stars_total, _, _ = _stars()
|
||||
if stars_total < venue["star_threshold"]:
|
||||
raise HTTPException(403, "Venue not unlocked yet.")
|
||||
with _lock:
|
||||
running = _state["downloads"].get(venue_id)
|
||||
if running and running["status"] == "running":
|
||||
raise HTTPException(409, "Download already running.")
|
||||
progress = {"status": "running", "bytes_done": 0,
|
||||
"bytes_total": pack.get("bytes") or 0, "error": None}
|
||||
_state["downloads"][venue_id] = progress
|
||||
threading.Thread(target=_download_pack, args=(venue_id, pack, progress),
|
||||
name=f"career-pack-{venue_id}", daemon=True).start()
|
||||
return {"ok": True}
|
||||
|
||||
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
|
||||
def delete_pack(venue_id: str):
|
||||
if not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None:
|
||||
raise HTTPException(404, "Unknown venue.")
|
||||
with _lock:
|
||||
running = _state["downloads"].get(venue_id)
|
||||
if running and running["status"] == "running":
|
||||
raise HTTPException(409, "Download in progress.")
|
||||
_state["downloads"].pop(venue_id, None)
|
||||
shutil.rmtree(_venue_dir(venue_id), ignore_errors=True)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}")
|
||||
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
|
||||
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
|
||||
# the resolved path must stay inside the venues dir.
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(_state["venues_dir"].resolve())
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(404, "Not found.")
|
||||
if not resolved.is_file():
|
||||
raise HTTPException(404, "Not found.")
|
||||
media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg",
|
||||
"json": "application/json"}[resolved.suffix.lstrip(".").lower()]
|
||||
return FileResponse(
|
||||
resolved,
|
||||
media_type=media,
|
||||
# Pack files are immutable per version, but a re-download after a
|
||||
# pack update overwrites in place — no-cache + ETag revalidation
|
||||
# keeps browsers honest for the price of a 304.
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="max-w-5xl mx-auto px-4 py-6">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-1">
|
||||
<h1 class="text-2xl font-bold text-white">Career</h1>
|
||||
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
||||
<div id="career-progress-wrap" class="mb-6">
|
||||
<div class="career-bar-track">
|
||||
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
|
||||
</div>
|
||||
<div id="career-venues" class="career-venues"></div>
|
||||
<div class="mt-8">
|
||||
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
|
||||
<div id="career-star-summary" class="text-xs text-gray-400"></div>
|
||||
</div>
|
||||
<div id="career-star-list" class="career-star-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Career plugin — venue progression UI + crowd-manifest push.
|
||||
*
|
||||
* Reads /api/plugins/career/state (stars from song_stats, per-venue
|
||||
* unlock/install/download status), renders the career screen, and pushes the
|
||||
* active venue's pack manifest into the crowd video layer
|
||||
* (window.v3VenueCrowd, shipped with the venue crowd PR) whenever it changes.
|
||||
* Everything degrades: no crowd layer → screen still works; no packs → the
|
||||
* venue scene keeps its static plate.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API = '/api/plugins/career';
|
||||
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||
const NO_VENUE = '__none__';
|
||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||
const POLL_MS = 2000;
|
||||
|
||||
let _state = null;
|
||||
let _pollTimer = 0;
|
||||
let _appliedManifestVenue = null;
|
||||
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
|
||||
let _prevUnlockedIds = null;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
const res = await fetch(API + '/state');
|
||||
if (!res.ok) throw new Error('career state ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function lastOf(arr) { return arr.length ? arr[arr.length - 1] : null; }
|
||||
|
||||
// Active pack = localStorage override when unlocked+installed, else the
|
||||
// highest unlocked+installed tier; none → clear the crowd manifest.
|
||||
async function pushCrowdManifest(state) {
|
||||
const crowd = window.v3VenueCrowd;
|
||||
if (!crowd || typeof crowd.setManifest !== 'function') return;
|
||||
// Any newer invocation (delete, venue switch, fresher state) must win
|
||||
// over a manifest fetch still in flight from this one.
|
||||
const gen = ++_manifestReqGen;
|
||||
const unlocked = state.venues.filter((v) => v.unlocked);
|
||||
let venue = null;
|
||||
let override = null;
|
||||
try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ }
|
||||
if (override !== NO_VENUE) {
|
||||
venue = unlocked.find((v) => v.id === override && v.installed) || null;
|
||||
if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
|
||||
}
|
||||
if (!venue) {
|
||||
if (_appliedManifestVenue !== null) {
|
||||
_appliedManifestVenue = null;
|
||||
crowd.setManifest(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (venue.id === _appliedManifestVenue) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/venues/${venue.id}/manifest.json`);
|
||||
if (gen !== _manifestReqGen || !res.ok) return;
|
||||
const manifest = await res.json();
|
||||
if (gen !== _manifestReqGen) return;
|
||||
manifest.base = `${API}/venues/${venue.id}/`;
|
||||
_appliedManifestVenue = venue.id;
|
||||
crowd.setManifest(manifest);
|
||||
} catch (_) { /* pack half-installed; next refresh retries */ }
|
||||
}
|
||||
|
||||
function venueCardHTML(v, state) {
|
||||
const locked = !v.unlocked;
|
||||
const dl = v.download || { status: 'idle' };
|
||||
const pct = dl.bytes_total > 0
|
||||
? Math.round((dl.bytes_done / dl.bytes_total) * 100) : 0;
|
||||
let action = '';
|
||||
if (locked) {
|
||||
action = `<div class="text-xs text-gray-500">Unlocks at ${v.star_threshold} ★ — ${Math.max(0, v.star_threshold - state.stars_total)} to go</div>`;
|
||||
} else if (dl.status === 'running') {
|
||||
action = `<div class="career-bar-track mb-1" style="height:0.375rem"><div class="career-bar-fill" style="width:${pct}%"></div></div>
|
||||
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
|
||||
} else if (v.installed) {
|
||||
const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
|
||||
const main = active
|
||||
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
|
||||
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
|
||||
action = `<div class="flex items-center gap-2">
|
||||
${main}
|
||||
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
|
||||
</div>`;
|
||||
} else if (v.has_pack) {
|
||||
const err = dl.status === 'error'
|
||||
? `<div class="text-xs text-amber-400 mb-1">${esc(dl.error || 'Download failed')} — try again</div>` : '';
|
||||
action = `${err}<button data-career-download="${esc(v.id)}" class="career-btn career-btn-primary">Download venue pack</button>`;
|
||||
} else {
|
||||
action = '<div class="text-xs text-gray-500">Venue pack coming soon — plays with the standard stage for now</div>';
|
||||
}
|
||||
// Mirror pushCrowdManifest(): an override only counts while the pack
|
||||
// is installed — after a removal the badge must not claim a venue the
|
||||
// crowd layer can't use.
|
||||
const isActive = !locked && v.installed &&
|
||||
localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
|
||||
return `<div class="rounded-xl border ${locked ? 'border-gray-800 opacity-60' : 'border-gray-700'} bg-dark-700/40 p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-white">${esc(v.name)}${isActive ? ' <span class="text-cyan-400 text-xs">● playing here</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-400">${v.star_threshold} ★</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 flex-1">${esc(v.description)}</div>
|
||||
${action}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function starGlyphs(n) {
|
||||
let out = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
out += `<span class="${i < n ? 'on' : 'off'}">★</span>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderStars(state) {
|
||||
const list = $('career-star-list');
|
||||
const summary = $('career-star-summary');
|
||||
if (!list || !summary) return;
|
||||
const detail = state.star_detail || [];
|
||||
const tiers = [0, 0, 0, 0];
|
||||
for (const r of detail) tiers[r.stars]++;
|
||||
summary.textContent =
|
||||
`${tiers[3]}× 3★ · ${tiers[2]}× 2★ · ${tiers[1]}× 1★ · ${tiers[0]} unstarred`;
|
||||
if (!detail.length) {
|
||||
list.innerHTML = '<div class="text-xs text-gray-500">Play songs to start collecting stars — 60% accuracy earns the first one.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = detail.map((r) => {
|
||||
let hint = 'maxed';
|
||||
let close = '';
|
||||
if (r.next_star_at != null) {
|
||||
const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100;
|
||||
hint = `${gap.toFixed(0)}% to next ★`;
|
||||
if (gap <= 5) close = ' close';
|
||||
}
|
||||
return `<div class="career-star-row">
|
||||
<span class="stars">${starGlyphs(r.stars)}</span>
|
||||
<span class="song">${esc(r.title)}${r.artist ? ` <span class="artist">— ${esc(r.artist)}</span>` : ''}</span>
|
||||
<span class="hint${close}">best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
const host = $('career-venues');
|
||||
if (!host) return;
|
||||
$('career-stars-summary').textContent = `★ ${state.stars_total} total`;
|
||||
const next = state.venues.find((v) => !v.unlocked);
|
||||
const bar = $('career-progress-bar');
|
||||
const label = $('career-progress-label');
|
||||
if (next) {
|
||||
const prevThreshold = state.venues
|
||||
.filter((v) => v.unlocked)
|
||||
.reduce((m, v) => Math.max(m, v.star_threshold), 0);
|
||||
const span = Math.max(1, next.star_threshold - prevThreshold);
|
||||
const into = Math.max(0, state.stars_total - prevThreshold);
|
||||
bar.style.width = Math.min(100, Math.round((into / span) * 100)) + '%';
|
||||
label.textContent = `${state.stars_total} / ${next.star_threshold} ★ to unlock ${next.name}`;
|
||||
} else {
|
||||
bar.style.width = '100%';
|
||||
label.textContent = 'All venues unlocked — enjoy the arena.';
|
||||
}
|
||||
host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join('');
|
||||
renderStars(state);
|
||||
}
|
||||
|
||||
function schedulePoll(state) {
|
||||
clearTimeout(_pollTimer);
|
||||
if (state.venues.some((v) => (v.download || {}).status === 'running')) {
|
||||
_pollTimer = setTimeout(refresh, POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function announceUnlocks(state) {
|
||||
const unlocked = state.venues.filter((v) => v.unlocked).map((v) => v.id);
|
||||
if (_prevUnlockedIds) {
|
||||
for (const v of state.venues) {
|
||||
if (v.unlocked && !_prevUnlockedIds.includes(v.id)) {
|
||||
const sm = window.feedBack;
|
||||
if (sm && typeof sm.emit === 'function') {
|
||||
sm.emit('career:venue-unlocked', { id: v.id, name: v.name });
|
||||
}
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
window.fbNotify.show({
|
||||
big: true, icon: '🎤', accent: '#06B6D4',
|
||||
title: 'New venue unlocked!',
|
||||
message: `${v.name} — your crowd just got bigger.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_prevUnlockedIds = unlocked;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let state;
|
||||
try {
|
||||
state = await fetchState();
|
||||
} catch (_) {
|
||||
return; // server restarting; next trigger retries
|
||||
}
|
||||
_state = state;
|
||||
announceUnlocks(state);
|
||||
render(state);
|
||||
schedulePoll(state);
|
||||
pushCrowdManifest(state);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
const dlBtn = e.target.closest('[data-career-download]');
|
||||
const delBtn = e.target.closest('[data-career-delete]');
|
||||
const playBtn = e.target.closest('[data-career-play]');
|
||||
if (dlBtn) {
|
||||
fetch(`${API}/packs/${dlBtn.dataset.careerDownload}/download`, { method: 'POST' })
|
||||
.then(refresh);
|
||||
} else if (delBtn) {
|
||||
// Do NOT null _appliedManifestVenue here: pushCrowdManifest()
|
||||
// clears/replaces the crowd manifest precisely by seeing that the
|
||||
// applied venue is no longer among the installed ones.
|
||||
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
|
||||
.then(refresh);
|
||||
} else if (playBtn) {
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay);
|
||||
// Selecting a venue makes the Venue visualization the default;
|
||||
// remember what the user had so Leave venue can restore it.
|
||||
const cur = localStorage.getItem('vizSelection');
|
||||
if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur);
|
||||
localStorage.setItem('vizSelection', 'venue');
|
||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||
} catch (_) { /* ok */ }
|
||||
_appliedManifestVenue = null; // force manifest re-push
|
||||
refresh();
|
||||
} else if (e.target.closest('[data-career-unselect]')) {
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE);
|
||||
const prev = localStorage.getItem(PREV_VIZ_KEY);
|
||||
if (prev) {
|
||||
localStorage.setItem('vizSelection', prev);
|
||||
if (typeof window.setViz === 'function') window.setViz(prev);
|
||||
}
|
||||
} catch (_) { /* ok */ }
|
||||
// keep _appliedManifestVenue: pushCrowdManifest clears the crowd
|
||||
// manifest precisely by seeing it is still set with no venue left
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const screen = document.getElementById('plugin-career');
|
||||
if (screen) screen.addEventListener('click', onClick);
|
||||
const sm = window.feedBack;
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
// New song stats can add stars → thresholds may cross mid-session.
|
||||
sm.on('stats:recorded', () => refresh());
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="space-y-3 text-sm">
|
||||
<label class="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="text-gray-200 font-medium">Crowd sound reactions</span>
|
||||
<span class="block text-xs text-gray-500">Cheers when the crowd's mood rises, boos when it drops. Uses each venue's own recordings.</span>
|
||||
</span>
|
||||
<input type="checkbox" id="career-sfx-toggle" class="accent-cyan-500 w-4 h-4">
|
||||
</label>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var KEY = 'feedBack-venue-crowd-sfx';
|
||||
var box = document.getElementById('career-sfx-toggle');
|
||||
if (!box) return;
|
||||
try { box.checked = localStorage.getItem(KEY) === 'on'; } catch (e) { /* ok */ }
|
||||
box.addEventListener('change', function () {
|
||||
try { localStorage.setItem(KEY, box.checked ? 'on' : 'off'); } catch (e) { /* ok */ }
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"star_accuracy_thresholds": [
|
||||
0.6,
|
||||
0.75,
|
||||
0.85
|
||||
],
|
||||
"venues": [
|
||||
{
|
||||
"id": "bar",
|
||||
"name": "The Dive Bar",
|
||||
"description": "Sticky floors, a dozen regulars, and a PA that has seen better decades.",
|
||||
"star_threshold": 0,
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "club",
|
||||
"name": "Velvet Room",
|
||||
"description": "A proper club stage. People actually came to hear you.",
|
||||
"star_threshold": 50,
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "arena",
|
||||
"name": "Feedback Arena",
|
||||
"description": "Ten thousand seats. Try not to think about it.",
|
||||
"star_threshold": 150,
|
||||
"pack": null
|
||||
}
|
||||
]
|
||||
}
|
||||
+373
-8087
File diff suppressed because it is too large
Load Diff
@@ -519,7 +519,9 @@ window.feedBack.audio = Object.assign(window.feedBack.audio || {}, {
|
||||
readSongVolume: _readSongVolume,
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
|
||||
@@ -111,7 +111,9 @@
|
||||
|
||||
// Announce once after the document parses, so any listener wired during page
|
||||
// load can sync without special-casing (consumers may also just call get()).
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', announce, { once: true });
|
||||
} else {
|
||||
announce();
|
||||
|
||||
+1
-1
@@ -282,7 +282,7 @@ function createHighway() {
|
||||
// it's only "full resolution" when Quality is HD; otherwise it pins at the
|
||||
// chosen Quality. Read once here; live changes come through
|
||||
// api.setMinRenderScale(), surfaced as the "Min res" control next to Quality
|
||||
// in the player controls (static/index.html).
|
||||
// in the player controls (static/v3/index.html).
|
||||
hwState._autoScaleMin = (function () {
|
||||
const v = parseFloat(localStorage.getItem('highwayMinRenderScale'));
|
||||
return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN;
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FeedBack</title>
|
||||
<!-- Placeholder favicon — emoji SVG data URI. Swap for a real logo later. See #55. -->
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%F0%9F%8E%B8%3C/text%3E%3C/svg%3E">
|
||||
<!-- Tailwind utility classes are served from a prebuilt static
|
||||
stylesheet (regenerated by scripts/build-tailwind.sh). The old
|
||||
Play CDN (cdn.tailwindcss.com) JIT scanned the DOM ~1.8x/sec
|
||||
on the main thread, dropping ~26% of frames with the 3D
|
||||
highway running — see feedBack-desktop#110. Theme extensions
|
||||
(dark/accent/gold colors, Inter font) live in tailwind.config.js. -->
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/vendor/shepherd.css">
|
||||
<link rel="stylesheet" href="/static/tour-engine.css">
|
||||
<!-- Diagnostics console capture must wrap console.* before any other
|
||||
script logs anything; load it as early as possible. See
|
||||
docs/diagnostics-bundle-spec.md (feedBack#166). -->
|
||||
<script src="/static/diagnostics.js"></script>
|
||||
<script src="/static/capabilities.js"></script>
|
||||
<script src="/static/capabilities/library.js"></script>
|
||||
<script src="/static/capabilities/tuning.js"></script>
|
||||
<script src="/static/capabilities/working-tuning.js"></script>
|
||||
<script src="/static/capabilities/audio-session.js"></script>
|
||||
<script src="/static/capabilities/audio-effects.js"></script>
|
||||
<script src="/static/capabilities/playback.js"></script>
|
||||
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
|
||||
<script src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
</head>
|
||||
<body class="bg-dark-900 text-gray-200 font-display">
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav id="navbar" class="fixed top-0 w-full z-50 transition-all duration-300">
|
||||
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<div class="flex items-end gap-1.5">
|
||||
<a href="#" onclick="showScreen('home');return false" class="text-xl font-bold bg-gradient-to-r from-accent-light to-purple-400 bg-clip-text text-transparent">
|
||||
FeedBack
|
||||
</a>
|
||||
<span id="app-version" class="text-xs text-gray-600 mb-0.5"></span>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<a href="#" onclick="showScreen('home');return false" class="text-sm text-gray-400 hover:text-white transition">Library</a>
|
||||
<a href="#" onclick="showScreen('favorites');return false" class="text-sm text-gray-400 hover:text-white transition">Favorites</a>
|
||||
<a href="#" onclick="document.getElementById('upload-songs-file').click();return false" class="text-sm text-gray-400 hover:text-white transition">Upload</a>
|
||||
<span id="nav-plugins" class="contents"></span>
|
||||
<a href="#" onclick="showScreen('settings');return false" class="text-sm text-gray-400 hover:text-white transition">Settings</a>
|
||||
</div>
|
||||
<!-- Mobile menu -->
|
||||
<button onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" class="md:hidden text-gray-400">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="mobile-menu" class="hidden md:hidden bg-dark-800/95 backdrop-blur border-t border-gray-800">
|
||||
<div class="px-6 py-4 flex flex-col gap-3">
|
||||
<a href="#" onclick="showScreen('home');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Library</a>
|
||||
<a href="#" onclick="showScreen('favorites');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Favorites</a>
|
||||
<a href="#" onclick="document.getElementById('upload-songs-file').click();this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Upload</a>
|
||||
<span id="mobile-nav-plugins" class="flex flex-col gap-2 border-t border-b border-gray-800 py-2 my-1">
|
||||
<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>
|
||||
</span>
|
||||
<a href="#" onclick="showScreen('settings');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
|
||||
<div id="home" class="screen active">
|
||||
<!-- Library -->
|
||||
<section id="library-section" class="max-w-7xl mx-auto px-6 pt-24 pb-16">
|
||||
<div id="alpha-warning-banner" class="hidden mb-6 px-4 py-3 bg-amber-900/30 border border-amber-500/30 rounded-xl flex items-start gap-3" role="status">
|
||||
<span class="text-amber-400 text-lg leading-none mt-0.5" aria-hidden="true">⚠</span>
|
||||
<div class="text-sm text-amber-100">
|
||||
<strong class="text-amber-300">Heads up — this is an alpha build.</strong>
|
||||
Some things may be broken or change without warning. If you hit a bug, please file an issue. Thanks for trying it out!
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
|
||||
<div>
|
||||
<h2 id="lib-title" class="text-3xl font-bold text-white">Your Library</h2>
|
||||
<p class="text-gray-500 mt-1" id="lib-count"></p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full md:w-auto flex-wrap">
|
||||
<select id="lib-provider" onchange="setLibraryProvider(this.value)"
|
||||
aria-label="Library source"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Library source">
|
||||
<option value="local">My Library</option>
|
||||
</select>
|
||||
<!-- View toggle -->
|
||||
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<button id="view-grid-btn" onclick="setLibView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||
</button>
|
||||
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
|
||||
</button>
|
||||
<button id="view-folder-btn" onclick="setLibView('folder')" class="px-3 py-2.5 text-sm transition" title="Folder view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Grid controls -->
|
||||
<select id="lib-sort" onchange="sortLibrary()"
|
||||
class="lib-nontree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="artist">Artist A-Z</option>
|
||||
<option value="artist-desc">Artist Z-A</option>
|
||||
<option value="title">Title A-Z</option>
|
||||
<option value="title-desc">Title Z-A</option>
|
||||
<option value="recent">Recently Added</option>
|
||||
<option value="year-desc">Year (newest)</option>
|
||||
<option value="year">Year (oldest)</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
<option value="difficulty">Difficulty (easiest first)</option>
|
||||
<option value="difficulty-desc">Difficulty (hardest first)</option>
|
||||
</select>
|
||||
<!-- Format filter (shared) -->
|
||||
<select id="lib-format" onchange="sortLibrary()"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Filter by format">
|
||||
<option value="">All formats</option>
|
||||
<option value="sloppak">Feedpak</option>
|
||||
<option value="loose">Folder</option>
|
||||
</select>
|
||||
<!-- Tree controls -->
|
||||
<button onclick="toggleAllArtists(true)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
|
||||
<button onclick="toggleAllArtists(false)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
|
||||
<!-- Filters drawer toggle (feedBack#129) -->
|
||||
<button onclick="toggleLibFilters()" id="btn-lib-filters"
|
||||
class="bg-dark-700 border border-gray-800 hover:border-accent/40 rounded-xl px-4 py-2.5 text-sm text-gray-300 transition flex items-center gap-2"
|
||||
title="Filter by parts, tuning, lyrics">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h18M6 12h12M10 20h4"/></svg>
|
||||
<span>Filters</span>
|
||||
<span id="lib-filters-count" class="hidden bg-accent/30 text-accent-light text-xs font-semibold rounded-full px-1.5 py-0.5 min-w-[1.25rem] text-center">0</span>
|
||||
</button>
|
||||
<!-- Shared -->
|
||||
<input type="text" id="lib-filter" placeholder="Search songs..." oninput="filterLibrary()"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Active-filter chip row (only visible when filters are set, feedBack#129) -->
|
||||
<div id="lib-filter-chips" class="hidden flex flex-wrap gap-2 mb-5"></div>
|
||||
<div id="lib-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
<!-- Cards populated by JS -->
|
||||
</div>
|
||||
<div id="lib-tree" class="space-y-2 hidden">
|
||||
<!-- Tree populated by JS -->
|
||||
</div>
|
||||
<div id="lib-folder-tree" class="space-y-1 hidden">
|
||||
<!-- Folder tree populated by JS when Folders source is active -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Filters drawer (feedBack#129/#69/#22) ═════════════════════ -->
|
||||
<div id="lib-filter-overlay" class="fixed inset-0 bg-black/40 z-40 hidden"
|
||||
onclick="toggleLibFilters(false)"></div>
|
||||
<aside id="lib-filter-drawer"
|
||||
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-dark-800 border-l border-gray-800 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-white">Filters</h3>
|
||||
<button onclick="toggleLibFilters(false)" class="text-gray-500 hover:text-white" title="Close">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Arrangements</div>
|
||||
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
|
||||
<div id="filter-arrangements" class="flex flex-wrap gap-2"></div>
|
||||
</section>
|
||||
|
||||
<section id="filter-stems-section">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Stems <span class="text-gray-600 normal-case font-normal">(sloppak)</span></div>
|
||||
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
|
||||
<div id="filter-stems" class="flex flex-wrap gap-2"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Lyrics</div>
|
||||
<div id="filter-lyrics" class="flex flex-wrap gap-2"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
|
||||
<span>Tuning</span>
|
||||
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
|
||||
</summary>
|
||||
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-800">
|
||||
<button onclick="clearLibFilters()" class="text-sm text-gray-400 hover:text-white transition">Clear all</button>
|
||||
<button onclick="toggleLibFilters(false)" class="bg-accent hover:bg-accent-light px-4 py-2 rounded-lg text-sm font-medium text-white transition">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- ══ FAVORITES ════════════════════════════════════════════════════ -->
|
||||
<div id="favorites" class="screen">
|
||||
<section class="max-w-7xl mx-auto px-6 pt-24 pb-16">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-white">Favorites</h2>
|
||||
<p class="text-gray-500 mt-1" id="fav-count"></p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full md:w-auto flex-wrap">
|
||||
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<button id="fav-view-grid-btn" onclick="setFavView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||
</button>
|
||||
<button id="fav-view-tree-btn" onclick="setFavView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<select id="fav-sort" onchange="sortFavorites()"
|
||||
class="fav-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="artist">Artist A-Z</option>
|
||||
<option value="artist-desc">Artist Z-A</option>
|
||||
<option value="title">Title A-Z</option>
|
||||
<option value="title-desc">Title Z-A</option>
|
||||
<option value="recent">Recently Added</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
</select>
|
||||
<button onclick="toggleAllFavoriteArtists(true)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
|
||||
<button onclick="toggleAllFavoriteArtists(false)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
|
||||
<input type="text" id="fav-filter" placeholder="Search favorites..." oninput="filterFavorites()"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
|
||||
</div>
|
||||
</div>
|
||||
<div id="fav-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
</div>
|
||||
<div id="fav-tree" class="space-y-2 hidden">
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ══ Plugin screens injected dynamically by loadPlugins() ══════════ -->
|
||||
|
||||
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
|
||||
<div id="settings" class="screen">
|
||||
<div class="max-w-2xl mx-auto px-6 pt-24 pb-16">
|
||||
<button onclick="showScreen('home')" class="text-gray-500 hover:text-white text-sm mb-6 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Back
|
||||
</button>
|
||||
<h2 class="text-3xl font-bold text-white mb-8">Settings</h2>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- App Updates — Velopack auto-update, desktop only. Stays
|
||||
hidden in the plain web app; setupAppUpdates() unhides
|
||||
this block when window.feedBackDesktop.update exists,
|
||||
and shows a disabled "not available on Linux" fallback
|
||||
when running on Linux. -->
|
||||
<div id="app-updates-block" class="hidden border border-gray-800 rounded-xl bg-dark-800/40 p-5">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">App Updates</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block" for="app-update-channel">Update channel</label>
|
||||
<select id="app-update-channel"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="stable">Stable</option>
|
||||
<option value="rc">Release candidate</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button id="app-update-check-now"
|
||||
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
|
||||
Check for updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="app-update-status" class="text-xs text-gray-500 mt-3">Loading updater status…</p>
|
||||
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 mt-2">
|
||||
Auto-update is not available on Linux —
|
||||
<a href="https://github.com/got-feedback/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Core FeedBack settings ─────────────────────────────── -->
|
||||
<section>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">FeedBack</h3>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Library Folder Path</label>
|
||||
<div class="flex gap-3">
|
||||
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
|
||||
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)"
|
||||
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
|
||||
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
|
||||
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
|
||||
<span class="text-sm text-gray-300">Autoplay & auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
|
||||
<select id="default-arrangement"
|
||||
onchange="persistSetting('default_arrangement', this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="">Most notes (auto)</option>
|
||||
<option value="Lead">Lead</option>
|
||||
<option value="Rhythm">Rhythm</option>
|
||||
<option value="Bass">Bass</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Arrangement Names</label>
|
||||
<select id="arrangement-naming-mode"
|
||||
onchange="_onNamingModeChange(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="smart">Smart (Lead, Alt. Lead, Rhythm, Bass…)</option>
|
||||
<option value="legacy">Legacy (Combo, Bass)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="setting-av-offset" class="text-sm font-medium text-gray-400 mb-2 block">
|
||||
A/V Sync Offset: <span id="setting-av-offset-val">0</span> ms
|
||||
</label>
|
||||
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
|
||||
oninput="setAvOffsetMs(this.value)"
|
||||
class="w-full slider-input">
|
||||
<p class="text-xs text-gray-600 mt-1">Positive = audio plays ahead of visual notes; raise this value to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves on every change.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="demucs-server-url" class="text-sm font-medium text-gray-400 mb-2 block">Demucs Server (for stem separation)</label>
|
||||
<div class="flex gap-3">
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Optional. Run <a href="https://github.com/got-feedBack/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a machine with a GPU to offload stem splitting and avoid resource exhaustion on the host running FeedBack.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Library</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
|
||||
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
|
||||
<span id="rescan-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Backup</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
|
||||
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
|
||||
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
|
||||
<span id="backup-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</p>
|
||||
</div>
|
||||
<!-- ── Diagnostics (feedBack#166) ────────────────────── -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Diagnostics</label>
|
||||
<div class="grid grid-cols-2 gap-2 mb-3 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths & song names</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
|
||||
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
|
||||
<span id="diag-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default. Attach to GitHub issues; AI agents can parse the included <code>manifest.json</code>.</p>
|
||||
<div id="diag-preview" class="hidden mt-3 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto"></div>
|
||||
</div>
|
||||
<div id="settings-status" class="text-sm text-gray-500"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Plugin settings ─────────────────────────────────────── -->
|
||||
<section id="plugin-settings-area" class="hidden">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Plugins</h3>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Plugin Updates</label>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
|
||||
<span id="updates-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<div id="plugin-updates-list" class="space-y-2"></div>
|
||||
</div>
|
||||
<!-- Per-plugin collapsible sections injected here -->
|
||||
<div id="plugin-settings" class="space-y-3"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── About / Source / License (AGPL §13 disclosure) ──────── -->
|
||||
<section>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">About</h3>
|
||||
<div class="space-y-2 text-sm text-gray-400">
|
||||
<div>FeedBack <span id="app-version-about" class="text-gray-500"></span></div>
|
||||
<div>Licensed under <a id="about-license-link" href="https://github.com/got-feedback/feedBack/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a>.</div>
|
||||
<div><a id="about-source-link" href="https://github.com/got-feedback/feedBack" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a></div>
|
||||
<p class="text-xs text-gray-600 mt-2">FeedBack is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global audio element (outside player so it's always accessible) -->
|
||||
<audio id="audio" preload="auto"></audio>
|
||||
<script>
|
||||
// Web Audio API fallback for iOS WKWebView which can't play WAV via <audio>
|
||||
(function() {
|
||||
var _waCtx = null, _waSource = null, _waStartTime = 0, _waBuffer = null, _waPlaying = false, _waLoading = false;
|
||||
var audioEl = document.getElementById('audio');
|
||||
|
||||
window._webAudioFallback = {
|
||||
load: function(url, cb) {
|
||||
if (!url || _waLoading) return;
|
||||
_waLoading = true;
|
||||
if (!_waCtx) _waCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
console.log('[WebAudio] Loading: ' + url);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onload = function() {
|
||||
_waCtx.decodeAudioData(xhr.response, function(decoded) {
|
||||
_waBuffer = decoded;
|
||||
_waLoading = false;
|
||||
console.log('[WebAudio] Decoded: ' + decoded.duration.toFixed(1) + 's');
|
||||
if (cb) cb();
|
||||
}, function(e) {
|
||||
_waLoading = false;
|
||||
console.error('[WebAudio] Decode error:', e);
|
||||
});
|
||||
};
|
||||
xhr.onerror = function() { _waLoading = false; };
|
||||
xhr.send();
|
||||
},
|
||||
play: function() {
|
||||
if (!_waBuffer || !_waCtx) return false;
|
||||
this.stop();
|
||||
if (_waCtx.state === 'suspended') _waCtx.resume();
|
||||
_waSource = _waCtx.createBufferSource();
|
||||
_waSource.buffer = _waBuffer;
|
||||
// AudioBufferSourceNode has no preservesPitch equivalent so changing playbackRate here also changes pitch
|
||||
_waSource.playbackRate.value = audioEl.playbackRate || 1;
|
||||
_waSource.connect(_waCtx.destination);
|
||||
_waStartTime = _waCtx.currentTime;
|
||||
_waSource.start(0);
|
||||
_waPlaying = true;
|
||||
console.log('[WebAudio] Playing');
|
||||
return true;
|
||||
},
|
||||
stop: function() {
|
||||
if (_waSource) { try { _waSource.stop(); } catch(e){} _waSource = null; }
|
||||
_waPlaying = false;
|
||||
},
|
||||
getTime: function() {
|
||||
if (!_waPlaying || !_waCtx) return 0;
|
||||
return _waCtx.currentTime - _waStartTime;
|
||||
},
|
||||
isActive: function() { return _waPlaying; },
|
||||
isReady: function() { return !!_waBuffer; },
|
||||
getDuration: function() { return _waBuffer ? _waBuffer.duration : 0; }
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ══ PLAYER ═════════════════════════════════════════════════════════ -->
|
||||
<div id="player" class="screen">
|
||||
<canvas id="highway"></canvas>
|
||||
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between px-4 py-3 pointer-events-none z-10">
|
||||
<div class="text-sm">
|
||||
<span id="hud-artist" class="text-gray-300"></span> — <span id="hud-title" class="text-white font-semibold"></span>
|
||||
<br><span id="hud-arrangement" class="text-gray-500 text-xs"></span>
|
||||
<br><span id="hud-tuning" class="text-gray-500 text-xs"></span>
|
||||
<br><span id="hud-tuning-targets" class="text-gray-500 text-xs"></span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div id="hud-time" class="text-sm text-gray-400"></div>
|
||||
<div id="hud-avoffset" class="text-xs text-gray-500 tabular-nums hidden" title="A/V offset — [ and ] to adjust, Shift for ±50 ms">A/V 0 ms</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #section-practice-bar lives OUTSIDE #player-controls on purpose: its
|
||||
nested chip <button>s would otherwise be matched by plugins' legacy
|
||||
`#player-controls`-scoped `button:last-child` injector anchor, making
|
||||
insertBefore throw (the node isn't a direct child) and aborting the
|
||||
shared playSong wrapper chain. The #player-footer wrapper keeps it
|
||||
visually directly above the transport row; margin-top:auto moves here
|
||||
from #player-controls so the whole footer still pins to the bottom. -->
|
||||
<div id="player-footer">
|
||||
<!-- Section Practice is collapsed behind a single pill; the multi-row bar
|
||||
is a popover opened from the pill (toggleSectionPracticePopover). The
|
||||
pill + popover live in #player-footer (NOT #player-controls) so the
|
||||
popover's nested chip <button>s can't be matched by a plugin's
|
||||
`#player-controls > button:last-child` injector anchor. -->
|
||||
<div id="section-practice-control" class="section-practice-control section-practice-control--hidden">
|
||||
<button type="button" id="section-practice-pill" class="section-practice-pill"
|
||||
aria-haspopup="dialog" aria-expanded="false" aria-controls="section-practice-bar"
|
||||
aria-label="Section practice"
|
||||
onclick="toggleSectionPracticePopover()" title="Section practice">
|
||||
<span class="section-practice-pill-icon" aria-hidden="true">🎯</span>
|
||||
<span class="section-practice-pill-text">Practice</span>
|
||||
<span class="section-practice-pill-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div id="section-practice-bar" class="section-practice-bar" role="dialog" aria-label="Section practice">
|
||||
<div class="section-practice-row">
|
||||
<label class="section-practice-mode-wrap" title="Loop the selected section until turned off">
|
||||
<input type="checkbox" id="section-practice-mode" onchange="onSectionPracticeModeChange()">
|
||||
<span class="section-practice-mode-text">Practice Section</span>
|
||||
</label>
|
||||
<span class="section-practice-label">Sections:</span>
|
||||
<div id="section-practice-scroll" class="section-practice-scroll" role="toolbar" aria-label="Section selection"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-controls" class="flex items-center gap-2 px-4 py-2.5 bg-dark-800 border-t border-gray-800/50 flex-wrap">
|
||||
<button onclick="seekBy(-5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Back 5s" aria-label="Seek Back 5s"><img src="/static/svg/rw.svg" class="button-icon-svg" alt="" aria-hidden="true" /> 5s</button>
|
||||
<button type="button" onclick="restartCurrentSong()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Restart song" aria-label="Restart song">↺</button>
|
||||
<button onclick="togglePlay()" id="btn-play" class="px-4 py-1.5 bg-accent hover:bg-accent-light rounded-lg text-xs font-semibold text-white transition" aria-label="Play" title="Play" aria-pressed="false"><img src="/static/svg/play.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
|
||||
<button onclick="seekBy(5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Forward 5s" aria-label="Seek Forward 5s">5s <img src="/static/svg/ff.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
|
||||
<select id="arr-select" onchange="changeArrangement(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[130px]"></select>
|
||||
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="w-8 h-8 inline-flex items-center justify-center bg-dark-600 border border-gray-700 hover:bg-dark-500 rounded-lg text-xs text-gray-400 transition" title="Select an arrangement to make it the default">☆</button>
|
||||
<input type="range" id="speed-slider" min="15" max="150" value="100" step="5" oninput="setSpeed(this.value/100)" class="w-20 accent-accent slider-input">
|
||||
<span id="speed-label" class="text-xs text-gray-500 w-10">1.0x</span>
|
||||
<span id="mastery-slider-label" class="text-xs text-gray-500 ml-1">Difficulty</span>
|
||||
<input type="range" id="mastery-slider" min="0" max="100" value="100" step="5" oninput="setMastery(this.value)" class="w-20 accent-accent slider-input" title="Master difficulty — low = simpler chart, high = full" aria-labelledby="mastery-slider-label">
|
||||
<span id="mastery-label" class="text-xs text-gray-500 w-10">100%</span>
|
||||
<span id="player-av-offset-slider-label" class="text-xs text-gray-500 ml-1">A/V sync offset (ms)</span>
|
||||
<input type="range" id="player-av-offset-slider" min="-1000" max="1000" value="0" step="1" oninput="setAvOffsetMs(this.value)" class="w-20 accent-accent slider-input" title="A/V sync offset (ms) — positive = audio plays ahead of visuals. [ and ] adjust ±10 ms (Shift = ±50). Double-click to reset." ondblclick="setAvOffsetMs(0)" aria-labelledby="player-av-offset-slider-label">
|
||||
<span id="player-av-offset-label" class="text-xs text-gray-500 w-12 tabular-nums">+0ms</span>
|
||||
<div id="mixer-control">
|
||||
<div id="mixer-anchor" class="relative">
|
||||
<button id="btn-mixer" type="button" onclick="window.feedBack.audio.toggleMixer()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" aria-haspopup="true" aria-expanded="false" aria-controls="mixer-popover" title="Audio mixer">Mixer ▾</button>
|
||||
<div id="mixer-popover" class="hidden absolute right-0 bottom-full mb-2 z-50 bg-dark-700 border border-gray-800 rounded-xl shadow-xl" role="group" aria-label="Audio mixer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="highway.toggleLyrics()" id="btn-lyrics" class="px-3 py-1.5 bg-purple-900/40 hover:bg-purple-900/60 rounded-lg text-xs text-purple-300 transition">Lyrics ✓</button>
|
||||
<select id="quality-select" onchange="highway.setRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="1">HD</option>
|
||||
<option value="0.75">Medium</option>
|
||||
<option value="0.5">Low</option>
|
||||
</select>
|
||||
<select id="min-scale-select" aria-label="Minimum auto resolution" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
|
||||
<option value="0.25">Min res: 25%</option>
|
||||
<option value="0.5">Min res: 50%</option>
|
||||
<option value="0.75">Min res: 75%</option>
|
||||
<option value="1">Min res: Full</option>
|
||||
</select>
|
||||
<span id="viz-picker-label" class="text-xs text-gray-500 ml-1 sr-only">Visualization</span>
|
||||
<select id="viz-picker" onchange="setViz(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" aria-labelledby="viz-picker-label" title="Visualization">
|
||||
<option value="auto">Auto (match arrangement)</option>
|
||||
<option value="default">Classic 2D Highway</option>
|
||||
<!-- Additional entries populated on load from /api/plugins (feedBack#36).
|
||||
The bundled 3D Highway plugin (plugins/highway_3d/) registers as
|
||||
`highway_3d` and is the default selection on fresh installs — see
|
||||
_populateVizPicker() in app.js. -->
|
||||
</select>
|
||||
<span class="text-gray-700 mx-1">|</span>
|
||||
<button onclick="setLoopStart()" id="btn-loop-a" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop start at current time">A</button>
|
||||
<button onclick="setLoopEnd()" id="btn-loop-b" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop end at current time">B</button>
|
||||
<button onclick="saveCurrentLoop()" id="btn-loop-save" class="px-3 py-1.5 bg-dark-600 hover:bg-green-900/50 rounded-lg text-xs text-gray-300 transition hidden" title="Save this loop">Save</button>
|
||||
<button onclick="clearLoop()" id="btn-loop-clear" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-500 transition hidden" title="Clear loop">✕</button>
|
||||
<span id="loop-label" class="text-xs text-gray-600"></span>
|
||||
<select id="saved-loops" onchange="loadSavedLoop(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[160px] hidden">
|
||||
<option value="">Saved Loops</option>
|
||||
</select>
|
||||
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="px-2 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-500 hover:text-red-400 transition hidden" title="Delete selected loop">✕</button>
|
||||
<!-- Editor ⇄ 3D Highway round-trip. "Edit region" opens the Song Editor
|
||||
scrolled to the active loop (or the section at the playhead).
|
||||
"↩ Editor" returns to the editing position you came from; it only
|
||||
appears after a Loop-in-3D handoff. Both are hidden when the editor
|
||||
plugin isn't loaded (state managed by _updateEditRegionBtn). -->
|
||||
<button onclick="editRegionInEditor()" id="btn-edit-region" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
|
||||
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Return to the editor where you left off">↩ Editor</button>
|
||||
<button onclick="showScreen('home')" class="ml-auto px-3 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-400 hover:text-red-400 transition">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/highway.js"></script>
|
||||
<script src="/static/vendor/lottie.min.js"></script>
|
||||
<script src="/static/lottie-api.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/audio-mixer.js"></script>
|
||||
<script src="/static/vendor/shepherd.min.js"></script>
|
||||
<script src="/static/tour-engine.js"></script>
|
||||
<script>
|
||||
// Navbar scroll effect
|
||||
window.addEventListener('scroll', () => {
|
||||
const nav = document.getElementById('navbar');
|
||||
if (window.scrollY > 50) {
|
||||
nav.classList.add('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
|
||||
} else {
|
||||
nav.classList.remove('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
// The one <audio> element the whole app plays through.
|
||||
//
|
||||
// This exists so that code carved out of app.js can reach the player without
|
||||
// importing app.js back — which would close a cycle and fail the import-x/no-cycle
|
||||
// gate. It is the same handle app.js has always held (`document.getElementById`
|
||||
// on the element in the shell), just given a home of its own.
|
||||
//
|
||||
// It is deliberately a `const`, and it is never reassigned anywhere in core — so a
|
||||
// read-only import binding is exactly right, and no state container is needed.
|
||||
// (Contrast the reassigned scalars — isPlaying, _avOffsetMs, … — which cannot be
|
||||
// shared this way, because an imported binding cannot be written to.)
|
||||
//
|
||||
// Module scripts evaluate after the HTML is parsed, so the element is already in
|
||||
// the document by the time this runs. app.js is loaded as <script type="module">,
|
||||
// and its imports evaluate before its body — the same point at which app.js used
|
||||
// to run this exact lookup itself.
|
||||
export const audio = document.getElementById('audio');
|
||||
@@ -0,0 +1,389 @@
|
||||
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
|
||||
// shares its lifecycle and timers.
|
||||
//
|
||||
// The third slice out of app.js's strongly-connected core, and the first that had to
|
||||
// WRITE shared state rather than just read it. It starts and stops playback, so it sets
|
||||
// `isPlaying` and `lastAudioTime`. An imported binding is read-only — `isPlaying = true`
|
||||
// throws — which is exactly why those two scalars were lifted onto the container in
|
||||
// ./player-state.js. Every earlier slice only READ what it shared, so a getter hook
|
||||
// sufficed; this one could not.
|
||||
//
|
||||
// It imports the loop module directly (setLoop / loopA / loopB — a count-in that starts
|
||||
// inside an A-B loop must begin at A). Nothing imports count-in back: app.js and
|
||||
// section-practice both reach it through the host seam, so the graph stays acyclic.
|
||||
//
|
||||
// app.js's autoplay path used to reach IN and set the credits timers itself. It cannot
|
||||
// now, and it should not have to — so the module exports the OPERATIONS instead
|
||||
// (armCreditsHideOnPlay, scheduleCreditsHide, holdCreditsThen, isCountingIn) and owns
|
||||
// its own timer invariants. Same reason section-practice grew resetSelection().
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.js';
|
||||
import { loopA, loopB, setLoop } from './loops.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Count-in click sound (Web Audio API) ────────────────────────────────
|
||||
let _audioCtx = null;
|
||||
export function playClick(high = false) {
|
||||
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = _audioCtx.createOscillator();
|
||||
const gain = _audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(_audioCtx.destination);
|
||||
osc.frequency.value = high ? 1200 : 800;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.5, _audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.08);
|
||||
osc.start(_audioCtx.currentTime);
|
||||
osc.stop(_audioCtx.currentTime + 0.08);
|
||||
}
|
||||
|
||||
let _countingIn = false;
|
||||
let _countOverlay = null;
|
||||
// Generation token so teardown can cancel an in-progress count-in. Each
|
||||
// startCountIn() captures the gen at entry; rewindStep, the loop-wrap
|
||||
// then-callback, and beginCount's tick all bail when their captured gen
|
||||
// no longer matches. Bumped by _cancelCountIn().
|
||||
let _countInGen = 0;
|
||||
let _countInTimer = null;
|
||||
let _countInRaf = 0;
|
||||
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
|
||||
// highway when a song is loaded, alongside the count-in. Torn down together
|
||||
// with the count-in via _cancelCountIn().
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_HOLD_MS = 3000;
|
||||
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
|
||||
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
|
||||
// a count-in handoff that never plays). This hard cap guarantees the credits
|
||||
// never linger over the highway. Generous enough to outlast a normal count-in.
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
export function _cancelCountIn() {
|
||||
_countInGen++;
|
||||
_countingIn = false;
|
||||
hideCountOverlay();
|
||||
// The credits overlay rides the count-in lifecycle (and its no-count-in
|
||||
// hold timer), so a teardown — leaving the player, loading another song —
|
||||
// must clear it too, or it lingers on the next screen.
|
||||
hideSongCreditsOverlay();
|
||||
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
|
||||
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
|
||||
}
|
||||
|
||||
export function showCountOverlay(n) {
|
||||
if (!_countOverlay) {
|
||||
_countOverlay = document.createElement('div');
|
||||
_countOverlay.className = 'fixed inset-0 z-[100] flex items-center justify-center pointer-events-none';
|
||||
document.body.appendChild(_countOverlay);
|
||||
}
|
||||
_countOverlay.innerHTML = `<span class="text-9xl font-black text-white/30">${n}</span>`;
|
||||
}
|
||||
|
||||
export function hideCountOverlay() {
|
||||
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
|
||||
}
|
||||
|
||||
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
|
||||
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
|
||||
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
|
||||
const _CREDIT_ROLE_VERBS = {
|
||||
charter: 'Charted by',
|
||||
transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by',
|
||||
editor: 'Edited by',
|
||||
mixer: 'Mixed by',
|
||||
engineer: 'Engineered by',
|
||||
proofreader: 'Proofread by',
|
||||
};
|
||||
|
||||
function _creditLineLabel(role) {
|
||||
if (!role) return '';
|
||||
const key = String(role).trim().toLowerCase();
|
||||
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
|
||||
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
|
||||
}
|
||||
|
||||
// Show the feedpak contributor credits over the highway. `authors` is the
|
||||
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
|
||||
// Anchored to the lower third (bottom-center) so it never collides with the
|
||||
// vertically-centered count-in number, and pointer-events-none so it never
|
||||
// intercepts clicks. No-op when there are no contributors to show.
|
||||
export function showSongCreditsOverlay(authors) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||
if (!_creditsOverlay) {
|
||||
_creditsOverlay = document.createElement('div');
|
||||
_creditsOverlay.className = 'song-credits-overlay';
|
||||
document.body.appendChild(_creditsOverlay);
|
||||
}
|
||||
// Build via DOM + textContent — author names are untrusted pack data and
|
||||
// must never be interpolated as HTML.
|
||||
_creditsOverlay.replaceChildren();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'song-credits-card';
|
||||
|
||||
const eyebrow = document.createElement('div');
|
||||
eyebrow.className = 'song-credits-eyebrow';
|
||||
eyebrow.textContent = 'Credits';
|
||||
card.appendChild(eyebrow);
|
||||
|
||||
const title = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.title) || '';
|
||||
if (title) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'song-credits-heading';
|
||||
heading.textContent = title;
|
||||
card.appendChild(heading);
|
||||
}
|
||||
|
||||
for (const a of authors) {
|
||||
if (!a || !a.name) continue;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'song-credits-line';
|
||||
const label = _creditLineLabel(a.role);
|
||||
if (label) {
|
||||
const lab = document.createElement('span');
|
||||
lab.className = 'song-credits-role';
|
||||
lab.textContent = label + ' ';
|
||||
row.appendChild(lab);
|
||||
}
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'song-credits-name';
|
||||
nm.textContent = a.name;
|
||||
row.appendChild(nm);
|
||||
card.appendChild(row);
|
||||
}
|
||||
_creditsOverlay.appendChild(card);
|
||||
// Arm the backstop so the overlay self-clears even if playback never starts
|
||||
// / never emits song:play. song:play (or any teardown) clears it earlier.
|
||||
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
|
||||
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
|
||||
}
|
||||
|
||||
export function hideSongCreditsOverlay() {
|
||||
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
|
||||
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
|
||||
if (_creditsHideOnPlay) {
|
||||
window.feedBack.off('song:play', _creditsHideOnPlay);
|
||||
_creditsHideOnPlay = null;
|
||||
}
|
||||
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
|
||||
}
|
||||
|
||||
export async function startCountIn(opts = {}) {
|
||||
if (_countingIn) return;
|
||||
_countingIn = true;
|
||||
// Snapshot the current gen so every delayed callback (rewind frames,
|
||||
// post-seek then, count-in ticks, post-count play) can bail if a
|
||||
// teardown bumped the gen mid-flight via _cancelCountIn().
|
||||
const gen = _countInGen;
|
||||
const immediate = !!opts.immediate;
|
||||
if (window._juceMode) {
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
|
||||
// Section-practice entry: already at loop A after setLoop(); skip the
|
||||
// B→A rewind animation used on loop wrap and go straight to clicks.
|
||||
if (immediate) {
|
||||
if (loopA === null || loopB === null) {
|
||||
_countingIn = false;
|
||||
return;
|
||||
}
|
||||
S.lastAudioTime = loopA;
|
||||
highway.setTime(loopA);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
}
|
||||
beginCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewind animation: sweep highway time from B to A
|
||||
const rewindDuration = 400; // ms
|
||||
const rewindStart = performance.now();
|
||||
const fromTime = loopB;
|
||||
const toTime = loopA;
|
||||
|
||||
function rewindStep(now) {
|
||||
if (gen !== _countInGen) return; // teardown mid-rewind
|
||||
const elapsed = now - rewindStart;
|
||||
const t = Math.min(elapsed / rewindDuration, 1);
|
||||
// Ease out quad
|
||||
const eased = 1 - (1 - t) * (1 - t);
|
||||
const currentT = fromTime + (toTime - fromTime) * eased;
|
||||
highway.setTime(currentT);
|
||||
if (t < 1) {
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
} else {
|
||||
_countInRaf = 0;
|
||||
// Rewind done — set final position and start count.
|
||||
// Await the JUCE seek so the engine has repositioned before
|
||||
// we start the click track (HTML5 path is synchronous).
|
||||
_audioSeek(loopA, 'loop-wrap').then((r) => {
|
||||
if (gen !== _countInGen) return; // teardown during seek
|
||||
// Abort the loop restart in two cases:
|
||||
// 1. Cancelled (player torn down): don't beginCount on a
|
||||
// new session.
|
||||
// 2. Off-target landing (JUCE rollback / clamp far from
|
||||
// loopA): proceeding would emit loop:restart and start
|
||||
// a count-in from the wrong position. Audio is at
|
||||
// r.from / r.to, which is not where the loop wants to
|
||||
// resume — better to drop this iteration than play out
|
||||
// of sync.
|
||||
// 50 ms tolerance: well within JUCE's normal seek precision
|
||||
// but tight enough to catch a real rollback or no-op.
|
||||
if (!r.completed || Math.abs(r.to - loopA) > 0.05) {
|
||||
// startCountIn paused audio at entry but left isPlaying
|
||||
// alone — beginCount would have set it on resume. On
|
||||
// abort, sync the transport: audio is paused, so
|
||||
// isPlaying must reflect that and the button + plugin
|
||||
// host must agree.
|
||||
_countingIn = false;
|
||||
if (S.isPlaying) {
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Use the verified post-seek clock for the chart so audio
|
||||
// and chart stay in sync if JUCE clamped to slightly
|
||||
// before/after loopA. The loop:restart event keeps `time:
|
||||
// loopA` because subscribers treat that as the semantic
|
||||
// marker for "new iteration starts at A", not the actual
|
||||
// audio position.
|
||||
S.lastAudioTime = r.to;
|
||||
highway.setTime(r.to);
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
beginCount();
|
||||
});
|
||||
}
|
||||
}
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
|
||||
function beginCount() {
|
||||
const bpm = highway.getBPM(loopA);
|
||||
const beatInterval = 60 / bpm;
|
||||
let count = 0;
|
||||
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
if (window._juceMode) {
|
||||
jucePlayer.play().then((started) => {
|
||||
if (gen !== _countInGen) return; // teardown during play start
|
||||
if (!started) return;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}).catch((err) => console.error('[app] jucePlayer.play error:', err));
|
||||
} else {
|
||||
audio.play().then(() => {
|
||||
if (gen !== _countInGen) return;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
}).catch((err) => {
|
||||
if (gen !== _countInGen) return;
|
||||
// An engine reroute's deliberate pause aborts this play()
|
||||
// while playback continues on JUCE — don't reset the
|
||||
// button (mirrors the togglePlay guard).
|
||||
if (window._juceRerouteInProgress) return;
|
||||
// Same rationale as togglePlay: don't claim playback
|
||||
// started if the Promise rejected.
|
||||
console.error('[app] audio.play() rejected after count-in:', err);
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
playClick(count === 1);
|
||||
_countInTimer = setTimeout(tick, beatInterval * 1000);
|
||||
}
|
||||
_countInTimer = setTimeout(tick, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
|
||||
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
|
||||
// overlay + click + gen-token cancellation, but counts from the song's current
|
||||
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
|
||||
// coupled (early-returns when loopA/loopB are null), so this is a sibling
|
||||
// rather than an overload. Hands off to togglePlay() once the count completes.
|
||||
export async function startSongCountIn() {
|
||||
if (_countingIn) return;
|
||||
_countingIn = true;
|
||||
// Snapshot the gen so a teardown (showScreen/playSong calls _cancelCountIn)
|
||||
// bumps it and every delayed callback below bails.
|
||||
const gen = _countInGen;
|
||||
if (window._juceMode) {
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
const startT = S.lastAudioTime || 0;
|
||||
let bpm = highway.getBPM(startT);
|
||||
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
|
||||
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
|
||||
const beatInterval = 60 / bpm;
|
||||
let count = 0;
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||
// updates the button, and emits song:play/resume for plugins.
|
||||
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
playClick(count === 1);
|
||||
_countInTimer = setTimeout(tick, beatInterval * 1000);
|
||||
}
|
||||
// First beat after a short lead-in, matching the loop count-in's 500 ms.
|
||||
_countInTimer = setTimeout(tick, 500);
|
||||
}
|
||||
|
||||
// ── Operations app.js's autoplay path used to perform by reaching in ────────
|
||||
// It used to assign _creditsTimer / _creditsHideOnPlay directly. Imported bindings are
|
||||
// read-only, and the module should own its own timer invariants anyway.
|
||||
|
||||
/** Is a count-in running? app.js's timeupdate handler suppresses highway sync during one. */
|
||||
export function isCountingIn() {
|
||||
return _countingIn;
|
||||
}
|
||||
|
||||
/** Dismiss the credits the moment real playback begins. Fires once. */
|
||||
export function armCreditsHideOnPlay() {
|
||||
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
|
||||
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
|
||||
}
|
||||
|
||||
/** Let the credits dwell, then clear them. Used when autoplay-exit is disabled. */
|
||||
export function scheduleCreditsHide() {
|
||||
_creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
|
||||
}
|
||||
|
||||
/** Let the credits dwell, then run `then` (the autoplay start). */
|
||||
export function holdCreditsThen(then) {
|
||||
_creditsTimer = setTimeout(() => { _creditsTimer = null; then(); }, _CREDITS_HOLD_MS);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// The diagnostics-bundle export — the Settings "Export diagnostics" flow.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
// It snapshots the browser-only state (console ring buffer, hardware probe,
|
||||
// localStorage, ua) via window.feedBack.diagnostics, POSTs it to
|
||||
// /api/diagnostics/export with the user's include/redact toggles, and streams the
|
||||
// returned zip to disk. Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
|
||||
//
|
||||
// Everything except the two entry points is module-private — the preview
|
||||
// renderer, the file-label table, and the byte/HTML formatters are used nowhere
|
||||
// else in core.
|
||||
|
||||
//
|
||||
// Companion to Settings export but for troubleshooting bug reports.
|
||||
// Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
|
||||
//
|
||||
// Frontend's job is to:
|
||||
// 1. Snapshot the browser-only state (console ring buffer, hardware
|
||||
// probe, localStorage, ua) via window.feedBack.diagnostics.
|
||||
// 2. POST it to /api/diagnostics/export with the user's include /
|
||||
// redact toggles.
|
||||
// 3. Stream the returned zip to disk.
|
||||
|
||||
function _diagIncludeFromUI() {
|
||||
const v = (id) => document.getElementById(id)?.checked !== false;
|
||||
return {
|
||||
system: v('diag-incl-system'),
|
||||
hardware: v('diag-incl-hardware'),
|
||||
logs: v('diag-incl-logs'),
|
||||
console: v('diag-incl-console'),
|
||||
plugins: v('diag-incl-plugins'),
|
||||
};
|
||||
}
|
||||
|
||||
function _diagRedactFromUI() {
|
||||
const el = document.getElementById('diag-redact');
|
||||
return el ? !!el.checked : true;
|
||||
}
|
||||
|
||||
// Map raw file paths inside the bundle to plain-English labels +
|
||||
// descriptions for the preview UI. Only paths that show up in
|
||||
// previews need entries — unknown paths fall back to the path itself.
|
||||
const _DIAG_FILE_LABELS = {
|
||||
'system/version.json': { label: 'App version', desc: 'FeedBack version, Python, OS' },
|
||||
'system/env.json': { label: 'Environment', desc: 'Allowlisted env vars (LOG_LEVEL, etc.). No secrets.' },
|
||||
'system/hardware.json': { label: 'Hardware (server-side)', desc: 'CPU, RAM, GPU. In Docker this reflects the container, not the host.' },
|
||||
'system/plugins.json': { label: 'Plugins', desc: 'Loaded plugins + git commit + orphan detection.' },
|
||||
'logs/server.log': { label: 'Server log', desc: 'Tail of LOG_FILE (last ~5 MB).' },
|
||||
'logs/server.log.meta.json': { label: 'Log metadata', desc: 'Log file path, size, rotation info.' },
|
||||
'client/console.json': { label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' },
|
||||
'client/hardware.json': { label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' },
|
||||
'client/local_storage.json': { label: 'Browser storage', desc: 'localStorage contents (preferences).' },
|
||||
'client/ua.json': { label: 'User agent', desc: 'Browser, screen, page URL.' },
|
||||
};
|
||||
|
||||
function _formatBytes(n) {
|
||||
if (!n || n < 1024) return (n || 0) + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function _escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function _renderDiagPreview(data) {
|
||||
const m = data.manifest || {};
|
||||
const files = m.files || [];
|
||||
const groups = { system: [], logs: [], client: [], plugins: [], other: [] };
|
||||
for (const f of files) {
|
||||
const top = (f.path || '').split('/')[0];
|
||||
(groups[top] || groups.other).push(f);
|
||||
}
|
||||
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
||||
const include = _diagIncludeFromUI();
|
||||
const redact = _diagRedactFromUI();
|
||||
|
||||
const sections = [];
|
||||
// Per-file `summary` (server-derived) → human one-liner.
|
||||
function _summaryLine(path, summary) {
|
||||
if (!summary || typeof summary !== 'object') return '';
|
||||
if (path === 'system/plugins.json') {
|
||||
const loaded = summary.loaded_count || 0;
|
||||
const orphans = summary.orphan_count || 0;
|
||||
const orphPart = orphans ? ` · <span class="text-amber-400">${orphans} orphan${orphans === 1 ? '' : 's'}</span>` : '';
|
||||
return `${loaded} plugin${loaded === 1 ? '' : 's'} loaded${orphPart}`;
|
||||
}
|
||||
if (path === 'client/console.json') {
|
||||
const total = summary.entry_count || 0;
|
||||
const lvl = summary.by_level || {};
|
||||
const parts = [];
|
||||
for (const k of ['error','warn','info','log','debug']) {
|
||||
if (lvl[k]) parts.push(`${lvl[k]} ${k}`);
|
||||
}
|
||||
return `${total} entries${parts.length ? ' (' + parts.join(', ') + ')' : ''}`;
|
||||
}
|
||||
if (path === 'system/hardware.json') {
|
||||
const bits = [];
|
||||
if (summary.cpu_brand) bits.push(summary.cpu_brand);
|
||||
if (summary.cores_logical) bits.push(`${summary.cores_logical} cores`);
|
||||
if (summary.gpu_count) bits.push(`${summary.gpu_count} GPU`);
|
||||
if (summary.runtime) bits.push(`runtime: ${summary.runtime}`);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
if (path === 'client/hardware.json') {
|
||||
const bits = [];
|
||||
if (summary.runtime) bits.push(summary.runtime);
|
||||
if (summary.webgl_renderer) bits.push(summary.webgl_renderer);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
if (path === 'client/local_storage.json') {
|
||||
return `${summary.key_count || 0} keys`;
|
||||
}
|
||||
if (path === 'system/version.json') {
|
||||
const bits = [];
|
||||
if (summary.feedBack) bits.push(`feedBack ${summary.feedBack}`);
|
||||
if (summary.python) bits.push(`python ${summary.python}`);
|
||||
if (summary.os) bits.push(summary.os);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pushSection(title, list, emptyHint) {
|
||||
if (!list.length) {
|
||||
if (emptyHint) {
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div><div class="text-gray-500">${_escapeHtml(emptyHint)}</div></div>`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rows = list.map(f => {
|
||||
const meta = _DIAG_FILE_LABELS[f.path] || { label: f.path, desc: '' };
|
||||
const summary = _summaryLine(f.path, f.summary);
|
||||
const summaryHtml = summary
|
||||
? `<div class="text-accent-light text-[10px] mt-0.5">${summary}</div>`
|
||||
: '';
|
||||
return `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
|
||||
<div class="min-w-0">
|
||||
<div class="text-gray-200">${_escapeHtml(meta.label)}</div>
|
||||
<div class="text-gray-500 text-[10px]">${_escapeHtml(meta.desc)}</div>
|
||||
${summaryHtml}
|
||||
</div>
|
||||
<div class="text-gray-400 text-right whitespace-nowrap">${_escapeHtml(_formatBytes(f.size))}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div>${rows}</div>`);
|
||||
}
|
||||
|
||||
pushSection('System', groups.system, include.system ? '' : 'Skipped (toggle off)');
|
||||
pushSection('Server logs', groups.logs, include.logs
|
||||
? 'No log file configured — set LOG_FILE env var to include server logs.'
|
||||
: 'Skipped (toggle off)');
|
||||
pushSection('Plugin diagnostics', groups.plugins, include.plugins
|
||||
? 'No plugins have opted in to diagnostics.'
|
||||
: 'Skipped (toggle off)');
|
||||
|
||||
// Client section preview is a server-side estimate only — actual
|
||||
// client/* payloads are added at Export time after the browser
|
||||
// snapshots. Show what WILL be added, not file sizes.
|
||||
const clientLines = [];
|
||||
if (include.console) clientLines.push({ label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' });
|
||||
if (include.hardware) clientLines.push({ label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' });
|
||||
clientLines.push({ label: 'Browser storage', desc: 'localStorage contents (preferences).' });
|
||||
clientLines.push({ label: 'User agent', desc: 'Browser, screen, page URL.' });
|
||||
const clientHtml = clientLines.map(c => `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
|
||||
<div><div class="text-gray-200">${_escapeHtml(c.label)}</div><div class="text-gray-500 text-[10px]">${_escapeHtml(c.desc)}</div></div>
|
||||
<div class="text-gray-500 text-right whitespace-nowrap">added on export</div>
|
||||
</div>`).join('');
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">Browser data</div>${clientHtml}</div>`);
|
||||
|
||||
const notesHtml = (m.notes || []).length
|
||||
? `<div class="mb-3 bg-dark-600 border border-amber-500/30 rounded-lg p-2">
|
||||
<div class="text-amber-400 text-[10px] font-semibold uppercase mb-1">Notes</div>
|
||||
${(m.notes).map(n => `<div class="text-gray-300 text-[11px]">• ${_escapeHtml(n)}</div>`).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const privacyHtml = redact
|
||||
? `<div class="text-emerald-400 text-[11px]">🔒 Redaction enabled — paths, song names, IPs, and secrets will be replaced with stable hash tokens.</div>`
|
||||
: `<div class="text-amber-400 text-[11px]">⚠ Redaction OFF — bundle will contain raw paths, song names, and IPs. Only share with people you trust.</div>`;
|
||||
|
||||
return `
|
||||
<div class="text-[11px]">
|
||||
<div class="flex justify-between items-baseline mb-2">
|
||||
<div class="text-gray-200 font-semibold">${_escapeHtml(data.filename)}</div>
|
||||
<div class="text-gray-400">${_escapeHtml(_formatBytes(totalBytes))}<span class="text-gray-600"> server-side</span></div>
|
||||
</div>
|
||||
<div class="text-gray-500 text-[10px] mb-3">runtime: ${_escapeHtml(m.runtime || 'unknown')} · exported_at: ${_escapeHtml(m.exported_at || '')}</div>
|
||||
${notesHtml}
|
||||
${sections.join('')}
|
||||
${privacyHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function previewDiagnostics() {
|
||||
const status = document.getElementById('diag-status');
|
||||
const preview = document.getElementById('diag-preview');
|
||||
if (!status || !preview) return;
|
||||
status.textContent = 'Building preview…';
|
||||
preview.classList.add('hidden');
|
||||
const include = _diagIncludeFromUI();
|
||||
const params = new URLSearchParams({
|
||||
redact: String(_diagRedactFromUI()),
|
||||
system: String(include.system),
|
||||
hardware: String(include.hardware),
|
||||
logs: String(include.logs),
|
||||
console: String(include.console),
|
||||
plugins: String(include.plugins),
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`/api/diagnostics/preview?${params.toString()}`);
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Preview failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
preview.innerHTML = _renderDiagPreview(data);
|
||||
preview.classList.remove('hidden');
|
||||
status.textContent = 'Preview ready.';
|
||||
} catch (e) {
|
||||
status.textContent = `Preview failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportDiagnostics() {
|
||||
const status = document.getElementById('diag-status');
|
||||
if (!status) return;
|
||||
status.textContent = 'Building bundle…';
|
||||
const include = _diagIncludeFromUI();
|
||||
const redact = _diagRedactFromUI();
|
||||
|
||||
const diag = window.feedBack && window.feedBack.diagnostics;
|
||||
const body = {
|
||||
redact,
|
||||
include,
|
||||
client_console: include.console && diag ? diag.snapshotConsole() : null,
|
||||
client_hardware: include.hardware && diag ? await diag.snapshotHardware() : null,
|
||||
client_ua: diag ? diag.snapshotUa() : null,
|
||||
local_storage: diag ? diag.snapshotLocalStorage() : null,
|
||||
client_contributions: diag ? diag.snapshotContributions() : null,
|
||||
};
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch('/api/diagnostics/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Export failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
let filename = 'feedBack-diag.zip';
|
||||
const disp = resp.headers.get('Content-Disposition');
|
||||
if (disp) {
|
||||
const m = /filename="([^"]+)"/.exec(disp);
|
||||
if (m) filename = m[1];
|
||||
}
|
||||
try {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed during download: ${e.message}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// DOM + HTML-escaping primitives, and the modal dialogs built on them.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// This one is a GATHER, not a slice — the six lived in six different places in
|
||||
// app.js. They belong together because they are the bottom of the UI stack:
|
||||
// `esc` / `_escAttr` alone have ~48 call sites, and every later carve that
|
||||
// renders HTML will need them. Giving them a home NOW means those carves can
|
||||
// import them instead of inventing a host seam to reach back into app.js —
|
||||
// which is exactly the trap the plugin-loader carve had to work around before
|
||||
// the viz layer became a module.
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
export 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<boolean>. 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).
|
||||
export 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 = 'feedBack-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 = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-3">${_escAttr(title || '')}</h3>
|
||||
<div class="mb-5">${body}</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" data-confirm class="${confirmClass}">${_escAttr(confirmText)}</button>
|
||||
<button type="button" data-cancel class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition focus:outline-none focus:ring-2 focus:ring-gray-500/40">${_escAttr(cancelText)}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// `esc()` escapes the HTML-content metacharacters (<, >, &) but not
|
||||
// quotes — fine for text-node interpolation but unsafe when the
|
||||
// result is used as an attribute value, where a literal `"` ends the
|
||||
// attribute early. Use `_escAttr` for any `attr="${...}"` site.
|
||||
export function _escAttr(s) {
|
||||
return esc(s == null ? '' : String(s))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// In-app text prompt — replaces window.prompt(), which Electron does NOT
|
||||
// implement (it logs "prompt() is and will not be supported" and returns null),
|
||||
// so any prompt()-based flow is a silent no-op on desktop. Returns the entered
|
||||
// string, or null if cancelled (Esc / Cancel / backdrop). Styled to match the
|
||||
// edit modal; role=dialog so the global keyboard shortcuts ignore typing here.
|
||||
// Injection-safe: all caller text is set via textContent / value, never innerHTML.
|
||||
export function uiPrompt({ title = '', label = '', value = '', okLabel = 'Save', placeholder = '' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'feedBack-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');
|
||||
if (title) modal.setAttribute('aria-label', title);
|
||||
modal.innerHTML = `
|
||||
<form class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-4" data-ui-prompt-title hidden></h3>
|
||||
<label class="text-xs text-gray-400 mb-1 block" data-ui-prompt-label hidden></label>
|
||||
<input type="text" data-ui-prompt-input autocomplete="off"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="submit"
|
||||
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition" data-ui-prompt-ok></button>
|
||||
<button type="button" data-ui-prompt-cancel
|
||||
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
|
||||
</div>
|
||||
</form>`;
|
||||
const titleEl = modal.querySelector('[data-ui-prompt-title]');
|
||||
const labelEl = modal.querySelector('[data-ui-prompt-label]');
|
||||
const input = modal.querySelector('[data-ui-prompt-input]');
|
||||
const okEl = modal.querySelector('[data-ui-prompt-ok]');
|
||||
if (title) { titleEl.textContent = title; titleEl.hidden = false; }
|
||||
if (label) { labelEl.textContent = label; labelEl.hidden = false; }
|
||||
okEl.textContent = okLabel;
|
||||
input.value = value;
|
||||
if (placeholder) input.placeholder = placeholder;
|
||||
|
||||
// Restore focus to wherever it was when we're done (matches the edit
|
||||
// modal's behavior so keyboard users aren't dumped at the page top).
|
||||
const previousActiveElement = document.activeElement;
|
||||
const focusables = () => Array.from(
|
||||
modal.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])'),
|
||||
).filter((el) => !el.disabled && el.offsetParent !== null);
|
||||
|
||||
let settled = false;
|
||||
const close = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
modal.remove();
|
||||
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(null); return; }
|
||||
// Trap Tab inside the modal so focus can't wander to the page behind it.
|
||||
if (e.key === 'Tab') {
|
||||
const items = focusables();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || !modal.contains(active))) {
|
||||
e.preventDefault(); last.focus();
|
||||
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
|
||||
e.preventDefault(); first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
modal.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); close(input.value); });
|
||||
modal.querySelector('[data-ui-prompt-cancel]').addEventListener('click', () => close(null));
|
||||
// Backdrop (overlay itself, not the panel) cancels.
|
||||
modal.addEventListener('mousedown', (e) => { if (e.target === modal) close(null); });
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.body.appendChild(modal);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Display formatters. A LEAF module: imports nothing.
|
||||
//
|
||||
// WHY THIS EXISTS FOR ONE FUNCTION. formatTime was a HOST HOOK — loops.js and
|
||||
// section-practice.js both reached back through the seam for it. It was also, by pure
|
||||
// accident of who calls it, inside the dependency closure of the library carve. Leaving
|
||||
// it there would have made loops.js and section-practice.js import the LIBRARY to format
|
||||
// a timestamp, which is nonsense, and a cycle waiting to happen.
|
||||
//
|
||||
// A hook is a cycle you agreed to live with. This one has a real owner — it just isn't
|
||||
// app.js, and it certainly isn't the library. Give it a home of its own and both
|
||||
// consumers import it directly.
|
||||
//
|
||||
// It is a leaf on purpose. Anything else that turns out to be a shared pure formatter
|
||||
// belongs here too; nothing does yet, so nothing else is here.
|
||||
|
||||
/** Seconds -> `M:SS`. */
|
||||
export function formatTime(s) { return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; }
|
||||
@@ -0,0 +1,601 @@
|
||||
// Highway string colours — user theming for the 2D + bundled 3D highways.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Slot→hex colours (per named string slot, so a 6-string map survives a 4-string
|
||||
// bass and a 7-string's Low B), named themes in localStorage, a copy/paste share
|
||||
// code, and the Settings-screen picker UI. The highways colour by raw string
|
||||
// INDEX, so a translation table maps named slots → per-index colours for the
|
||||
// current arrangement, recomputed whenever a song loads.
|
||||
//
|
||||
// Exports exactly two entry points; the other 43 symbols (the HWC_* tables, the
|
||||
// theme store, the picker handlers, the window.feedBack facade) are used nowhere
|
||||
// else in core and stay private. The Settings buttons are wired by
|
||||
// addEventListener inside hwcInitSettingsUI — there are no inline on*= handlers
|
||||
// here, so nothing needs re-exposing on window.
|
||||
//
|
||||
// It does import uiPrompt from ./dom.js (the "name this theme" prompt) — which is
|
||||
// precisely why dom.js was carved out first: without it this module would have
|
||||
// needed a host seam back into app.js.
|
||||
import { uiPrompt } from './dom.js';
|
||||
|
||||
// Colors are assigned per NAMED string (Low E, A, D, G, B, High E, plus the
|
||||
// extended low strings of 7/8-string guitars), so a string keeps its color
|
||||
// when the string count changes (e.g. Low E stays the same from a 6-string
|
||||
// guitar to a 4-string bass, and on a 7-string the extra Low B takes the
|
||||
// 7-string slot rather than bumping every color over). The highways color by
|
||||
// raw string INDEX, so a small translation table maps named slots → per-index
|
||||
// colors for the current arrangement; this is recomputed whenever a song loads
|
||||
// (its string count / bass-vs-guitar may differ). Applies to BOTH the 2D and
|
||||
// bundled 3D highway; stored client-side; shared via a copy/paste code.
|
||||
const HWC_KEY_ACTIVE = 'highwayStringColors'; // JSON slot→hex map (active)
|
||||
const HWC_KEY_THEMES = 'highwayColorThemes'; // { "<name>": {slot:hex} }
|
||||
const HWC_KEY_NAME = 'highwayColorActiveName'; // selected saved theme name, or ''
|
||||
const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
// Named color slots, in display order (high → low, then extended low strings).
|
||||
const HWC_SLOTS = [
|
||||
{ key: 'highE', label: 'High E', sub: '1st' },
|
||||
{ key: 'B', label: 'B', sub: '2nd' },
|
||||
{ key: 'G', label: 'G', sub: '3rd' },
|
||||
{ key: 'D', label: 'D', sub: '4th' },
|
||||
{ key: 'A', label: 'A', sub: '5th' },
|
||||
{ key: 'lowE', label: 'Low E', sub: '6th / lowest' },
|
||||
{ key: 'low7', label: 'Low B', sub: '7-string' },
|
||||
{ key: 'low8', label: 'Low F#', sub: '8-string' },
|
||||
];
|
||||
const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key);
|
||||
// Hardcoded fallback (matches the highway defaults) for before the 2D highway
|
||||
// is queryable.
|
||||
const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' };
|
||||
|
||||
// One-click string-color presets. Each is a full named-slot → hex map (every
|
||||
// slot, so 7/8-string charts get a sensible color too) keyed by the same slot
|
||||
// names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of
|
||||
// string count. Hues are chosen for the dark scene (~#080810): each color is
|
||||
// bright enough to read on black and distinct from its neighbours.
|
||||
// - warmcool: an ordered low→high spectrum (warm reds at the bass end →
|
||||
// cool blues/violet at the treble end) so pitch reads as color temperature.
|
||||
// - vivid: punchier, higher-saturation take on the classic mapping for a
|
||||
// stage-bright look.
|
||||
// - colorblind: the Okabe–Ito accessible qualitative palette (vermillion,
|
||||
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
|
||||
// distinguishable option for deuteranopia/protanopia.
|
||||
// - colorblind_deuteranope: a deuteranope-tuned variant of the Okabe–Ito set
|
||||
// above, contributed by a deuteranopic player who still found that set hard
|
||||
// to separate. Retunes the six main strings (red / yellow-green / blue /
|
||||
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
|
||||
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
|
||||
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
|
||||
// violet) so adjacent strings separate harder than vivid — a stage/stream
|
||||
// "pop" set, not a vivid duplicate.
|
||||
// - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep
|
||||
// blue → vermilion → azure → orange → yellow → cream). Unlike the unordered
|
||||
// Okabe–Ito 'colorblind' set, the value ramp teaches pitch low→high AND
|
||||
// survives grayscale/colorblindness; no red/green pair carries meaning.
|
||||
// - ember: a warm, lower-intensity family for long sessions, luminance-stepped
|
||||
// from rust/ember at the bass through warm gold to cream at the treble. The
|
||||
// bass embers stay light enough to clear the near-black scene.
|
||||
// - tapedeck: a vintage-print, slightly desaturated ochre-tinted family
|
||||
// (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted
|
||||
// hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the
|
||||
// dusty mid-strings (avocado/teal/denim) distinct on the dark board.
|
||||
// - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber)
|
||||
// stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue,
|
||||
// so lightness alone carries the ordering. Verified to stay legible even on
|
||||
// the matching phosphor scene board (green-on-green / amber-on-amber).
|
||||
// - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green →
|
||||
// yellow → warm-white) with rising lightness — memorable + teaches order.
|
||||
// - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream),
|
||||
// warm and lower-intensity, lightness-stepped low→high.
|
||||
const HWC_PRESETS = [
|
||||
{
|
||||
id: 'warmcool', label: 'Warm → Cool',
|
||||
colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' },
|
||||
},
|
||||
{
|
||||
id: 'vivid', label: 'Vivid',
|
||||
colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
},
|
||||
{
|
||||
id: 'accessible', label: 'Accessible (ordered)',
|
||||
colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' },
|
||||
},
|
||||
{
|
||||
id: 'ember', label: 'Warm Ember',
|
||||
colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' },
|
||||
},
|
||||
{
|
||||
id: 'tapedeck', label: 'Tape Deck',
|
||||
colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' },
|
||||
},
|
||||
{
|
||||
id: 'crtgreen', label: 'CRT Green',
|
||||
colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' },
|
||||
},
|
||||
{
|
||||
id: 'crtamber', label: 'CRT Amber',
|
||||
colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' },
|
||||
},
|
||||
{
|
||||
id: 'pitchramp', label: 'Pitch Ramp',
|
||||
colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' },
|
||||
},
|
||||
{
|
||||
id: 'sunrise', label: 'Sunrise',
|
||||
colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' },
|
||||
},
|
||||
];
|
||||
|
||||
// Translation table: chart string index → named slot, for a given string count
|
||||
// and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass
|
||||
// shares the low strings (E A D G), 7/8-string guitars prepend lower strings,
|
||||
// and sub-6 guitars truncate from the high end. Index 0 is always the lowest.
|
||||
function _hwcSlotKeysForChart(sc, isBass) {
|
||||
sc = Math.max(1, Math.min(8, (sc | 0) || 6));
|
||||
if (isBass) {
|
||||
if (sc <= 4) return ['lowE', 'A', 'D', 'G'].slice(0, sc);
|
||||
if (sc === 5) return ['low7', 'lowE', 'A', 'D', 'G'];
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G'].slice(0, sc);
|
||||
}
|
||||
if (sc <= 6) return ['lowE', 'A', 'D', 'G', 'B', 'highE'].slice(0, sc);
|
||||
if (sc === 7) return ['low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
}
|
||||
|
||||
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
|
||||
function _hwcChartShape() {
|
||||
let sc = 6, arr = '';
|
||||
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
|
||||
try { arr = window.highway?.getSongInfo?.()?.arrangement || window.feedBack?.currentSong?.arrangement || ''; } catch (_) {}
|
||||
return { sc: Math.max(1, Math.min(8, sc)), isBass: /bass/i.test(String(arr)) };
|
||||
}
|
||||
|
||||
// Normalize an arbitrary value to a slot→hex map of validated lowercase colors
|
||||
// (absent / invalid slots are omitted).
|
||||
function _hwcNormalize(slotMap) {
|
||||
const out = {};
|
||||
if (slotMap && typeof slotMap === 'object' && !Array.isArray(slotMap)) {
|
||||
for (const k of HWC_SLOT_KEYS) {
|
||||
const v = (typeof slotMap[k] === 'string') ? slotMap[k].trim().toLowerCase() : '';
|
||||
if (HWC_HEX_RE.test(v)) out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Canonical default color per named slot (the classic highway mapping).
|
||||
// Fixed, not read back from the highway (which may already be name-remapped for
|
||||
// a 7/8-string chart), so the pickers always preview the true per-name default.
|
||||
function getHighwayDefaultSlotColors() {
|
||||
return { ...HWC_DEFAULT_FALLBACK };
|
||||
}
|
||||
|
||||
// Active (user-customized) slot→hex map from storage ({} when none set).
|
||||
function getHighwayStringColors() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HWC_KEY_ACTIVE);
|
||||
if (raw) return _hwcNormalize(JSON.parse(raw));
|
||||
} catch (_) { /* corrupt / blocked */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
// Defaults overlaid with the user's custom slots (custom wins). Always a full
|
||||
// 8-slot map, so name-mapping has a color for every string of any arrangement.
|
||||
function _hwcMergedSlotColors() {
|
||||
return { ...getHighwayDefaultSlotColors(), ...getHighwayStringColors() };
|
||||
}
|
||||
|
||||
// True when the slot→index mapping is the identity (index 0 = lowest = Low E):
|
||||
// guitar ≤6 strings and 4-string bass. For these the name mapping equals the
|
||||
// stock index order, so we leave the highways on their hand-tuned defaults
|
||||
// (byte-identical) unless the user set custom colors. Extended-range charts —
|
||||
// 7/8-string guitar and 5/6-string bass — prepend lower strings (Low B/F#),
|
||||
// shifting Low E up an index, so their defaults must be name-remapped too.
|
||||
function _hwcMappingIsIdentity(sc, isBass) {
|
||||
return isBass ? sc <= 4 : sc <= 6;
|
||||
}
|
||||
|
||||
// Translate a full slot map into the index-keyed array the highways consume.
|
||||
function _hwcEffectiveIndexColors(slotMap, sc, isBass) {
|
||||
const keys = _hwcSlotKeysForChart(sc, isBass);
|
||||
return keys.map((k) => slotMap[k] || null);
|
||||
}
|
||||
|
||||
// Persist the user's custom slot map (or clear it), then apply. Only slots that
|
||||
// actually DIFFER from the default are stored — so reverting every picker to its
|
||||
// stock color persists as empty and the identity/stock path is restored (rather
|
||||
// than pinning the highways on an all-default "custom" theme).
|
||||
function applyHighwayStringColors(slotMap, opts) {
|
||||
const persist = !opts || opts.persist !== false;
|
||||
const colors = _hwcNormalize(slotMap);
|
||||
const defaults = getHighwayDefaultSlotColors();
|
||||
const overrides = {};
|
||||
for (const k of Object.keys(colors)) {
|
||||
if (colors[k] !== defaults[k]) overrides[k] = colors[k];
|
||||
}
|
||||
if (persist) {
|
||||
try {
|
||||
if (Object.keys(overrides).length) localStorage.setItem(HWC_KEY_ACTIVE, JSON.stringify(overrides));
|
||||
else localStorage.removeItem(HWC_KEY_ACTIVE);
|
||||
} catch (_) {}
|
||||
}
|
||||
reapplyHighwayStringColors();
|
||||
}
|
||||
|
||||
// Apply a named one-click string-color preset (see HWC_PRESETS) to all strings.
|
||||
// Persists + applies to both highways (via applyHighwayStringColors), then —
|
||||
// when the Settings UI is mounted — refreshes the per-string pickers so their
|
||||
// swatches show the preset's colors. Unknown id is a no-op.
|
||||
function applyHighwayStringPreset(id) {
|
||||
const preset = HWC_PRESETS.find((p) => p.id === id);
|
||||
if (!preset) return false;
|
||||
applyHighwayStringColors(preset.colors);
|
||||
try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Apply colors by NAMED string to both highways for the current arrangement.
|
||||
// Colors follow the string name regardless of count: Low E stays Low E's color
|
||||
// on a 6-, 7-, or 8-string. Defaults map identically to the stock order for
|
||||
// 6-string/bass (so those stay byte-identical); 7/8-string remaps the defaults
|
||||
// too so Low E keeps its color. The String Colors UI replaces the 3D highway's
|
||||
// old palette picker, so core always drives the 3D string colors here.
|
||||
function reapplyHighwayStringColors() {
|
||||
const { sc, isBass } = _hwcChartShape();
|
||||
const custom = getHighwayStringColors();
|
||||
const hasCustom = Object.keys(custom).length > 0;
|
||||
|
||||
if (!hasCustom && _hwcMappingIsIdentity(sc, isBass)) {
|
||||
// Pure stock defaults in natural order — leave the hand-tuned highway
|
||||
// defaults intact, and make sure the 3D is on its plain default palette
|
||||
// (clears any stale 'custom' / leftover palette selection).
|
||||
try { window.highway?.setStringColors?.(null); } catch (_) {}
|
||||
try {
|
||||
if (localStorage.getItem('h3d_bg_palette') !== 'default') window.h3dBgSetPalette?.('default');
|
||||
} catch (_) {}
|
||||
try { window.feedBack?.emit?.('highway:stringColors', {}); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
const eff = _hwcEffectiveIndexColors(_hwcMergedSlotColors(), sc, isBass);
|
||||
try { window.highway?.setStringColors?.(eff); } catch (_) {}
|
||||
try { window.h3dBgSetStringColors?.(eff); } catch (_) {}
|
||||
try { window.feedBack?.emit?.('highway:stringColors', custom); } catch (_) {}
|
||||
}
|
||||
|
||||
function _hwcReadThemes() {
|
||||
// Null-prototype store: theme names come from user input / share codes, so
|
||||
// names like `constructor`/`toString`/`__proto__` must not collide with
|
||||
// inherited Object properties or mutate the prototype.
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(HWC_KEY_THEMES) || '{}');
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return Object.create(null);
|
||||
const out = Object.create(null);
|
||||
for (const [name, colors] of Object.entries(parsed)) out[name] = _hwcNormalize(colors);
|
||||
return out;
|
||||
} catch (_) { return Object.create(null); }
|
||||
}
|
||||
function _hwcWriteThemes(o) { try { localStorage.setItem(HWC_KEY_THEMES, JSON.stringify(o)); } catch (_) {} }
|
||||
function listHighwayColorThemes() { return Object.keys(_hwcReadThemes()); }
|
||||
function getActiveHighwayColorThemeName() { try { return localStorage.getItem(HWC_KEY_NAME) || ''; } catch (_) { return ''; } }
|
||||
|
||||
function saveHighwayColorTheme(name, slotMap) {
|
||||
name = String(name || '').trim();
|
||||
if (!name) return false;
|
||||
const o = _hwcReadThemes();
|
||||
o[name] = _hwcNormalize(slotMap);
|
||||
_hwcWriteThemes(o);
|
||||
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
function deleteHighwayColorTheme(name) {
|
||||
const o = _hwcReadThemes();
|
||||
if (Object.prototype.hasOwnProperty.call(o, name)) { delete o[name]; _hwcWriteThemes(o); }
|
||||
if (getActiveHighwayColorThemeName() === name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} }
|
||||
}
|
||||
// Select a saved theme by name, or pass '' to revert to defaults.
|
||||
function selectHighwayColorTheme(name) {
|
||||
if (!name) {
|
||||
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
|
||||
applyHighwayStringColors(null);
|
||||
return;
|
||||
}
|
||||
const o = _hwcReadThemes();
|
||||
if (!Object.prototype.hasOwnProperty.call(o, name)) return;
|
||||
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
|
||||
applyHighwayStringColors(o[name]);
|
||||
}
|
||||
|
||||
// Compact, paste-friendly share code: "SLOPHWY2." + base64url(JSON{n,c}) where
|
||||
// c is the named slot→hex map.
|
||||
function encodeHighwayColorShare(name, slotMap) {
|
||||
const payload = { n: String(name || '').slice(0, 60), c: _hwcNormalize(slotMap) };
|
||||
const json = JSON.stringify(payload);
|
||||
let b64;
|
||||
try { b64 = btoa(unescape(encodeURIComponent(json))); } catch (_) { b64 = btoa(json); }
|
||||
return 'SLOPHWY2.' + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function decodeHighwayColorShare(code) {
|
||||
if (typeof code !== 'string') return null;
|
||||
let s = code.trim();
|
||||
// Require the exact versioned prefix. Anything else (a future/legacy
|
||||
// SLOPHWY*, or unprefixed text) is rejected so the version boundary is real.
|
||||
const PREFIX = 'SLOPHWY2.';
|
||||
if (s.slice(0, PREFIX.length).toUpperCase() !== PREFIX) return null;
|
||||
s = s.slice(PREFIX.length);
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
let json;
|
||||
try { json = decodeURIComponent(escape(atob(s))); } catch (_) { try { json = atob(s); } catch (_) { return null; } }
|
||||
let obj;
|
||||
try { obj = JSON.parse(json); } catch (_) { return null; }
|
||||
if (!obj || typeof obj.c !== 'object' || Array.isArray(obj.c)) return null;
|
||||
return { name: String(obj.n || '').slice(0, 60), colors: _hwcNormalize(obj.c) };
|
||||
}
|
||||
// Import a share code: store it as a (uniquely named) saved theme and apply.
|
||||
function importHighwayColorShare(code) {
|
||||
const parsed = decodeHighwayColorShare(code);
|
||||
if (!parsed) return null;
|
||||
let name = parsed.name || 'Imported';
|
||||
const existing = _hwcReadThemes();
|
||||
if (Object.prototype.hasOwnProperty.call(existing, name)) {
|
||||
let i = 2;
|
||||
while (Object.prototype.hasOwnProperty.call(existing, name + ' ' + i)) i++;
|
||||
name = name + ' ' + i;
|
||||
}
|
||||
saveHighwayColorTheme(name, parsed.colors);
|
||||
applyHighwayStringColors(parsed.colors);
|
||||
return { name, colors: parsed.colors };
|
||||
}
|
||||
|
||||
// Startup: apply persisted colors to the 2D highway immediately and re-apply on
|
||||
// every song load (string count / bass-vs-guitar can change the slot→index
|
||||
// mapping) and whenever a viz renderer (re)initializes (the 3D loads async +
|
||||
// rebuilds per song, so a one-shot apply could land before it exists).
|
||||
let _hwcWired = false;
|
||||
export function initHighwayColors() {
|
||||
reapplyHighwayStringColors();
|
||||
if (!_hwcWired && window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
_hwcWired = true;
|
||||
window.feedBack.on('viz:renderer:ready', reapplyHighwayStringColors);
|
||||
window.feedBack.on('song:loaded', reapplyHighwayStringColors);
|
||||
window.feedBack.on('song:ready', reapplyHighwayStringColors);
|
||||
}
|
||||
_hwcInstallFacade();
|
||||
}
|
||||
|
||||
// ── Public plugin API: window.feedBack.highwayColors ─────────────────────
|
||||
// A stable, documented facade over the (otherwise private) string-color
|
||||
// manager so plugins can read / react to / set the user's per-string colors
|
||||
// without reaching into internals. This is a synchronous data-plane API, not a
|
||||
// capability domain — consistent with the constitution keeping highway/viz
|
||||
// surfaces off the capability graph until a dedicated render-facade slice
|
||||
// lands. Colors are keyed by NAMED string slot (see `slots`); use
|
||||
// `keysForChart`/`toEffective` to map names → per-string-index for a given
|
||||
// arrangement. See docs/plugin-capability-inventory.md.
|
||||
const _hwcChangeWrappers = new WeakMap();
|
||||
function _hwcInstallFacade() {
|
||||
if (!window.feedBack || window.feedBack.highwayColors) return;
|
||||
const api = {
|
||||
version: 1,
|
||||
// Ordered named slots: [{ key, label, sub }]. `key` is the stable id.
|
||||
slots: HWC_SLOTS.map((s) => ({ key: s.key, label: s.label, sub: s.sub })),
|
||||
// User-set overrides only (named slot → hex); empty object = defaults.
|
||||
get() { return getHighwayStringColors(); },
|
||||
// Canonical default color per named slot.
|
||||
getDefaults() { return getHighwayDefaultSlotColors(); },
|
||||
// Defaults overlaid with overrides — the colors in effect, by name.
|
||||
getResolved() { return _hwcMergedSlotColors(); },
|
||||
// Which named slot each chart string index maps to, for an arrangement
|
||||
// (index 0 = lowest string). e.g. (7,false) → ['low7','lowE','A',...].
|
||||
keysForChart(stringCount, isBass) { return _hwcSlotKeysForChart(stringCount, !!isBass); },
|
||||
// Per-string-INDEX hex array (resolved colors) for an arrangement.
|
||||
// Omit args to use the currently-loaded chart's shape.
|
||||
toEffective(stringCount, isBass) {
|
||||
const shape = (typeof stringCount === 'number')
|
||||
? { sc: stringCount, isBass: !!isBass }
|
||||
: _hwcChartShape();
|
||||
return _hwcEffectiveIndexColors(_hwcMergedSlotColors(), shape.sc, shape.isBass);
|
||||
},
|
||||
// The per-index colors actually applied to the live 2D highway now.
|
||||
getCurrent() {
|
||||
try { return (window.highway && window.highway.getStringColors) ? window.highway.getStringColors() : []; }
|
||||
catch (_) { return []; }
|
||||
},
|
||||
// Set colors programmatically (persists + applies to both highways).
|
||||
// Pass a named slot map, or null/{} to revert to defaults.
|
||||
apply(slotMap) { return applyHighwayStringColors(slotMap); },
|
||||
// One-click presets: [{ id, label, colors }] (full named-slot maps).
|
||||
presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })),
|
||||
// Apply a preset by id (persists + applies to both highways).
|
||||
applyPreset(id) { return applyHighwayStringPreset(id); },
|
||||
// Share-code interop (the "SLOPHWY2." copy/paste format).
|
||||
encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); },
|
||||
decodeShare(code) { return decodeHighwayColorShare(code); },
|
||||
// Subscribe to color changes; handler receives the resolved slot map.
|
||||
// Returns an unsubscribe fn that removes exactly THIS subscription;
|
||||
// offChange(fn) removes every subscription registered with that fn.
|
||||
// (Each fn maps to a Set of wrappers so repeated mount/init paths that
|
||||
// subscribe the same handler don't clobber each other or leak.)
|
||||
onChange(fn) {
|
||||
if (typeof fn !== 'function' || !window.feedBack) return () => {};
|
||||
const wrapper = () => {
|
||||
try { fn(api.getResolved()); } catch (e) { console.error('[highwayColors] onChange handler threw', e); }
|
||||
};
|
||||
let set = _hwcChangeWrappers.get(fn);
|
||||
if (!set) { set = new Set(); _hwcChangeWrappers.set(fn, set); }
|
||||
set.add(wrapper);
|
||||
window.feedBack.on('highway:stringColors', wrapper);
|
||||
return () => {
|
||||
if (window.feedBack) window.feedBack.off('highway:stringColors', wrapper);
|
||||
const s = _hwcChangeWrappers.get(fn);
|
||||
if (s) { s.delete(wrapper); if (!s.size) _hwcChangeWrappers.delete(fn); }
|
||||
};
|
||||
},
|
||||
offChange(fn) {
|
||||
const set = _hwcChangeWrappers.get(fn);
|
||||
if (set && window.feedBack) {
|
||||
for (const wrapper of set) window.feedBack.off('highway:stringColors', wrapper);
|
||||
_hwcChangeWrappers.delete(fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
window.feedBack.highwayColors = api;
|
||||
}
|
||||
|
||||
// ── Highway String Colors — Settings UI wiring ───────────────────────────
|
||||
// Pickers are per NAMED string (see HWC_SLOTS). Assigning "Low E" a color
|
||||
// keeps Low E that color regardless of string count — the translation table
|
||||
// (_hwcSlotKeysForChart) handles the index remapping per arrangement.
|
||||
|
||||
function _hwcStatus(msg) {
|
||||
const el = document.getElementById('hwc-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
if (msg) {
|
||||
clearTimeout(_hwcStatus._t);
|
||||
_hwcStatus._t = setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Render one color input per named slot, seeded from active colors (falling
|
||||
// back to the highway defaults for that slot).
|
||||
function hwcRenderPickers() {
|
||||
const host = document.getElementById('hwc-pickers');
|
||||
if (!host) return;
|
||||
const defaults = getHighwayDefaultSlotColors();
|
||||
const active = getHighwayStringColors();
|
||||
host.innerHTML = '';
|
||||
for (const slot of HWC_SLOTS) {
|
||||
const val = active[slot.key] || defaults[slot.key] || '#888888';
|
||||
const wrap = document.createElement('label');
|
||||
wrap.className = 'flex items-center gap-2 text-xs text-gray-400';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.id = 'hwc-color-' + slot.key;
|
||||
input.dataset.slot = slot.key;
|
||||
input.value = val;
|
||||
input.style.width = '2.5rem';
|
||||
input.style.height = '1.75rem';
|
||||
input.style.padding = '2px';
|
||||
input.style.cursor = 'pointer';
|
||||
input.className = 'rounded border border-gray-800 bg-dark-700';
|
||||
input.addEventListener('input', () => hwcOnColorInput());
|
||||
wrap.appendChild(input);
|
||||
const span = document.createElement('span');
|
||||
span.textContent = slot.label;
|
||||
wrap.appendChild(span);
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'text-gray-600';
|
||||
sub.textContent = slot.sub;
|
||||
wrap.appendChild(sub);
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function hwcReadPickers() {
|
||||
const out = {};
|
||||
for (const slot of HWC_SLOTS) {
|
||||
const el = document.getElementById('hwc-color-' + slot.key);
|
||||
if (el) out[slot.key] = el.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Live apply on any picker change. Leaves the saved-theme select alone so a
|
||||
// tweaked-but-unsaved state is allowed; "Save as…" captures it.
|
||||
function hwcOnColorInput() {
|
||||
applyHighwayStringColors(hwcReadPickers());
|
||||
}
|
||||
|
||||
function hwcPopulateThemeSelect() {
|
||||
const sel = document.getElementById('hwc-theme-select');
|
||||
if (!sel) return;
|
||||
const names = listHighwayColorThemes().sort((a, b) => a.localeCompare(b));
|
||||
const current = getActiveHighwayColorThemeName();
|
||||
sel.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
def.textContent = 'Default colors';
|
||||
sel.appendChild(def);
|
||||
for (const n of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n;
|
||||
opt.textContent = n;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = (current && names.includes(current)) ? current : '';
|
||||
}
|
||||
|
||||
function hwcOnSelectTheme(name) {
|
||||
selectHighwayColorTheme(name);
|
||||
hwcRenderPickers();
|
||||
}
|
||||
|
||||
async function hwcSaveTheme() {
|
||||
const name = await uiPrompt({ title: 'Save Highway Colors', label: 'Theme name', value: getActiveHighwayColorThemeName() || 'My Colors', okLabel: 'Save' });
|
||||
if (!name) return;
|
||||
saveHighwayColorTheme(name, hwcReadPickers());
|
||||
hwcPopulateThemeSelect();
|
||||
_hwcStatus('Saved “' + name + '”');
|
||||
}
|
||||
|
||||
function hwcDeleteTheme() {
|
||||
const name = getActiveHighwayColorThemeName();
|
||||
if (!name) { _hwcStatus('No saved theme selected'); return; }
|
||||
deleteHighwayColorTheme(name);
|
||||
applyHighwayStringColors(null);
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Deleted “' + name + '”');
|
||||
}
|
||||
|
||||
function hwcReset() {
|
||||
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
|
||||
applyHighwayStringColors(null);
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Reset to defaults');
|
||||
}
|
||||
|
||||
async function hwcCopyShare() {
|
||||
const name = getActiveHighwayColorThemeName() || 'Highway Colors';
|
||||
const code = encodeHighwayColorShare(name, hwcReadPickers());
|
||||
let copied = false;
|
||||
try { await navigator.clipboard.writeText(code); copied = true; } catch (_) {}
|
||||
if (!copied) {
|
||||
// Fallback: drop the code into the import field so it can be copied manually.
|
||||
const inp = document.getElementById('hwc-import-code');
|
||||
if (inp) { inp.value = code; inp.select(); }
|
||||
}
|
||||
_hwcStatus(copied ? 'Share code copied' : 'Copy failed — code shown below');
|
||||
}
|
||||
|
||||
function hwcImport() {
|
||||
const inp = document.getElementById('hwc-import-code');
|
||||
const code = inp ? inp.value : '';
|
||||
const res = importHighwayColorShare(code);
|
||||
if (!res) { _hwcStatus('Invalid share code'); return; }
|
||||
if (inp) inp.value = '';
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Imported “' + res.name + '”');
|
||||
}
|
||||
|
||||
export function hwcInitSettingsUI() {
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// The host seam — how a carved-out module calls back into app.js.
|
||||
//
|
||||
// WHY THIS EXISTS. What is left in app.js is not a tree, it is a cycle: seeding a
|
||||
// dependency closure from count-in, from loops, from section-practice, or from the
|
||||
// JUCE seek shim all return the SAME 178-function set, and setLoop() and
|
||||
// practiceSection() call each other directly. So a module carved out of that
|
||||
// component will always need to call back into app.js — and it cannot `import`
|
||||
// app.js to do it, because app.js imports the module, and that closes a cycle the
|
||||
// import-x/no-cycle gate (rightly) rejects.
|
||||
//
|
||||
// So app.js hands its functions DOWN, once, at boot: `configureHost({ playSong, … })`.
|
||||
//
|
||||
// ─── THE FAILURE MODE THIS IS BUILT TO PREVENT ───────────────────────────────
|
||||
//
|
||||
// The obvious way to write this is a plain object with no-op defaults. That is a
|
||||
// TRAP, and we walked into it once already: the plugin loader's host seam defaulted
|
||||
// `populateVizPicker` to `() => {}`, which means that if the wiring call in app.js
|
||||
// is ever dropped, renamed, or drifts, the loader keeps running, the viz picker
|
||||
// silently stops refreshing, and NOTHING — no test, no boot check, no bot — says a
|
||||
// word. A feature just quietly stops existing.
|
||||
//
|
||||
// Two layers stop that here, and the second is the one that actually closes it:
|
||||
//
|
||||
// 1. RUNTIME — reading an unwired hook THROWS. There are no defaults and no
|
||||
// stubs. `host.playSong` either is the real function or it is a loud error.
|
||||
// An unwired hook cannot degrade into a no-op, because there is nothing for
|
||||
// it to degrade INTO.
|
||||
//
|
||||
// 2. STATIC — tests/js/host_contract.test.js asserts that the set of hooks the
|
||||
// modules USE is exactly the set app.js WIRES. This is the important one:
|
||||
// layer 1 only fires if the broken path actually executes, and the whole
|
||||
// danger of this seam is paths that don't run in a smoke test. The static
|
||||
// check catches a drifted or misspelled hook in CI, on a path nobody ran.
|
||||
//
|
||||
// Consequence for anyone adding a hook: add it to the configureHost({…}) call in
|
||||
// app.js *and* use it as `host.<name>`. The contract test fails on either alone —
|
||||
// deliberately. A hook wired but never used is dead weight; a hook used but never
|
||||
// wired is a bug that would otherwise hide.
|
||||
|
||||
const _hooks = Object.create(null);
|
||||
let _configured = false;
|
||||
|
||||
/**
|
||||
* Called ONCE by app.js at boot, before any carved module runs. Every value must
|
||||
* be a function — a hook that is accidentally `undefined` (a typo, a renamed
|
||||
* export, a dropped line) fails HERE, at startup, rather than silently much later.
|
||||
*/
|
||||
export function configureHost(hooks) {
|
||||
if (_configured) {
|
||||
throw new Error('[host] configureHost() called twice — it must be wired exactly once, at boot.');
|
||||
}
|
||||
const bad = Object.entries(hooks || {})
|
||||
.filter(([, v]) => typeof v !== 'function')
|
||||
.map(([k]) => k);
|
||||
if (bad.length) {
|
||||
throw new Error(
|
||||
`[host] these hooks are not functions: ${bad.join(', ')}. `
|
||||
+ 'A hook is usually undefined because it was renamed or its line was dropped.',
|
||||
);
|
||||
}
|
||||
Object.assign(_hooks, hooks);
|
||||
_configured = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seam itself. Reading a hook that was never wired THROWS — it never returns
|
||||
* undefined and never returns a silent no-op. See the note at the top: a no-op
|
||||
* default is precisely the bug this module exists to make impossible.
|
||||
*/
|
||||
export const host = new Proxy(Object.create(null), {
|
||||
get(_target, name) {
|
||||
if (typeof name === 'symbol') return undefined; // let JS probe it freely
|
||||
if (!_configured) {
|
||||
throw new Error(
|
||||
`[host] host.${name} was read before configureHost() ran. `
|
||||
+ 'app.js must call configureHost() at boot, before any carved module executes.',
|
||||
);
|
||||
}
|
||||
const fn = _hooks[name];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
`[host] host.${name} is not wired. Add it to the configureHost({ … }) `
|
||||
+ 'call in app.js. (tests/js/host_contract.test.js should have caught this in CI.)',
|
||||
);
|
||||
}
|
||||
return fn;
|
||||
},
|
||||
// Keep the object honest for anything that introspects it.
|
||||
has(_target, name) { return name in _hooks; },
|
||||
ownKeys() { return Object.keys(_hooks); },
|
||||
getOwnPropertyDescriptor(_target, name) {
|
||||
return name in _hooks
|
||||
? { value: _hooks[name], enumerable: true, configurable: true, writable: false }
|
||||
: undefined;
|
||||
},
|
||||
set(_target, name) {
|
||||
throw new Error(`[host] host.${String(name)} is read-only — hooks are wired only via configureHost().`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,994 @@
|
||||
// The desktop (JUCE) audio integration — three self-installing shims.
|
||||
//
|
||||
// The largest single slice out of app.js's core: 938 lines, ~12% of what was left.
|
||||
//
|
||||
// _installJuceEngineRoutingWatcher routes a song to the JUCE engine or HTML5 as the
|
||||
// desktop output device enters/leaves exclusive/ASIO
|
||||
// _installRendererBusFeeder feeds the highway renderer bus from whichever
|
||||
// transport is actually running
|
||||
// _installJuceAudioElementShim patches audio.play/pause so the rest of the app
|
||||
// can keep talking to the <audio> element while JUCE
|
||||
// owns the transport
|
||||
//
|
||||
// They EXPORT NOTHING. All three are IIFEs that publish through `window.*`
|
||||
// (_juceMode, _reevaluateJuceRouting, _reevaluateRendererBus, …) — which is why app.js
|
||||
// only needs a side-effect import for two of them, plus _resetJuceAudioShimChain.
|
||||
//
|
||||
// ORDERING, CHECKED: importing this module runs the IIFEs EARLIER than before —
|
||||
// imports evaluate ahead of app.js's body, and therefore ahead of configureHost().
|
||||
// That is safe because none of them touches a hook at execution depth: they only
|
||||
// register listeners and patch audio.play/pause (and `audio` is itself an imported
|
||||
// module now). Verified by walking the AST at IIFE-body depth. If a hook were ever
|
||||
// read there it would THROW loudly — see ./host.js — rather than silently misbehave.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState } from './transport.js';
|
||||
import { setSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
(function _installJuceEngineRoutingWatcher() {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if (!juceApi || typeof juceApi.isAudioRunning !== 'function') {
|
||||
// Desktop bridge present but audio API incomplete — the whole
|
||||
// exclusive reroute chain is dead and this line is the only witness.
|
||||
// (Docker sphere has no bridge at all: stay silent, nothing to
|
||||
// diagnose there and no debug flag to gate on.)
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] routing watcher NOT installed (audio api incomplete)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let _rerouteInFlight = false;
|
||||
// URL that JUCE's loadBackingTrack *explicitly rejected* (ok === false —
|
||||
// e.g. a codec it can't read). The poll below would otherwise retry the
|
||||
// same doomed track every 350 ms; remember it and skip until the song
|
||||
// changes. Only a hard JUCE reject is memoised here — transient failures
|
||||
// (a network blip on /api/audio-local-path, an isAudioRunning() race
|
||||
// during a device restart) are deliberately NOT memoised so they retry.
|
||||
let _rerouteRejectedUrl = null;
|
||||
// Exclusive-style output backends silence every other client on the
|
||||
// endpoint — including our own <audio> element. The share mode IS the
|
||||
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
|
||||
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
|
||||
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
|
||||
// shared and must NOT match.
|
||||
function _isExclusiveOutputType(t) {
|
||||
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
|
||||
}
|
||||
// [feedpak-route] diagnostics: log the raw outputType string once per
|
||||
// value change (this runs on a 350ms poll — logging every tick would
|
||||
// flood the diagnostics buffer).
|
||||
let _loggedOutputType;
|
||||
// [asio-diag] verbose diagnostics, gated on --debug (preload exposes
|
||||
// audio.debugEnabled). Resolved once at install; until it resolves the
|
||||
// flag stays false and verbose lines are skipped. Shared with the
|
||||
// renderer-bus feeder below via window._asioDiagEnabled.
|
||||
let _asioDiag = false;
|
||||
if (typeof juceApi.debugEnabled === 'function') {
|
||||
juceApi.debugEnabled().then((v) => {
|
||||
_asioDiag = !!v;
|
||||
// Deferred install line: the flag resolves async, so logging at
|
||||
// IIFE entry would race it. Change-detection isn't needed — this
|
||||
// runs once per page load.
|
||||
if (_asioDiag) console.log('[asio-diag] routing watcher installed');
|
||||
}).catch(() => {});
|
||||
}
|
||||
window._asioDiagEnabled = () => _asioDiag;
|
||||
async function _outputIsExclusive() {
|
||||
if (typeof juceApi.getCurrentDevice !== 'function') {
|
||||
if (_loggedOutputType !== '<no-getCurrentDevice>') {
|
||||
_loggedOutputType = '<no-getCurrentDevice>';
|
||||
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const dev = await juceApi.getCurrentDevice();
|
||||
const t = dev?.outputType || dev?.type || '';
|
||||
const excl = _isExclusiveOutputType(t);
|
||||
if (t !== _loggedOutputType) {
|
||||
_loggedOutputType = t;
|
||||
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
|
||||
// [asio-diag] full device object on every type change — shows
|
||||
// the exact strings the predicate saw (inputType vs outputType,
|
||||
// device names, duplex), so a driver reporting a non-'ASIO'
|
||||
// type name is visible in tester logs.
|
||||
if (_asioDiag) {
|
||||
try {
|
||||
console.log('[asio-diag] getCurrentDevice=', JSON.stringify(dev));
|
||||
} catch (_) { /* circular/hostile object — skip */ }
|
||||
}
|
||||
}
|
||||
return excl;
|
||||
} catch (e) {
|
||||
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
|
||||
_loggedOutputType = '<getCurrentDevice-failed>';
|
||||
console.warn('[feedpak-route] getCurrentDevice failed:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// highway.js's initial song-load routing consults this for the same
|
||||
// feedpak-under-exclusive decision the watcher makes below.
|
||||
window._juceOutputIsExclusive = _outputIsExclusive;
|
||||
// Returns true when window._currentSongAudio no longer references the exact
|
||||
// snapshot object captured at reroute entry — i.e. the song was swapped (or
|
||||
// cleared) mid-flight. Staleness is detected by object-reference identity,
|
||||
// not by URL value.
|
||||
function _isStale(songAudio) {
|
||||
return window._currentSongAudio !== songAudio;
|
||||
}
|
||||
|
||||
// Migrates the loaded song from the HTML5 element onto the JUCE backing
|
||||
// transport. Throws only on transient/unexpected failures.
|
||||
// `songAudio` is the snapshot captured at reroute entry; if it stops being
|
||||
// the current song mid-flight we abort without mutating global routing.
|
||||
// Returns a distinct string outcome — the caller must NOT conflate them:
|
||||
// 'switched' — song now plays via JUCE.
|
||||
// 'rejected' — JUCE hard-rejected the track (codec). Caller memoises it.
|
||||
// 'stale' — the loaded song changed mid-flight; aborted, NOT memoised.
|
||||
// (a transient transport-start failure throws instead — also not memoised.)
|
||||
async function _switchHtml5ToJuce(songAudio) {
|
||||
const url = songAudio.url;
|
||||
const wasPlaying = S.isPlaying;
|
||||
const pos = audio.currentTime || 0;
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'desktop-native',
|
||||
state: 'switching',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio engine became active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
// Mark a reroute in progress so the <audio> 'play'/'pause' listeners
|
||||
// suppress their song:play / song:pause emissions: the migration is
|
||||
// transparent — playback genuinely continues — so plugin state and
|
||||
// window.feedBack.isPlaying must NOT flip. This also silences the
|
||||
// "Audio paused unexpectedly" diagnostic. A REFCOUNT (not a boolean)
|
||||
// lets an overlapping reroute's deferred release coexist: each switch
|
||||
// increments on entry and decrements after its own timeout; listeners
|
||||
// treat any count > 0 as "reroute active".
|
||||
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
|
||||
audio.pause();
|
||||
try {
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
|
||||
if (!res.ok) {
|
||||
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
|
||||
throw new Error('HTTP ' + res.status);
|
||||
}
|
||||
const { path } = await res.json();
|
||||
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
|
||||
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
|
||||
const ok = await juceApi.loadBackingTrack(path);
|
||||
if (ok === false) {
|
||||
// JUCE rejected the track — stay on HTML5, resume if needed.
|
||||
console.warn('[juce-reroute] loadBackingTrack rejected; staying on HTML5');
|
||||
// Only resume if the element still has a source. In the normal
|
||||
// flow audio.src is intact here, but a prior HTML5→JUCE switch
|
||||
// clears it — re-point + load before resuming so a bounced
|
||||
// reroute doesn't try to play() an empty element.
|
||||
if (S.isPlaying && !_isStale(songAudio)) {
|
||||
if (!audio.src) { audio.src = url; audio.load(); }
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'degraded',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route rejected track; kept browser media route',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
return 'rejected';
|
||||
}
|
||||
if (_isStale(songAudio)) return 'stale';
|
||||
const dur = await juceApi.getBackingDuration();
|
||||
await juceApi.seekBacking(pos);
|
||||
// Start the new transport BEFORE committing global routing state, so
|
||||
// a play() failure can't leave us in "JUCE mode, nothing playing"
|
||||
// (the silent-song state this watcher exists to prevent).
|
||||
// jucePlayer.play() RETURNS false (it does not throw) when
|
||||
// startBacking fails — check the result, don't just await it.
|
||||
// A play() failure is a TRANSIENT transport-start issue, not a hard
|
||||
// codec reject: throw (rather than returning 'rejected') so the
|
||||
// caller's catch path handles it WITHOUT memoising the URL, leaving
|
||||
// it free to retry on the next poll. Only 'rejected' is memoised.
|
||||
// Re-read isPlaying as late as possible: the user can press Pause
|
||||
// during the multi-await fetch/IPC chain above. Starting the JUCE
|
||||
// transport off a stale `wasPlaying` snapshot would resume a song
|
||||
// the user just paused. Only start it if playback is still wanted.
|
||||
if (S.isPlaying) {
|
||||
const started = await jucePlayer.play();
|
||||
if (started === false) {
|
||||
if (!_isStale(songAudio) && S.isPlaying) {
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
throw new Error('jucePlayer.play() failed (transient transport start)');
|
||||
}
|
||||
}
|
||||
if (_isStale(songAudio)) {
|
||||
// Song changed while JUCE was spinning up — undo and bail.
|
||||
await jucePlayer.pause().catch(() => {});
|
||||
return 'stale';
|
||||
}
|
||||
if (window.jucePlayer) {
|
||||
jucePlayer._dur = dur;
|
||||
jucePlayer._pos = pos;
|
||||
jucePlayer._pollAt = performance.now();
|
||||
}
|
||||
window._juceMode = true;
|
||||
window._juceAudioUrl = url;
|
||||
const _spSlider = document.getElementById?.('speed-slider');
|
||||
if (_spSlider) setSpeed(_spSlider.value / 100);
|
||||
audio.src = '';
|
||||
try {
|
||||
const apply = window.feedBack?.audio?.applySongVolume;
|
||||
if (typeof apply === 'function') await apply();
|
||||
} catch (_) { /* best-effort */ }
|
||||
console.log('[juce-reroute] HTML5 → JUCE @', pos.toFixed(2), 's playing=', wasPlaying);
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'desktop-native',
|
||||
state: 'active',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
return 'switched';
|
||||
} catch (err) {
|
||||
// Path lookup, JSON parse, or a JUCE IPC call threw partway through.
|
||||
// audio.pause() already ran above; restore HTML5 playback so a
|
||||
// previously playing song isn't left silently paused, then re-throw
|
||||
// so the caller logs it. The caller does NOT memoise this URL —
|
||||
// transient failures must retry on the next poll.
|
||||
if (S.isPlaying && !window._juceMode && !_isStale(songAudio)) {
|
||||
if (!audio.src) { audio.src = url; audio.load(); }
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'degraded',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route failed; kept browser media route',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
// Clearing audio.src above dispatches a 'pause' event in a later
|
||||
// task, after this synchronous finally. Defer the refcount
|
||||
// decrement so that trailing event is still suppressed; a 0ms
|
||||
// timeout lands after the pending pause-event task. Decrementing
|
||||
// (rather than zeroing) leaves any overlapping reroute's own
|
||||
// suppression intact.
|
||||
setTimeout(() => {
|
||||
window._juceRerouteInProgress = Math.max(
|
||||
0, (window._juceRerouteInProgress || 1) - 1);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function _switchJuceToHtml5(songAudio) {
|
||||
const url = songAudio.url;
|
||||
const wasPlaying = S.isPlaying;
|
||||
const pos = (window.jucePlayer ? jucePlayer.currentTime : 0) || 0;
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'switching',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio engine stopped',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
// Mark a reroute in progress (refcount) so the <audio> 'play' listener
|
||||
// suppresses its song:play emission — the migration is transparent and
|
||||
// playback genuinely continues, so plugin state must not flip. Held
|
||||
// until after the (possibly deferred) audio.play() event has fired.
|
||||
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
|
||||
let _suppressionReleased = false;
|
||||
const _releaseSuppression = () => {
|
||||
if (_suppressionReleased) return;
|
||||
_suppressionReleased = true;
|
||||
// Defer so the 'play' (or 'pause') event task fires while still
|
||||
// suppressed; a 0ms timeout lands after it.
|
||||
setTimeout(() => {
|
||||
window._juceRerouteInProgress = Math.max(
|
||||
0, (window._juceRerouteInProgress || 1) - 1);
|
||||
}, 0);
|
||||
};
|
||||
let _resumeScheduled = false;
|
||||
try {
|
||||
await jucePlayer.pause().catch(() => {});
|
||||
if (_isStale(songAudio)) return; // song changed mid-pause
|
||||
window._juceMode = false;
|
||||
window._juceAudioUrl = null;
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
const _spSlider = document.getElementById?.('speed-slider');
|
||||
if (_spSlider) setSpeed(_spSlider.value / 100);
|
||||
// Resume only AFTER the seek so playback starts at `pos`, not at 0
|
||||
// with an audible jump once metadata arrives.
|
||||
const resumeAtPos = () => {
|
||||
try {
|
||||
// The metadata event can land after a fast song switch —
|
||||
// bail before touching currentTime so a stale callback
|
||||
// doesn't seek the newly loaded song to the old position.
|
||||
if (_isStale(songAudio)) return;
|
||||
try { audio.currentTime = pos; } catch (_) { /* ignore */ }
|
||||
// Re-read isPlaying (not the entry snapshot): the user may
|
||||
// have pressed Pause during jucePlayer.pause()/metadata
|
||||
// load — don't resume a song they just paused.
|
||||
if (S.isPlaying) {
|
||||
audio.play().catch(() => { /* ignore */ });
|
||||
}
|
||||
} finally {
|
||||
_releaseSuppression();
|
||||
}
|
||||
};
|
||||
_resumeScheduled = true;
|
||||
if (audio.readyState >= 1) {
|
||||
resumeAtPos();
|
||||
} else {
|
||||
// Wait for metadata to resume at `pos`. But metadata may never
|
||||
// arrive (bad URL, network error) — that would leak the
|
||||
// suppression refcount and permanently silence song:play /
|
||||
// song:pause. Guard with the element's 'error' event AND a
|
||||
// backstop timeout; whichever fires first wins, the others are
|
||||
// detached. _releaseSuppression is idempotent regardless.
|
||||
let _settled = false;
|
||||
const _onMeta = () => { finish(true); };
|
||||
const _onErr = () => { finish(false); };
|
||||
let _backstop;
|
||||
function finish(reachedMetadata) {
|
||||
if (_settled) return;
|
||||
_settled = true;
|
||||
clearTimeout(_backstop);
|
||||
audio.removeEventListener('loadedmetadata', _onMeta);
|
||||
audio.removeEventListener('error', _onErr);
|
||||
if (reachedMetadata) {
|
||||
resumeAtPos(); // resumeAtPos releases suppression
|
||||
} else {
|
||||
_releaseSuppression(); // no resume — just release
|
||||
}
|
||||
}
|
||||
audio.addEventListener('loadedmetadata', _onMeta, { once: true });
|
||||
audio.addEventListener('error', _onErr, { once: true });
|
||||
// 10s is well beyond a normal local-file metadata load.
|
||||
_backstop = setTimeout(() => { finish(false); }, 10000);
|
||||
}
|
||||
} finally {
|
||||
// resumeAtPos owns the release once scheduled; if we returned
|
||||
// early (stale, before scheduling) release here instead.
|
||||
// _releaseSuppression is idempotent so an overlap is harmless.
|
||||
if (!_resumeScheduled) _releaseSuppression();
|
||||
}
|
||||
try {
|
||||
const apply = window.feedBack?.audio?.applySongVolume;
|
||||
if (typeof apply === 'function') await apply();
|
||||
} catch (_) { /* best-effort */ }
|
||||
console.log('[juce-reroute] JUCE → HTML5 @', pos.toFixed(2), 's playing=', wasPlaying);
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'active',
|
||||
preservedTime: true,
|
||||
safeReason: 'browser media route active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
}
|
||||
|
||||
async function _reevaluateJuceRouting() {
|
||||
if (_rerouteInFlight) return;
|
||||
const songAudio = window._currentSongAudio;
|
||||
// /audio/ songs are always JUCE-routable. A feedpak full-mix
|
||||
// (single-mix pack, no stems) is routable ONLY under an
|
||||
// exclusive-style output — in shared mode it must stay on HTML5 so
|
||||
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
|
||||
// are never routable (per-stem mix can't ride a single transport).
|
||||
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
|
||||
// Don't race highway.js's own initial song-load routing: it owns
|
||||
// _juceMode until _juceRoutingPromise settles. Re-running our switch
|
||||
// concurrently would double-call loadBackingTrack for the same URL.
|
||||
if (window._highwayJuceRoutingPending) return;
|
||||
|
||||
// Claim the in-flight guard SYNCHRONOUSLY, before the first await. The
|
||||
// watcher is driven by a 350ms setInterval; if isAudioRunning() (or any
|
||||
// later await) stalls past the poll period, a second tick would
|
||||
// otherwise pass the `if (_rerouteInFlight) return` check above and run
|
||||
// a concurrent switch — duplicate loadBackingTrack IPCs racing on
|
||||
// _juceMode / audio.src. Setting it here closes that window.
|
||||
_rerouteInFlight = true;
|
||||
try {
|
||||
let running;
|
||||
try { running = await juceApi.isAudioRunning(); }
|
||||
catch (_) { return; }
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
// Eligibility is evaluated per tick, not snapshotted at song load:
|
||||
// the output share mode can change mid-song (device switch in the
|
||||
// Audio Engine panel), and a feedpak full-mix must follow it —
|
||||
// exclusive → ride the engine; back to shared → return to HTML5.
|
||||
let eligible = !!songAudio.juceEligible;
|
||||
if (!eligible && songAudio.feedpakFullMix && running) {
|
||||
eligible = await _outputIsExclusive();
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
}
|
||||
const wantJuce = !!(running && eligible);
|
||||
// [feedpak-route] diagnostics: one line per decision change (the
|
||||
// watcher polls at 350ms; steady state must not spam the buffer).
|
||||
const _decision = 'running=' + running + ' eligible=' + eligible
|
||||
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
|
||||
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
|
||||
if (_decision !== window._lastFeedpakRouteDecision) {
|
||||
window._lastFeedpakRouteDecision = _decision;
|
||||
console.log('[feedpak-route] watcher:', _decision);
|
||||
}
|
||||
if (wantJuce === !!window._juceMode) return; // routing already consistent
|
||||
// Don't keep retrying a track JUCE explicitly rejected.
|
||||
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
|
||||
|
||||
if (wantJuce) {
|
||||
const outcome = await _switchHtml5ToJuce(songAudio);
|
||||
// Memoise ONLY an explicit hard JUCE reject. A successful
|
||||
// switch clears the memo; a 'stale' abort (song changed
|
||||
// mid-flight) leaves it untouched — it must never be
|
||||
// misclassified as a reject, even if the song object was
|
||||
// swapped and then restored before this point.
|
||||
if (outcome === 'rejected') {
|
||||
_rerouteRejectedUrl = songAudio.url;
|
||||
} else if (outcome === 'switched') {
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
|
||||
} else {
|
||||
await _switchJuceToHtml5(songAudio);
|
||||
// The engine stopped (or a feedpak's output left exclusive
|
||||
// mode). Clear any hard-reject memo so a later engine restart
|
||||
// or mode change re-evaluates the track at least once — the
|
||||
// rejection may have been a transient device/decoder state.
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Transient failure — log but do NOT memoise, so the next poll retries.
|
||||
console.warn('[juce-reroute] re-route failed (will retry):', e);
|
||||
} finally {
|
||||
_rerouteInFlight = false;
|
||||
}
|
||||
}
|
||||
window._reevaluateJuceRouting = _reevaluateJuceRouting;
|
||||
|
||||
// Clears the hard-reject memo. Called from the song-teardown sites that
|
||||
// null window._currentSongAudio (showScreen, playSong) so that reloading
|
||||
// the same file later gets a fresh routing attempt — a prior reject may
|
||||
// have been a transient JUCE/device state, not a permanent codec issue.
|
||||
window._clearJuceRerouteMemo = function () { _rerouteRejectedUrl = null; };
|
||||
|
||||
// The engine can be started/stopped from several places (the desktop Audio
|
||||
// Engine panel, the audio_engine plugin, note_detect) and via setDevice
|
||||
// restarts — and the contextBridge api object is frozen, so its methods
|
||||
// can't be wrapped. Poll isAudioRunning() while a song is loaded; the check
|
||||
// is a cheap IPC boolean and no-ops once routing is already consistent.
|
||||
// Skip the poll while the document is hidden (background tab / minimised
|
||||
// window) — engine toggles there will be reconciled on the first poll
|
||||
// after the tab is visible again.
|
||||
setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
if (window._currentSongAudio) void _reevaluateJuceRouting();
|
||||
}, 350);
|
||||
})();
|
||||
|
||||
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
|
||||
// output endpoint in an exclusive-style mode, Chromium cannot reach the
|
||||
// device, so any song audio still played by the renderer goes silent. The
|
||||
// Phase 1 watcher above already migrates what a single-file transport can
|
||||
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
|
||||
// transport. This feeder covers the rest — the stems plugin's multi-stem
|
||||
// WebAudio graph, plus <audio>-element songs the native transport could not
|
||||
// take (e.g. a codec loadBackingTrack rejected).
|
||||
//
|
||||
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
|
||||
// re-point the owning AudioContext at a null sink so it keeps rendering
|
||||
// without a device, and push ~10 ms chunks over IPC into the engine's
|
||||
// renderer bus, where they are mixed into the exclusive output like a
|
||||
// backing track (~10-20 ms added latency on song audio only; the guitar
|
||||
// monitoring path is untouched). Validated by the fix12 tester spike:
|
||||
// null-sink rendering works, clocks hold (drift → 0), no overflow.
|
||||
//
|
||||
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
|
||||
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
|
||||
// captured contexts keep/regain their default sink.
|
||||
(function _installRendererBusFeeder() {
|
||||
const api = window.feedBackDesktop?.audio;
|
||||
if (!api || typeof api.setRendererBus !== 'function'
|
||||
|| typeof api.pushRendererAudio !== 'function') {
|
||||
// Silent in the Docker sphere (no bridge, no debug flag); a desktop
|
||||
// bridge missing the bus API is the diagnostic case.
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] renderer-bus feeder NOT installed (api=' + !!api
|
||||
+ ' setRendererBus=' + typeof api?.setRendererBus
|
||||
+ ' pushRendererAudio=' + typeof api?.pushRendererAudio + ')');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Deferred like the watcher's install line: gate on the async debug flag.
|
||||
if (typeof api.debugEnabled === 'function') {
|
||||
api.debugEnabled().then((v) => {
|
||||
if (v) console.log('[asio-diag] renderer-bus feeder installed (loopback-capable='
|
||||
+ (typeof window.navigator?.mediaDevices?.getDisplayMedia === 'function') + ')');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const TAP_WORKLET = `
|
||||
class FeedbackBusTap extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const inp = inputs[0];
|
||||
if (inp && inp[0]) {
|
||||
const L = inp[0], R = inp[1] || inp[0];
|
||||
const out = new Float32Array(L.length * 2);
|
||||
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
|
||||
this.port.postMessage(out, [out.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('feedback-bus-tap', FeedbackBusTap);
|
||||
`;
|
||||
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
|
||||
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
|
||||
|
||||
// One tap per captured graph. `active` gates the push (the worklet keeps
|
||||
// running when inactive — it's silent bookkeeping, not audio).
|
||||
function _makeTap(ctx) {
|
||||
const state = { node: null, active: false, batch: [], batchFrames: 0 };
|
||||
state.attach = async (sourceNode) => {
|
||||
if (!_tapModuleLoaded.has(ctx)) {
|
||||
await ctx.audioWorklet.addModule(_tapModuleUrl);
|
||||
_tapModuleLoaded.add(ctx);
|
||||
}
|
||||
if (!state.node) {
|
||||
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
|
||||
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
|
||||
state.node.port.onmessage = (e) => {
|
||||
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
|
||||
state.batch.push(e.data);
|
||||
state.batchFrames += e.data.length / 2;
|
||||
if (state.batchFrames >= BATCH) {
|
||||
const merged = new Float32Array(state.batchFrames * 2);
|
||||
let o = 0;
|
||||
for (const c of state.batch) { merged.set(c, o); o += c.length; }
|
||||
api.pushRendererAudio(merged, ctx.sampleRate);
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
sourceNode.connect(state.node);
|
||||
// No onward connection: the tap is a sink-side observer; audibility
|
||||
// in shared mode comes from the graph's own destination path.
|
||||
};
|
||||
state.detach = (sourceNode) => {
|
||||
state.active = false;
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
if (state.node && sourceNode) {
|
||||
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
|
||||
}
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Core <audio> element capture ─────────────────────────────────────────
|
||||
// createMediaElementSource permanently reroutes the element into its
|
||||
// context, so it is created lazily — only the first time an exclusive
|
||||
// device actually needs it — and never torn down. From then on the element
|
||||
// always plays through _elCtx; sink toggling routes it to the speakers
|
||||
// (shared mode) or the null sink + bus (exclusive mode).
|
||||
let _elCtx = null, _elSource = null, _elTap = null;
|
||||
async function _ensureElementCapture() {
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
// Assign the module state ONLY after the whole chain succeeded.
|
||||
// createMediaElementSource throws InvalidStateError when another
|
||||
// consumer (highway_3d's analyser tap) already owns the element's
|
||||
// one-shot source — assigning _elCtx before that throw poisoned every
|
||||
// later tick into `_elTap.active` TypeErrors (tester log 2026-07-11)
|
||||
// while the song kept playing on the default device.
|
||||
const ctx = new AudioContext();
|
||||
let source, tap;
|
||||
try {
|
||||
source = ctx.createMediaElementSource(el);
|
||||
source.connect(ctx.destination);
|
||||
tap = _makeTap(ctx);
|
||||
await tap.attach(source);
|
||||
} catch (e) {
|
||||
try { await ctx.close(); } catch (_) { /* already closed */ }
|
||||
throw e;
|
||||
}
|
||||
_elCtx = ctx; _elSource = source; _elTap = tap;
|
||||
}
|
||||
|
||||
// ── Whole-app loopback capture ───────────────────────────────────────────
|
||||
// Preferred mode: one getDisplayMedia frame-audio capture covers EVERY
|
||||
// sound the app makes (song, previews, UI) — no per-surface taps, so
|
||||
// plugin-private AudioContexts (song-preview, future plugins) survive
|
||||
// exclusive/ASIO output too. The desktop main process answers the request
|
||||
// with this window's own frame (frame-scoped — no other apps' audio).
|
||||
// Local playback is silenced via the suppressLocalAudioPlayback track
|
||||
// constraint, with a page-mute IPC fallback (capture taps frame audio
|
||||
// before the output mute, so a muted page still feeds the stream).
|
||||
let _lbStream = null, _lbCtx = null, _lbTap = null, _lbPageMuted = false;
|
||||
let _loopbackUnavailable = false; // sticky: probe once, then fall back
|
||||
async function _engageLoopback() {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: { suppressLocalAudioPlayback: true },
|
||||
});
|
||||
for (const t of stream.getVideoTracks()) t.stop(); // required, unused
|
||||
const track = stream.getAudioTracks()[0];
|
||||
if (!track) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw new Error('no loopback audio track');
|
||||
}
|
||||
try {
|
||||
// Fresh context per session (not reused) so teardown's close()
|
||||
// fully releases the tap worklet node — see _teardownLoopback.
|
||||
_lbCtx = new AudioContext();
|
||||
if (_lbCtx.state !== 'running') await _lbCtx.resume().catch(() => {});
|
||||
const source = _lbCtx.createMediaStreamSource(stream);
|
||||
const tap = _makeTap(_lbCtx);
|
||||
await tap.attach(source);
|
||||
const suppressed = track.getSettings?.().suppressLocalAudioPlayback === true;
|
||||
if (!suppressed && typeof api.setPageMuted === 'function') {
|
||||
_lbPageMuted = (await api.setPageMuted(true)) === true;
|
||||
}
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] loopback: suppressed=', suppressed,
|
||||
'pageMuted=', _lbPageMuted, 'rate=', _lbCtx.sampleRate);
|
||||
}
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_lbStream = stream; _lbTap = tap;
|
||||
_mode = 'loopback';
|
||||
console.log('[renderer-bus] engaged: app loopback → engine bus');
|
||||
} catch (e) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function _teardownLoopback() {
|
||||
if (_lbTap) _lbTap.active = false;
|
||||
if (_lbStream) for (const t of _lbStream.getTracks()) t.stop();
|
||||
_lbStream = null; _lbTap = null;
|
||||
// Close the capture context so its tap worklet node is released. The
|
||||
// context is per-session (not reused): without this, each exclusive⇄
|
||||
// shared switch orphaned a live worklet on a long-lived context.
|
||||
if (_lbCtx) {
|
||||
try { await _lbCtx.close(); } catch (_) { /* already closed */ }
|
||||
_lbCtx = null;
|
||||
}
|
||||
if (_lbPageMuted && typeof api.setPageMuted === 'function') {
|
||||
try { await api.setPageMuted(false); } catch (_) { /* engine gone */ }
|
||||
}
|
||||
_lbPageMuted = false;
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
|
||||
// loopback capture is unavailable: old desktop main, denied capture)
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
|
||||
let _busy = false;
|
||||
|
||||
async function _setSink(ctx, exclusive) {
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
|
||||
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
|
||||
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
|
||||
// [asio-diag] a context left on the default sink while the bus is
|
||||
// engaged is exactly the "song on the wrong device" symptom — record
|
||||
// every successful sink flip (failures throw and are logged upstream).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] setSink:', exclusive ? 'null-sink' : 'default',
|
||||
'state=', ctx.state, 'rate=', ctx.sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
if (_mode === 'off') return;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'loopback') {
|
||||
await _teardownLoopback();
|
||||
} else if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
|
||||
await _setSink(_stemsGraph.context, false).catch(() => {});
|
||||
_stemsGraph = null; _stemsTap = null;
|
||||
}
|
||||
console.log('[renderer-bus] disengaged (' + prev + ')');
|
||||
}
|
||||
|
||||
async function _engageStems(graph) {
|
||||
await _setSink(graph.context, true);
|
||||
let tap = _stemsTaps.get(graph.context);
|
||||
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
|
||||
await tap.attach(graph.masterNode);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_stemsGraph = graph; _stemsTap = tap;
|
||||
_mode = 'stems';
|
||||
console.log('[renderer-bus] engaged: stems graph → engine bus');
|
||||
}
|
||||
|
||||
async function _engageElement() {
|
||||
await _ensureElementCapture();
|
||||
await _setSink(_elCtx, true);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
_elTap.active = true;
|
||||
_mode = 'element';
|
||||
console.log('[renderer-bus] engaged: <audio> element → engine bus');
|
||||
}
|
||||
|
||||
async function _reevaluate() {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
let running = false, exclusive = false;
|
||||
try {
|
||||
running = await api.isAudioRunning();
|
||||
} catch (_) { /* engine unreachable → treat as not running */ }
|
||||
if (running) {
|
||||
// Reuse the Phase 1 predicate installed by the routing watcher
|
||||
// (getCurrentDevice + exclusive-type check with change-logged
|
||||
// diagnostics). Fail closed if it is somehow absent.
|
||||
exclusive = !!(await window._juceOutputIsExclusive?.());
|
||||
}
|
||||
|
||||
// The stems plugin publishes its live graph while a multi-stem
|
||||
// song is loaded (and removes it on teardown).
|
||||
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
|
||||
// Element songs: a song is loaded, it is NOT riding the native
|
||||
// transport (Phase 1 owns those), and the stems graph is not the
|
||||
// player. Covers native-transport rejects (codec) in exclusive
|
||||
// mode — without this they would be silent.
|
||||
const songAudio = window._currentSongAudio;
|
||||
const elementSong = !!songAudio && !window._juceMode && !stems;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
// Loopback covers ALL app audio (song, previews, UI), so it
|
||||
// engages for the whole exclusive session — not just while a
|
||||
// song is loaded. Per-surface modes remain as fallback when
|
||||
// loopback capture is unavailable (old desktop main without
|
||||
// the display-media handler, capture denied).
|
||||
if (!_loopbackUnavailable) want = 'loopback';
|
||||
else if (stems) want = 'stems';
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
// Song audio riding the native transport must not ALSO ride the
|
||||
// loopback (double-carry into the same engine output). The native
|
||||
// transport plays from the engine, not the page, so page loopback
|
||||
// never hears it — no conflict; loopback stays engaged for
|
||||
// previews/UI while the transport owns the song.
|
||||
|
||||
// [asio-diag] full decision vector, change-gated (500ms poll —
|
||||
// steady state must not flood the buffer). This is the feeder-side
|
||||
// counterpart of the watcher's [feedpak-route] decision line: it
|
||||
// shows WHY the bus did or didn't engage (exclusive predicate,
|
||||
// stems graph presence, native transport ownership, element song).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
const d = 'running=' + running + ' exclusive=' + exclusive
|
||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||
+ ' juceMode=' + !!window._juceMode
|
||||
+ ' elementSong=' + elementSong
|
||||
+ ' loopbackUnavailable=' + _loopbackUnavailable
|
||||
+ ' want=' + want + ' mode=' + _mode;
|
||||
if (d !== window._lastRendererBusDecision) {
|
||||
window._lastRendererBusDecision = d;
|
||||
console.log('[asio-diag] renderer-bus:', d);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
try {
|
||||
if (want === 'loopback') await _engageLoopback();
|
||||
else if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
} catch (e) {
|
||||
if (want === 'loopback') {
|
||||
// Capture unavailable (no handler in an old desktop
|
||||
// main, permission denied) — remember and fall back to
|
||||
// the per-surface modes on the next tick.
|
||||
_loopbackUnavailable = true;
|
||||
console.warn('[renderer-bus] loopback capture unavailable — falling back to surface taps:', e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Explicit name/message/stack head — the console-message forward
|
||||
// stringifies a DOMException to the useless "[object DOMException]".
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):',
|
||||
(e && e.name ? e.name + ': ' + e.message : String(e)),
|
||||
(e && e.stack ? '| ' + String(e.stack).split('\n')[1] : ''));
|
||||
_mode = 'off';
|
||||
// A partial engage may have left the bus enabled with no producer
|
||||
// and the page muted — undo both so a failed tick can't strand
|
||||
// audio in silence until the next successful engage.
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
await _teardownLoopback().catch(() => {});
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same cadence/rationale as the routing watcher above. Also re-check on
|
||||
// visibility return so a device switch made while hidden is reconciled.
|
||||
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
|
||||
window._reevaluateRendererBus = _reevaluate;
|
||||
})();
|
||||
|
||||
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
|
||||
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
|
||||
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
|
||||
// (no stopBacking before seek — HTML5 needed that for buffering; JUCE does not).
|
||||
export let _resetJuceAudioShimChain = function () {};
|
||||
(function _installJuceAudioElementShim() {
|
||||
if (!window.feedBackDesktop?.audio) return;
|
||||
|
||||
const mediaProto = HTMLMediaElement.prototype;
|
||||
const ctDesc = Object.getOwnPropertyDescriptor(mediaProto, 'currentTime');
|
||||
const pausedDesc = Object.getOwnPropertyDescriptor(mediaProto, 'paused');
|
||||
if (!ctDesc?.get || !ctDesc?.set || !pausedDesc?.get) return;
|
||||
|
||||
const nativePlay = mediaProto.play;
|
||||
const nativePause = mediaProto.pause;
|
||||
|
||||
let chain = Promise.resolve();
|
||||
/** Same-tick pause + seek (Section Map): coalesce to one seek — no stopBacking before seek. */
|
||||
let _juceShimBatch = null;
|
||||
let _juceShimBatchFlushScheduled = false;
|
||||
let _juceShimGen = 0;
|
||||
function enqueue(fn) {
|
||||
const gen = _juceShimGen;
|
||||
const p = chain.then(async () => {
|
||||
if (gen !== _juceShimGen) return;
|
||||
return fn(gen);
|
||||
});
|
||||
chain = p.catch((e) => {
|
||||
console.warn('[juce-audio-shim]', e);
|
||||
});
|
||||
return p;
|
||||
}
|
||||
// forUpcomingPlay: caller will enqueue a play() right after, so don't
|
||||
// emit pause-state side effects for a wantsPause batch — play() will
|
||||
// overwrite them anyway.
|
||||
function flushJuceShimBatchNow({ forUpcomingPlay = false } = {}) {
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
const batch = _juceShimBatch;
|
||||
_juceShimBatch = null;
|
||||
if (!batch || !window._juceMode) return;
|
||||
const wantsPause = !!batch.wantsPause;
|
||||
const seekTime = batch.seekTime;
|
||||
if (wantsPause && seekTime !== undefined) {
|
||||
enqueue(async (gen) => {
|
||||
const r = await _audioSeek(seekTime, 'audio-element-shim');
|
||||
if (!r.completed) return; // seek cancelled by teardown
|
||||
if (gen !== _juceShimGen) return;
|
||||
if (!forUpcomingPlay) {
|
||||
await jucePlayer.pause();
|
||||
if (gen !== _juceShimGen) return;
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = false;
|
||||
sm.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
audio.dispatchEvent(new Event('seeked'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (wantsPause) {
|
||||
enqueue(async (gen) => {
|
||||
await jucePlayer.pause();
|
||||
if (gen !== _juceShimGen) return;
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = false;
|
||||
sm.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (seekTime !== undefined) {
|
||||
enqueue(async (gen) => {
|
||||
const r = await _audioSeek(seekTime, 'audio-element-shim');
|
||||
if (!r.completed) return; // seek cancelled by teardown
|
||||
if (gen !== _juceShimGen) return;
|
||||
audio.dispatchEvent(new Event('seeked'));
|
||||
});
|
||||
}
|
||||
}
|
||||
function scheduleJuceShimBatchFlush() {
|
||||
if (_juceShimBatchFlushScheduled) return;
|
||||
_juceShimBatchFlushScheduled = true;
|
||||
const flushGen = _juceShimGen;
|
||||
queueMicrotask(() => {
|
||||
if (flushGen !== _juceShimGen) {
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
return;
|
||||
}
|
||||
flushJuceShimBatchNow();
|
||||
});
|
||||
}
|
||||
_resetJuceAudioShimChain = function () {
|
||||
chain = Promise.resolve();
|
||||
_juceShimBatch = null;
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
_juceShimGen++;
|
||||
};
|
||||
|
||||
Object.defineProperty(audio, 'currentTime', {
|
||||
get() {
|
||||
if (window._juceMode) return jucePlayer.currentTime;
|
||||
return ctDesc.get.call(this);
|
||||
},
|
||||
set(v) {
|
||||
if (window._juceMode) {
|
||||
const t = Math.max(0, Number(v) || 0);
|
||||
_juceShimBatch = _juceShimBatch || {};
|
||||
_juceShimBatch.seekTime = t;
|
||||
scheduleJuceShimBatchFlush();
|
||||
return;
|
||||
}
|
||||
ctDesc.set.call(this, v);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(audio, 'paused', {
|
||||
get() {
|
||||
if (window._juceMode) return !S.isPlaying;
|
||||
return pausedDesc.get.call(this);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
audio.pause = function () {
|
||||
if (window._juceMode) {
|
||||
_juceShimBatch = _juceShimBatch || {};
|
||||
_juceShimBatch.wantsPause = true;
|
||||
scheduleJuceShimBatchFlush();
|
||||
return;
|
||||
}
|
||||
nativePause.call(audio);
|
||||
};
|
||||
|
||||
audio.play = function () {
|
||||
if (window._juceMode) {
|
||||
if (_juceShimBatch != null) flushJuceShimBatchNow({ forUpcomingPlay: true });
|
||||
const p = enqueue(async (gen) => {
|
||||
const started = await jucePlayer.play();
|
||||
if (gen !== _juceShimGen || !started) return;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
sm.emit('song:play', payload);
|
||||
sm.emit('song:resume', payload);
|
||||
}
|
||||
});
|
||||
return p.then(() => undefined);
|
||||
}
|
||||
return nativePlay.call(audio);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,29 @@
|
||||
// Shared, MUTABLE library state.
|
||||
//
|
||||
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is READ-ONLY:
|
||||
// `import { _treeStats }; _treeStats = x` throws. Of the library module's 28 outward
|
||||
// bindings, 23 are only ever READ from outside, so they stay plain exports. These five
|
||||
// are genuinely WRITTEN from outside — by showScreen (session teardown bumps the epoch,
|
||||
// resets the page), deleteSongFromModal, and syncLibrarySong, none of which can move into
|
||||
// the library module because they reach the playSong/showScreen core.
|
||||
//
|
||||
// So exactly these five move onto an object, and no more. `L.treeStats = x` is a property
|
||||
// write, which works from any module holding the same `L`. Same shape as ./player-state.js.
|
||||
//
|
||||
// Add to it when a carve actually needs it, not before — a container is a shared mutable
|
||||
// global with better manners, and every field on it is a coupling you have to keep true.
|
||||
export const L = {
|
||||
/** Library tree stats (artist -> counts), cached from /api/library/tree-stats. */
|
||||
treeStats: null,
|
||||
/** Same, for the favourites tree. */
|
||||
favTreeStats: null,
|
||||
/** Tuning names, cached from /api/library/tuning-names. */
|
||||
tuningNames: null,
|
||||
/**
|
||||
* Session generation for the library. Bumped on teardown so an in-flight page fetch
|
||||
* that resolves against a stale library can't render into the new one.
|
||||
*/
|
||||
libEpoch: 0,
|
||||
/** Current grid page (0-based). */
|
||||
currentPage: 0,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
// The A–B loop — set / clear / persist, and the saved-loops list.
|
||||
//
|
||||
// The second slice out of app.js's strongly-connected core, and it owns the loop
|
||||
// state: loopA, loopB, _loopMutationGen. Nothing outside this module writes them
|
||||
// (restartCurrentSong() looked like it did, but it declares its own local shadows).
|
||||
//
|
||||
// DIRECTION MATTERS HERE. loops and section-practice are mutually dependent — the
|
||||
// SCC in miniature. clearLoop() has to drop section-practice's selection, and
|
||||
// practiceSection() has to call setLoop(). Both directions cannot be imports or the
|
||||
// no-cycle gate (rightly) rejects it. So the edge is oriented:
|
||||
//
|
||||
// section-practice -> reaches loops through the HOST SEAM (host.setLoop, …)
|
||||
// loops -> imports section-practice DIRECTLY
|
||||
//
|
||||
// section-practice is the higher-level feature — it is a consumer of loops, not the
|
||||
// other way round — so it is the one that gets the indirection. app.js wires this
|
||||
// module's exports into the seam for it.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { esc, uiPrompt } from './dom.js';
|
||||
import { _audioSeek, _audioTime } from './transport.js';
|
||||
import { formatTime } from './format.js';
|
||||
import { host } from './host.js';
|
||||
import {
|
||||
_setSectionPracticeMode,
|
||||
_syncSectionPracticeFromLoop,
|
||||
_updateSectionPracticeHighlight,
|
||||
practiceSection,
|
||||
resetSelection,
|
||||
} from './section-practice.js';
|
||||
|
||||
// ── A-B Loop ────────────────────────────────────────────────────────────
|
||||
export let loopA = null;
|
||||
export let loopB = null;
|
||||
// Bumped on every NON-practiceSection loop mutation (direct setLoop from Saved
|
||||
// Loops / the plugin API, and clearLoop). practiceSection() captures it and bails
|
||||
// if it changes mid-retry, so a stale section retry can't overwrite a loop the
|
||||
// user just set/cleared by another path. practiceSection's own setLoop calls pass
|
||||
// skipSectionSync and do NOT bump it (they must not supersede themselves).
|
||||
export let _loopMutationGen = 0;
|
||||
|
||||
export function setLoopStart() {
|
||||
loopA = _audioTime();
|
||||
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
}
|
||||
|
||||
export function setLoopEnd() {
|
||||
if (loopA === null) return;
|
||||
loopB = _audioTime();
|
||||
if (loopB <= loopA) { loopB = null; return; }
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
|
||||
// transport event so event-driven consumers (note_detect drill sync) see
|
||||
// button-armed loops without having to poll getLoop().
|
||||
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||
}
|
||||
|
||||
export function clearLoop(options) {
|
||||
const { emitTransportEvent = true } = options || {};
|
||||
// playSong() clears the loop on every song load, so only signal a
|
||||
// loop-cleared transport event when a loop was actually active —
|
||||
// otherwise every song switch emits a spurious playback:loop-cleared.
|
||||
const hadLoop = loopA !== null || loopB !== null;
|
||||
_setSectionPracticeMode(false, { skipClearLoop: true });
|
||||
loopA = null;
|
||||
loopB = null;
|
||||
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
|
||||
document.getElementById('btn-loop-clear').classList.add('hidden');
|
||||
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||
document.getElementById('loop-label').textContent = '';
|
||||
document.getElementById('saved-loops').value = '';
|
||||
resetSelection();
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
|
||||
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
|
||||
requesterId: 'core.loop',
|
||||
reason: 'app loop cleared',
|
||||
loop: { enabled: false, state: 'inactive' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resync #saved-loops + #btn-loop-delete with the currently-active
|
||||
// loopA/loopB. Used by both setLoop's success path (so plugin-driven
|
||||
// loops show up correctly in the dropdown) and loadSavedLoop's
|
||||
// failure path (so a cancelled selection reverts to the still-active
|
||||
// loop). Without this sync, deleteSelectedLoop could target a stale
|
||||
// option that doesn't match the active loop.
|
||||
function _syncSavedLoopSelection() {
|
||||
const sel = document.getElementById('saved-loops');
|
||||
const delBtn = document.getElementById('btn-loop-delete');
|
||||
if (!sel || !delBtn) return;
|
||||
let selected = '';
|
||||
if (loopA !== null && loopB !== null) {
|
||||
for (const opt of sel.options) {
|
||||
if (Number(opt.dataset.start) === loopA && Number(opt.dataset.end) === loopB) {
|
||||
selected = opt.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
sel.value = selected;
|
||||
delBtn.classList.toggle('hidden', !selected);
|
||||
}
|
||||
|
||||
// Programmatically set both loop endpoints and seek to A. The dropdown
|
||||
// path (loadSavedLoop) and the plugin-API path (window.feedBack.setLoop)
|
||||
// both funnel through here so the UI state stays canonical regardless of
|
||||
// who triggered the loop.
|
||||
//
|
||||
// Returns true if the seek landed at A and the loop is now active;
|
||||
// returns false if the seek was cancelled by teardown or landed off-target
|
||||
// (JUCE clamp / HTML5 snap > 50ms from A). On false, loopA/loopB are NOT
|
||||
// committed and the UI is not painted — the prior loop (if any) stays
|
||||
// active. Throws on invalid inputs.
|
||||
export async function setLoop(a, b, options) {
|
||||
const { emitTransportEvent = true, skipSectionSync = false, commitGuard = null } = options || {};
|
||||
const aNum = Number(a);
|
||||
const bNum = Number(b);
|
||||
if (!Number.isFinite(aNum) || !Number.isFinite(bNum) || bNum <= aNum) {
|
||||
throw new Error(`setLoop: requires finite a and b with b > a (got a=${a}, b=${b})`);
|
||||
}
|
||||
// Don't arm loopA/loopB before the seek lands — the 60Hz tick's wrap
|
||||
// detector (`ct >= loopB`) would trigger startCountIn against
|
||||
// half-applied state.
|
||||
const r = await _audioSeek(aNum, 'loop-set');
|
||||
if (!r.completed || Math.abs(r.to - aNum) > 0.05) return false;
|
||||
// Caller-owned staleness gate, re-checked after the awaited seek and before
|
||||
// we commit loopA/loopB. practiceSection() passes this so a superseded retry
|
||||
// (newer section click, mode turned off, or song/arrangement teardown that
|
||||
// happened during the seek) does not arm a stale loop. Returning false here
|
||||
// leaves the prior loop (if any) untouched, same as the off-target path.
|
||||
if (typeof commitGuard === 'function' && !commitGuard()) return false;
|
||||
loopA = aNum;
|
||||
loopB = bNum;
|
||||
// A direct (non-practice) loop set supersedes any in-flight practiceSection
|
||||
// retry; practiceSection passes skipSectionSync and is exempt so it doesn't
|
||||
// cancel itself.
|
||||
if (!skipSectionSync) _loopMutationGen++;
|
||||
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
// Sync the saved-loops dropdown so a plugin-driven setLoop call
|
||||
// surfaces the matching saved option (and Delete button) — otherwise
|
||||
// the dropdown can stay on a stale selection and deleteSelectedLoop
|
||||
// would target the wrong record.
|
||||
_syncSavedLoopSelection();
|
||||
// practiceSection() passes skipSectionSync: it sets its own section state
|
||||
// under a request-gen guard, so the shared setLoop path must NOT re-sync
|
||||
// here — otherwise a stale (superseded / mode-off) practiceSection retry
|
||||
// that lands inside setLoop would re-arm the loop and flip the mode back on
|
||||
// before the caller's gen check can bail. Direct callers (Saved Loops,
|
||||
// window.feedBack.setLoop) still sync so their chip selection tracks.
|
||||
if (!skipSectionSync && typeof _syncSectionPracticeFromLoop === 'function') {
|
||||
_syncSectionPracticeFromLoop();
|
||||
}
|
||||
if (emitTransportEvent && typeof window !== 'undefined') {
|
||||
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateLoopUI() {
|
||||
const label = document.getElementById('loop-label');
|
||||
const hasLoop = loopA !== null && loopB !== null;
|
||||
if (hasLoop) {
|
||||
label.textContent = `${formatTime(loopA)} → ${formatTime(loopB)}`;
|
||||
document.getElementById('btn-loop-clear').classList.remove('hidden');
|
||||
document.getElementById('btn-loop-save').classList.remove('hidden');
|
||||
} else if (loopA !== null) {
|
||||
label.textContent = `${formatTime(loopA)} → ?`;
|
||||
document.getElementById('btn-loop-clear').classList.add('hidden');
|
||||
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||
} else {
|
||||
label.textContent = '';
|
||||
}
|
||||
host._updateEditRegionBtn();
|
||||
}
|
||||
|
||||
export async function loadSavedLoops() {
|
||||
const sel = document.getElementById('saved-loops');
|
||||
const delBtn = document.getElementById('btn-loop-delete');
|
||||
if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }
|
||||
|
||||
const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
|
||||
const loops = await resp.json();
|
||||
|
||||
sel.innerHTML = '<option value="">Saved Loops</option>';
|
||||
for (const l of loops) {
|
||||
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${formatTime(l.start)}→${formatTime(l.end)})</option>`;
|
||||
}
|
||||
if (loops.length > 0) {
|
||||
sel.classList.remove('hidden');
|
||||
} else {
|
||||
sel.classList.add('hidden');
|
||||
}
|
||||
delBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
export async function loadSavedLoop(loopId) {
|
||||
const sel = document.getElementById('saved-loops');
|
||||
const opt = sel.selectedOptions[0];
|
||||
const delBtn = document.getElementById('btn-loop-delete');
|
||||
if (!loopId || !opt?.dataset.start) {
|
||||
delBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
let ok = false;
|
||||
try {
|
||||
// Pass raw strings — setLoop's Number() coercion is stricter than
|
||||
// parseFloat (rejects "12abc") so malformed dataset values throw
|
||||
// and fall into the catch instead of silently truncating.
|
||||
ok = await setLoop(opt.dataset.start, opt.dataset.end);
|
||||
} catch (err) {
|
||||
// Malformed dataset (server returned bad data): treat the same as
|
||||
// a failed seek so the dropdown resyncs and we don't propagate an
|
||||
// uncaught rejection out of the onchange handler.
|
||||
console.warn('[loadSavedLoop] setLoop threw:', err);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) {
|
||||
// Seek aborted, landed off-target, or input was malformed.
|
||||
// Resync the dropdown with the still-active loop so the UI
|
||||
// doesn't lie about which loop is loaded.
|
||||
_syncSavedLoopSelection();
|
||||
return;
|
||||
}
|
||||
// Success path: setLoop already called _syncSavedLoopSelection,
|
||||
// which surfaces the delete button when the new loop matches a
|
||||
// saved option (which the dropdown selection guarantees here).
|
||||
}
|
||||
|
||||
export async function saveCurrentLoop() {
|
||||
if (loopA === null || loopB === null || !host.currentFilename()) return;
|
||||
const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' });
|
||||
if (name === null) return; // cancelled
|
||||
const finalName = name.trim() || 'Loop'; // never persist an empty name
|
||||
await fetch('/api/loops', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: decodeURIComponent(host.currentFilename()),
|
||||
name: finalName,
|
||||
start: loopA,
|
||||
end: loopB,
|
||||
}),
|
||||
});
|
||||
await loadSavedLoops();
|
||||
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||
}
|
||||
|
||||
export async function deleteSelectedLoop() {
|
||||
const sel = document.getElementById('saved-loops');
|
||||
const loopId = sel.value;
|
||||
if (!loopId) return;
|
||||
await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
|
||||
clearLoop();
|
||||
await loadSavedLoops();
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Player controls — the speed and mastery sliders, and the four playback preference
|
||||
// reads (autoplay-exit, up-next, countdown-before-song, confirm-exit).
|
||||
//
|
||||
// The fourth slice out of app.js's strongly-connected core, and by far the easiest:
|
||||
// ONE hook and NO shared mutable state. It is here because these three groups are the
|
||||
// same surface (the controls under the highway) and all three reach the same helper.
|
||||
//
|
||||
// The preference reads are one-line localStorage lookups that half of app.js consults
|
||||
// before deciding whether to auto-start, show the Up Next pill, run a count-in, or
|
||||
// confirm on exit. They travel with the controls that set them.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { host } from './host.js';
|
||||
|
||||
// ── Autoplay & auto-exit (global option, default ON) ──────────────────
|
||||
// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song
|
||||
// once it's ready and (b) returns to the launching menu when the song
|
||||
// ends. Absence of the key means enabled. The behaviour lives in core
|
||||
// (app.js, shared by the v3 + classic UIs); the end-of-song *score*
|
||||
// screen, when present, is a plugin and hooks the contract below.
|
||||
export function _autoplayExitEnabled() {
|
||||
try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
// ── "Up Next" pill (global option, default ON) ────────────────────────
|
||||
// Gates the v3 player chrome's persistent upcoming-section pill
|
||||
// (#v3-upnext, driven by player-chrome.js's updateUpNext). Client-only
|
||||
// localStorage pref (`showUpNext`); absence of the key means enabled.
|
||||
// player-chrome.js reads window.feedBack.showUpNext each tick and hides
|
||||
// the pill when off.
|
||||
export function _showUpNextEnabled() {
|
||||
try { return localStorage.getItem('showUpNext') !== '0'; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
// "Countdown before song" (Gameplay tab). Mirrored to localStorage by
|
||||
// loadSettings so the song-start path can read it synchronously here — no
|
||||
// async /api/settings fetch on the play hot path. Defaults off.
|
||||
export function _countdownBeforeSongEnabled() {
|
||||
try { return localStorage.getItem('countdownBeforeSong') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
export function _curPlaybackSpeed() {
|
||||
try {
|
||||
return window._juceMode
|
||||
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
|
||||
: (document.getElementById('audio')?.playbackRate || 1);
|
||||
} catch (_) { return 1; }
|
||||
}
|
||||
|
||||
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
||||
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
||||
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
||||
// of leaving immediately. Auto-exit on song-end and a results screen's own
|
||||
// Close never prompt — they call closeCurrentSong() directly, which stays the
|
||||
// unguarded actual-exit.
|
||||
export function _exitConfirmEnabled() {
|
||||
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
|
||||
const SPEED_SNAP_THRESHOLD = 0.02;
|
||||
let _speedPresetsWired = false;
|
||||
|
||||
function _speedPresetPctFromActive(activePctOrRate) {
|
||||
if (!Number.isFinite(activePctOrRate)) return null;
|
||||
const rate = activePctOrRate <= 1.5 ? activePctOrRate : activePctOrRate / 100;
|
||||
for (const pct of SPEED_PRESET_PCTS) {
|
||||
if (Math.abs(rate - pct / 100) <= SPEED_SNAP_THRESHOLD) return pct;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _updateSpeedPresetButtons(activePctOrRate) {
|
||||
const wrap = document.getElementById('speed-presets');
|
||||
if (!wrap) return;
|
||||
const target = _speedPresetPctFromActive(activePctOrRate);
|
||||
for (const btn of wrap.querySelectorAll('[data-speed-preset]')) {
|
||||
const pct = Number(btn.dataset.speedPreset);
|
||||
btn.classList.toggle('v3-speed-preset-active', target !== null && pct === target);
|
||||
}
|
||||
}
|
||||
|
||||
export function applySpeedPreset(percent) {
|
||||
const slider = document.getElementById('speed-slider');
|
||||
if (!slider) return;
|
||||
const pct = Math.max(
|
||||
Number(slider.min) || 15,
|
||||
Math.min(Number(slider.max) || 150, Number(percent)),
|
||||
);
|
||||
if (!Number.isFinite(pct)) return;
|
||||
slider.value = String(pct);
|
||||
host.handleSliderInput(slider);
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function _wireSpeedPresetsOnce() {
|
||||
if (_speedPresetsWired) return;
|
||||
const presets = document.getElementById('speed-presets');
|
||||
if (!presets) return;
|
||||
_speedPresetsWired = true;
|
||||
presets.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-speed-preset]');
|
||||
if (!btn) return;
|
||||
applySpeedPreset(Number(btn.dataset.speedPreset));
|
||||
});
|
||||
}
|
||||
|
||||
export function setSpeed(v) {
|
||||
const speedSlider = document.getElementById('speed-slider');
|
||||
const rate = Number(v);
|
||||
if (!Number.isFinite(rate)) {
|
||||
return;
|
||||
}
|
||||
if (window._juceMode) {
|
||||
window.jucePlayer?.setRate(rate);
|
||||
const juceAudio = window.feedBackDesktop?.audio;
|
||||
Promise.resolve()
|
||||
.then(() => juceAudio?.setBackingSpeed(rate))
|
||||
// Match the HTML5 path: preserve pitch on the JUCE backing track too.
|
||||
// Optional-chained call is a no-op on desktop builds that predate
|
||||
// setBackingPreservePitch, so this is safe to ship unconditionally.
|
||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||
.catch(err => console.warn('[setSpeed] backing speed/preserve-pitch failed:', err));
|
||||
} else {
|
||||
audio.playbackRate = rate;
|
||||
}
|
||||
const speedLabel = document.getElementById('speed-label');
|
||||
if (speedLabel) speedLabel.textContent = rate.toFixed(2) + 'x';
|
||||
host.handleSliderInput(speedSlider);
|
||||
_updateSpeedPresetButtons(rate);
|
||||
}
|
||||
|
||||
export function _resetPlaybackSpeedForNewSong() {
|
||||
// Reset the *actual* playback rate to 1x, not just the visible slider/label
|
||||
// (feedBack#615). The HTML5 <audio> element and the desktop JUCE/backing
|
||||
// engine each retain their own rate, and which one drives the next song
|
||||
// isn't decided until later in the load, so reset all paths unconditionally.
|
||||
// Every setter is idempotent and optional-chained, so this is safe in web
|
||||
// and desktop builds alike — no need to branch on window._juceMode.
|
||||
const speedSlider = document.getElementById('speed-slider');
|
||||
if (speedSlider) speedSlider.value = 100;
|
||||
audio.playbackRate = 1;
|
||||
window.jucePlayer?.setRate?.(1);
|
||||
const juceAudio = window.feedBackDesktop?.audio;
|
||||
Promise.resolve()
|
||||
.then(() => juceAudio?.setBackingSpeed?.(1))
|
||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||
.catch(err => console.warn('[resetSpeed] backing speed/preserve-pitch failed:', err));
|
||||
// Mirror setSpeed's UI side-effects (label text + slider fill styling).
|
||||
const speedLabel = document.getElementById('speed-label');
|
||||
if (speedLabel) speedLabel.textContent = (1).toFixed(2) + 'x';
|
||||
host.handleSliderInput(speedSlider);
|
||||
_updateSpeedPresetButtons(100);
|
||||
}
|
||||
// Master-difficulty slider (feedBack#48). Persists partial via
|
||||
// /api/settings — the POST handler merges only the keys present, so
|
||||
// this fire-and-forget call doesn't clobber dlc_dir or other settings.
|
||||
//
|
||||
// Debounced trailing-edge (300ms) so dragging the slider — which fires
|
||||
// oninput per pixel — doesn't flood the server with concurrent writes
|
||||
// to config.json. highway.setMastery() still fires every oninput so
|
||||
// the chart re-filters in real time; only disk persistence waits.
|
||||
let _masteryPersistTimer = null;
|
||||
function _persistMastery(pct) {
|
||||
if (_masteryPersistTimer) clearTimeout(_masteryPersistTimer);
|
||||
_masteryPersistTimer = setTimeout(() => {
|
||||
_masteryPersistTimer = null;
|
||||
fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ master_difficulty: pct }),
|
||||
}).catch(() => { /* best-effort — next setMastery() will retry */ });
|
||||
}, 300);
|
||||
}
|
||||
export function setMastery(v) {
|
||||
_applyMastery(v);
|
||||
}
|
||||
// Shared mastery applier. Master difficulty has two controls that write the
|
||||
// same master_difficulty key: the player-popover slider (#mastery-slider) and
|
||||
// the Gameplay-tab "Note highway speed" slider (#setting-highway-speed). Route
|
||||
// both — and loadSettings' hydration — through here so their positions,
|
||||
// labels, and track fills stay in sync regardless of which the user touches,
|
||||
// plus the live highway re-filter and the debounced persist. All element reads
|
||||
// are null-guarded since either control may be absent (follower window, or the
|
||||
// settings markup not yet rendered).
|
||||
export function _applyMastery(v, opts = {}) {
|
||||
// Guard + clamp: v might be a slider string, a programmatic call from a
|
||||
// plugin, or a restored settings value with a bad shape. Don't let NaN
|
||||
// reach a label (would show "NaN%") or the POST.
|
||||
const parsed = parseInt(v, 10);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const pct = Math.max(0, Math.min(100, parsed));
|
||||
const popLabel = document.getElementById('mastery-label');
|
||||
if (popLabel) popLabel.textContent = pct + '%';
|
||||
const popSlider = document.getElementById('mastery-slider');
|
||||
if (popSlider) {
|
||||
if (String(popSlider.value) !== String(pct)) popSlider.value = pct;
|
||||
host.handleSliderInput(popSlider);
|
||||
}
|
||||
const setSlider = document.getElementById('setting-highway-speed');
|
||||
if (setSlider) {
|
||||
if (String(setSlider.value) !== String(pct)) setSlider.value = pct;
|
||||
host.handleSliderInput(setSlider);
|
||||
}
|
||||
// The Gameplay-tab label markup appends a literal "%" after this span
|
||||
// (matching the av-offset "ms" pattern), so write the number alone here —
|
||||
// unlike #mastery-label above, whose markup carries no trailing unit.
|
||||
const setLabel = document.getElementById('setting-highway-speed-val');
|
||||
if (setLabel) setLabel.textContent = pct;
|
||||
highway.setMastery(pct / 100);
|
||||
if (!opts.skipPersist) _persistMastery(pct);
|
||||
}
|
||||
// Reflect phrase-data availability on the slider after every `ready`.
|
||||
// The server omits the `phrases` message entirely for single-level
|
||||
// sources (GP imports, legacy sloppak), so hasPhraseData() is the
|
||||
// right signal to enable/disable the slider.
|
||||
export function _applyMasteryAvailability(hasPhraseData) {
|
||||
const slider = document.getElementById('mastery-slider');
|
||||
if (!slider) return;
|
||||
if (hasPhraseData) {
|
||||
slider.disabled = false;
|
||||
slider.title = 'Master difficulty — low = simpler chart, high = full';
|
||||
} else {
|
||||
slider.disabled = true;
|
||||
slider.title = 'Source chart has a single difficulty level — slider disabled';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Shared, MUTABLE player state.
|
||||
//
|
||||
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is read-only. Every
|
||||
// slice carved out of app.js so far has only ever READ the state it shares
|
||||
// (loopA/loopB, _audioSeekGen, currentFilename), so a getter hook was enough and no
|
||||
// container was needed. That runs out here: count-in genuinely WRITES `isPlaying`
|
||||
// (it starts and stops playback) and `lastAudioTime`. `import { isPlaying }` then
|
||||
// `isPlaying = true` throws — the binding cannot be assigned to.
|
||||
//
|
||||
// So the state moves onto an object. `S.isPlaying = true` is a property write, which
|
||||
// works from any module holding the same `S`. This is the same shape the stems,
|
||||
// studio, and editor migrations converged on.
|
||||
//
|
||||
// It is deliberately SMALL. app.js has ~104 top-level `let` scalars; lifting all of
|
||||
// them would be a ~977-site rewrite for no benefit, since most are private to one
|
||||
// cluster and travel with it. Only the ones a carved module must WRITE belong here.
|
||||
// Add to it when a carve actually needs it, not before.
|
||||
//
|
||||
// NB app.js's own 71 reference sites were rewritten mechanically — but from the AST,
|
||||
// not by text substitution. Of 100 textual occurrences of these two names, only 71
|
||||
// resolve to the module binding: 22 are member accesses (`someObj.isPlaying`), 4 are
|
||||
// the local parameter of setPlayButtonState(isPlaying), one is an object key, and two
|
||||
// are shorthand properties (`{ isPlaying }`) that must become `{ isPlaying: S.isPlaying }`.
|
||||
// A blind find-and-replace corrupts all 29.
|
||||
export const S = {
|
||||
/** Is the transport running? Written by playback, count-in, and the JUCE shims. */
|
||||
isPlaying: false,
|
||||
|
||||
/**
|
||||
* The last audio position we saw, in seconds. Used to detect a seek that did not
|
||||
* land where it was asked to (JUCE can clamp; HTML5 can round).
|
||||
*/
|
||||
lastAudioTime: 0,
|
||||
|
||||
/**
|
||||
* A resume request armed by playSong({ resume }) and consumed on song:ready.
|
||||
* Written by app.js (playSong, and the song:ready listener that consumes it) and
|
||||
* read by the resume-session module — so, like the two above, it cannot be a plain
|
||||
* export.
|
||||
*/
|
||||
pendingResume: null,
|
||||
};
|
||||
@@ -0,0 +1,914 @@
|
||||
// The plugin loader — the R0 host rails.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). This is the highest-risk module in
|
||||
// core: it fetches /api/plugins, injects each plugin's screen.js (as
|
||||
// <script type="module"> when its manifest says scriptType:"module"), mounts nav
|
||||
// entries and screens, and wires plugin capability + UI contributions. If it
|
||||
// breaks, every plugin breaks — so every change here ends with a real plugin
|
||||
// booted against a local uvicorn, not just a green test run.
|
||||
//
|
||||
// The one thing it still needs from app.js is `window.showScreen` — already the
|
||||
// public host contract (constitution II), so it is called through `window` rather
|
||||
// than re-coupled as an import.
|
||||
//
|
||||
// `_populateVizPicker` used to arrive through a configurePluginLoader() host seam:
|
||||
// it lived in app.js, and importing app.js from here would have closed a cycle.
|
||||
// The viz layer is now its own leaf module, so the seam is GONE — this imports it
|
||||
// directly, and the graph stays acyclic without any injection.
|
||||
import { _populateVizPicker } from './viz.js';
|
||||
|
||||
let _loadPluginsInFlight = false;
|
||||
const _pluginUiContributions = new Map();
|
||||
const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu';
|
||||
|
||||
function _capabilityInspectorNavEnabled() {
|
||||
try { return localStorage.getItem(CAPABILITY_INSPECTOR_NAV_SETTING) === '1'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
// Derive a display label from a (possibly string) nav value. `/api/plugins`
|
||||
// can return `nav` as a plain string (manifest `"nav": "Declared"`) or an
|
||||
// object with a `.label`, and _pluginNav() may synthesize an object (e.g. the
|
||||
// Capability Inspector). Handle all three so string labels and the synthesized
|
||||
// label aren't dropped in favour of the plugin name.
|
||||
function _navLabel(nav, plugin) {
|
||||
if (typeof nav === 'string' && nav.trim()) return nav;
|
||||
if (nav && typeof nav === 'object' && nav.label) return nav.label;
|
||||
return (plugin && (plugin.name || plugin.id)) || '';
|
||||
}
|
||||
|
||||
function _pluginNav(plugin) {
|
||||
if (!plugin || !plugin.id) return null;
|
||||
if (plugin.id === 'capability_inspector') {
|
||||
if (!_capabilityInspectorNavEnabled()) return null;
|
||||
return plugin.nav || { label: 'Capabilities', screen: 'plugin-capability_inspector' };
|
||||
}
|
||||
return plugin.nav || null;
|
||||
}
|
||||
|
||||
async function _commandUiDomain(domain, command, plugin, payload) {
|
||||
try {
|
||||
if (!window.feedBack?.capabilities?.command) return;
|
||||
await window.feedBack.capabilities.command(domain, command, {
|
||||
requester: plugin.id || 'plugin',
|
||||
target: { id: payload.id, pluginId: plugin.id, region: payload.region },
|
||||
payload: { ...payload, pluginId: plugin.id },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`ui contribution ${command} failed for ${plugin.id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async function _registerLegacyPluginUiContributions(plugin) {
|
||||
const previous = _pluginUiContributions.get(plugin.id) || [];
|
||||
for (const contribution of previous) {
|
||||
await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution);
|
||||
}
|
||||
const contributions = [];
|
||||
const nav = _pluginNav(plugin);
|
||||
if (nav) {
|
||||
contributions.push({ domain: 'ui.navigation', id: `${plugin.id}:nav`, region: 'plugins', label: _navLabel(nav, plugin), mounted: true });
|
||||
}
|
||||
if (plugin.has_screen) {
|
||||
contributions.push({ domain: 'ui.plugin-screens', id: `${plugin.id}:screen`, region: 'plugin-screens', label: plugin.name || plugin.id, mounted: true });
|
||||
}
|
||||
if (plugin.has_settings) {
|
||||
contributions.push({ domain: 'settings', id: `${plugin.id}:settings`, region: 'plugin-settings', label: plugin.name || plugin.id, mounted: true });
|
||||
}
|
||||
if (plugin.type === 'visualization') {
|
||||
contributions.push({ domain: 'ui.player-overlays', id: `${plugin.id}:visualization`, region: 'visualization-picker', label: plugin.name || plugin.id, mounted: true });
|
||||
}
|
||||
contributions.sort((a, b) => `${a.domain}:${a.id}`.localeCompare(`${b.domain}:${b.id}`));
|
||||
_pluginUiContributions.set(plugin.id, contributions);
|
||||
for (const contribution of contributions) {
|
||||
await _commandUiDomain(contribution.domain, 'register-contribution', plugin, contribution);
|
||||
await _commandUiDomain(contribution.domain, 'mount', plugin, contribution);
|
||||
}
|
||||
}
|
||||
|
||||
// Settings-tab containers that can host plugin <details> panels on the v3
|
||||
// tabbed settings page. '#plugin-settings' is the fallback bucket (and the
|
||||
// only container in the classic v2 settings page); the per-tab containers map
|
||||
// to a plugin manifest's settings.category. A plugin with no category, or one
|
||||
// whose tab container is absent (v2, or render not yet run), falls back to
|
||||
// '#plugin-settings'. Body divs injected per plugin use id
|
||||
// `plugin-settings-<pluginId>` and live INSIDE a <details>, so they are never
|
||||
// direct children of these containers — no id collision in the scans below.
|
||||
const _PLUGIN_SETTINGS_CONTAINER_IDS = [
|
||||
'plugin-settings', 'plugin-settings-graphics',
|
||||
'plugin-settings-mic', 'plugin-settings-progression',
|
||||
];
|
||||
function _pluginSettingsContainers() {
|
||||
const out = [];
|
||||
for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) out.push(el);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function _pluginSettingsTarget(plugin) {
|
||||
const cat = plugin && plugin.settings_category;
|
||||
if (cat) {
|
||||
const el = document.getElementById('plugin-settings-' + cat);
|
||||
if (el) return el;
|
||||
}
|
||||
return document.getElementById('plugin-settings');
|
||||
}
|
||||
|
||||
export async function loadPlugins() {
|
||||
if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; }
|
||||
_loadPluginsInFlight = true;
|
||||
console.log('[feedBack] loadPlugins: start');
|
||||
let plugins;
|
||||
const navContainer = document.getElementById('nav-plugins');
|
||||
const mobileNavContainer = document.getElementById('mobile-nav-plugins');
|
||||
// Snapshot current nav so we can restore it if the fetch fails.
|
||||
const _savedNav = navContainer ? navContainer.innerHTML : null;
|
||||
const _savedMobileNav = mobileNavContainer ? mobileNavContainer.innerHTML : null;
|
||||
try {
|
||||
const resp = await fetch('/api/plugins');
|
||||
const fetchedPlugins = await resp.json();
|
||||
const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
|
||||
plugins = fetchedPlugins.slice().sort((a, b) => {
|
||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||
// plugin registry at the start of load_plugins() and repopulates it
|
||||
// incrementally while HTTP stays up, so every backend restart serves a
|
||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||
// contributions and unregistered capability participants on mere
|
||||
// absence, permanently breaking still-loaded plugins — their scripts
|
||||
// don't re-run (loadedScripts guard below), so nothing ever
|
||||
// re-registered. A genuine mid-session uninstall now leaves the
|
||||
// (already-evaluated, un-unloadable) script's contributions in place
|
||||
// until reload; its nav entry still disappears because nav is rebuilt
|
||||
// from the response each round. Same invariant as the settings/screen
|
||||
// DOM wipe and _reconcilePluginStyles below.
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
const capabilityApi = window.feedBack?.capabilities;
|
||||
if (capabilityApi?.registerParticipants) {
|
||||
capabilityApi.registerParticipants(capabilityPlugins);
|
||||
if (capabilityApi.registerCompatibilityShim) {
|
||||
for (const plugin of capabilityPlugins) {
|
||||
for (const shim of Array.isArray(plugin.compatibility_shims) ? plugin.compatibility_shims : []) {
|
||||
capabilityApi.registerCompatibilityShim(shim);
|
||||
}
|
||||
}
|
||||
}
|
||||
capabilityApi.validateRuntime?.({ phase: 'plugin-manifest-load' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[feedBack] capability manifest registration failed:', e);
|
||||
}
|
||||
|
||||
// Plugin settings panels mount into one of several tab containers —
|
||||
// see _pluginSettingsContainers()/_pluginSettingsTarget() above.
|
||||
|
||||
// Plugins whose screen.js has already been evaluated this session
|
||||
// at the current version AND whose DOM is still in the document.
|
||||
// Their listeners were bound to the existing settings / screen DOM,
|
||||
// so we must preserve that DOM — the script load guard below skips
|
||||
// re-evaluating screen.js, and a fresh empty DOM with no listeners
|
||||
// would leave the plugin half-hydrated on subsequent loadPlugins()
|
||||
// calls (e.g. the streamed refetches in _streamPluginStartup).
|
||||
//
|
||||
// The DOM-existence check is the safety net for plugins that
|
||||
// disappeared and reappeared between calls (uninstall + reinstall,
|
||||
// or a backend snapshot churn that drops a plugin then restores
|
||||
// it). In that case the loadedScripts key would still be set, but
|
||||
// any listeners are bound to elements that have since been removed
|
||||
// — drop the stale key so screen.js re-runs against the fresh DOM
|
||||
// we're about to inject.
|
||||
// Map<pluginId, version> — one entry per plugin. Storing only the
|
||||
// currently-loaded version (rather than a Set of all (id, version)
|
||||
// pairs ever loaded) means upgrade → downgrade → upgrade cycles
|
||||
// within one session don't leave stale keys that could mistakenly
|
||||
// mark an old version as already-hydrated. Coerce a legacy Set, if
|
||||
// present, to an empty Map — the previous shape never shipped.
|
||||
let loadedScripts = window.feedBack._loadedPluginScripts;
|
||||
if (!(loadedScripts instanceof Map)) {
|
||||
loadedScripts = new Map();
|
||||
window.feedBack._loadedPluginScripts = loadedScripts;
|
||||
}
|
||||
const _removePluginScriptTags = (pluginId) => {
|
||||
// Filter via dataset rather than a CSS attribute selector —
|
||||
// CSS.escape is not universally available, and plugin IDs
|
||||
// aren't constrained server-side.
|
||||
document.querySelectorAll('script[data-plugin-id]').forEach((s) => {
|
||||
if (s.dataset.pluginId === pluginId) s.remove();
|
||||
});
|
||||
};
|
||||
// Mirror of loadedScripts for the plugin `styles` capability: a single
|
||||
// versioned <link rel=stylesheet> per plugin lives in <head>, deduped by
|
||||
// id → version so an upgrade swaps it and re-activation doesn't pile up
|
||||
// duplicate tags. The <link> covers both the plugin's screen and its
|
||||
// settings panel. Plugins ship preflight-off (utilities only) CSS, so a
|
||||
// stylesheet that lingers after deactivation can't bleed a base reset.
|
||||
let loadedStyles = window.feedBack._loadedPluginStyles;
|
||||
if (!(loadedStyles instanceof Map)) {
|
||||
loadedStyles = new Map();
|
||||
window.feedBack._loadedPluginStyles = loadedStyles;
|
||||
}
|
||||
const _removePluginStyleTags = (pluginId) => {
|
||||
// Same dataset-filter rationale as _removePluginScriptTags.
|
||||
document.querySelectorAll('link[data-plugin-id]').forEach((l) => {
|
||||
if (l.dataset.pluginId === pluginId) l.remove();
|
||||
});
|
||||
};
|
||||
const _injectPluginStyles = (plugin) => {
|
||||
// Tear down a <link> we injected earlier this session when the plugin
|
||||
// no longer ships a usable stylesheet — upgraded to drop `styles`, or
|
||||
// to an invalid path — so stale CSS can't keep applying after the
|
||||
// plugin disabled its styling.
|
||||
const teardownStale = () => {
|
||||
if (loadedStyles.has(plugin.id)) {
|
||||
_removePluginStyleTags(plugin.id);
|
||||
loadedStyles.delete(plugin.id);
|
||||
}
|
||||
};
|
||||
if (!plugin.has_styles || !plugin.styles) { teardownStale(); return; }
|
||||
// `styles` is a plugin-root-relative path (like screen/script/routes)
|
||||
// and must live under assets/ so it serves through the sandboxed
|
||||
// asset route — e.g. "assets/plugin.css". Reject anything that can't
|
||||
// reach a served file or would build a malformed URL: not under
|
||||
// assets/, a `..` traversal segment, a backslash, or a `?`/`#` that
|
||||
// would collide with the cache-busting query we append. The server
|
||||
// also enforces containment via safe_join — this just avoids the
|
||||
// wasted 404 and matches the documented contract.
|
||||
const path = String(plugin.styles).replace(/^\/+/, '');
|
||||
const unsafe = !path.startsWith('assets/')
|
||||
|| /(^|\/)\.\.(\/|$)/.test(path)
|
||||
|| /[\\?#]/.test(path);
|
||||
if (unsafe) {
|
||||
console.warn(`Plugin ${plugin.id}: styles must be a path under assets/ with no "..", backslash, or query/fragment (got "${plugin.styles}") — skipping`);
|
||||
teardownStale();
|
||||
return;
|
||||
}
|
||||
const wantedVersion = plugin.version || '';
|
||||
// Idempotent: same id+version already injected → nothing to do.
|
||||
if (loadedStyles.get(plugin.id) === wantedVersion) return;
|
||||
// A different version (or none) was loaded — drop the prior <link>
|
||||
// so we never accumulate stale stylesheets across upgrades.
|
||||
_removePluginStyleTags(plugin.id);
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.dataset.pluginId = plugin.id;
|
||||
link.dataset.pluginVersion = wantedVersion;
|
||||
// Version in the URL (the plugin `version`, mirroring the screen.js
|
||||
// loader's ?v= convention) so a plugin upgrade within one session
|
||||
// fetches fresh CSS instead of a copy cached by path alone.
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
link.href = `/api/plugins/${plugin.id}/${path}${v ? `?v=${v}` : ''}`;
|
||||
// Cascade ordering: insert this <link> BEFORE core's prebuilt
|
||||
// Tailwind (/static/tailwind.min.css) instead of appending at the
|
||||
// end of <head>. A plugin that ships a full utility build — the
|
||||
// default output of running the Tailwind CLI without a scoped
|
||||
// content config — re-defines core utilities like .grid /
|
||||
// .xl:grid-cols-4; appended last, those equal-specificity rules
|
||||
// would win on source order and clobber core's responsive layout
|
||||
// (e.g. the library grid collapses to 2 columns, the nav bar
|
||||
// breaks). Loading the plugin sheet first means core wins any
|
||||
// EQUAL-specificity collision, while the plugin's own namespaced
|
||||
// classes still apply. A plugin can still deliberately override core
|
||||
// via higher-specificity selectors or !important — this only removes
|
||||
// the accidental source-order clobber.
|
||||
const coreSheet =
|
||||
document.head.querySelector('link[rel="stylesheet"][href*="tailwind.min.css"]')
|
||||
|| document.head.querySelector('link[rel="stylesheet"]');
|
||||
if (coreSheet) {
|
||||
document.head.insertBefore(link, coreSheet);
|
||||
} else {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// Drop stylesheets for plugins the response KNOWS about but that
|
||||
// are no longer ready+styled this round. _injectPluginStyles below
|
||||
// only visits plugins still returned by the API, so a newly-not-
|
||||
// ready or unstyled plugin would otherwise keep its <link>
|
||||
// applying. Plugins merely ABSENT from the response keep their
|
||||
// stylesheet — a transient partial response during a backend
|
||||
// restart is not an uninstall (same invariant as the screen/
|
||||
// settings wipe below), and stripping the <link> would leave a
|
||||
// still-loaded plugin visible but unstyled.
|
||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||
const styled = new Set(
|
||||
currentPlugins
|
||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
for (const id of Array.from(loadedStyles.keys())) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
const existingSettingsByPluginId = new Map();
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
for (const child of container.children) {
|
||||
const pid = child.dataset ? child.dataset.pluginId : null;
|
||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||
}
|
||||
}
|
||||
// Plugins named in THIS response. A plugin can be transiently absent
|
||||
// from /api/plugins — the backend clears its registry at the start of
|
||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||
// so every backend restart serves a window of partial (even empty)
|
||||
// responses. The wipe loops below must never treat that absence as an
|
||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||
// audio_engine's native signal chain (its init re-ran against the
|
||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||
// they're re-reconciled when they reappear in a later response.
|
||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||
const alreadyHydrated = new Set();
|
||||
for (const p of plugins) {
|
||||
if (!p.has_script) continue;
|
||||
// Version must match exactly — an upgrade / downgrade has to
|
||||
// re-run the new script against fresh DOM.
|
||||
if (loadedScripts.get(p.id) !== (p.version || '')) continue;
|
||||
const screenOk = !p.has_screen || !!document.getElementById(`plugin-${p.id}`);
|
||||
const settingsOk = !p.has_settings || existingSettingsByPluginId.has(p.id);
|
||||
if (screenOk && settingsOk) {
|
||||
alreadyHydrated.add(p.id);
|
||||
} else {
|
||||
// DOM was wiped externally (uninstall + reinstall, snapshot
|
||||
// churn) — drop the entry and remove the orphaned <script>
|
||||
// so screen.js re-runs against fresh DOM below.
|
||||
loadedScripts.delete(p.id);
|
||||
_removePluginScriptTags(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear plugin-owned containers, but keep already-hydrated plugins'
|
||||
// settings / screen DOM. Nav links carry no per-plugin script state,
|
||||
// so always rebuild them.
|
||||
navContainer.innerHTML = '';
|
||||
mobileNavContainer.innerHTML = '<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>';
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||
// about but that failed hydration; leave plugins absent from
|
||||
// the response untouched (see respondedIds above).
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
// dataset.pluginId is the source of truth (set on injection);
|
||||
// the id-prefix fallback covers screens injected before this
|
||||
// change shipped — both forms strip a single leading "plugin-".
|
||||
const pid = (el.dataset && el.dataset.pluginId)
|
||||
|| el.id.replace(/^plugin-/, '');
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
// collapsibles. Reveal it whenever any plugins are installed —
|
||||
// updates are relevant even for plugins that contribute no settings.
|
||||
if (plugins.length > 0) {
|
||||
const area = document.getElementById('plugin-settings-area');
|
||||
if (area) area.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Build plugin dropdown for desktop nav
|
||||
const navPlugins = plugins.map(plugin => ({ plugin, nav: _pluginNav(plugin) })).filter(entry => entry.nav);
|
||||
if (navPlugins.length > 0) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'relative';
|
||||
dropdown.innerHTML = `
|
||||
<button class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||||
Plugins
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div class="hidden absolute top-full left-0 mt-2 bg-dark-800 border border-gray-700 rounded-xl shadow-xl py-2 min-w-[180px] max-h-[80vh] overflow-y-auto z-50" id="plugin-dropdown"></div>`;
|
||||
navContainer.appendChild(dropdown);
|
||||
const ddMenu = dropdown.querySelector('#plugin-dropdown');
|
||||
|
||||
// Close the plugin dropdown when clicking outside it. Bind ONCE:
|
||||
// loadPlugins() re-runs on every plugin status change during
|
||||
// startup (SSE-driven refetches), and each run rebuilds `dropdown`
|
||||
// / `ddMenu`. A per-run addEventListener would leak a new global
|
||||
// click listener on every refetch, each closing over a now-detached
|
||||
// dropdown. The one-time handler instead resolves the LIVE dropdown
|
||||
// from the DOM at click time, so it always targets the current one.
|
||||
if (!window.feedBack._pluginDropdownOutsideClickBound) {
|
||||
window.feedBack._pluginDropdownOutsideClickBound = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
const menu = document.getElementById('plugin-dropdown');
|
||||
if (!menu) return;
|
||||
const container = menu.parentElement;
|
||||
if (container && !container.contains(e.target)) menu.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
for (const { plugin, nav } of navPlugins) {
|
||||
const screenId = `plugin-${plugin.id}`;
|
||||
// A plugin is navigable only once it's ready. While its deps
|
||||
// install (status "installing") or after a failed load
|
||||
// (status "failed") we still render the nav slot — disabled,
|
||||
// with an "installing…" suffix or the error as a tooltip — so
|
||||
// the nav is stable and the user sees the plugin is coming
|
||||
// (#421). Entries without a status (legacy / stub) are ready.
|
||||
const status = plugin.status || 'ready';
|
||||
const isReady = status === 'ready';
|
||||
// nav is truthy here (navPlugins is filtered on entry.nav), and
|
||||
// is the computed value from _pluginNav() — which may be a
|
||||
// string, an object that omits `label`, or a synthesized object
|
||||
// (e.g. the Capability Inspector). _navLabel() normalizes all
|
||||
// three and falls back to name/id so a missing label never
|
||||
// renders "undefined" or throws. Use the loop's `nav`, not the
|
||||
// raw `plugin.nav`, so string and synthesized labels survive.
|
||||
const label = _navLabel(nav, plugin);
|
||||
|
||||
const item = document.createElement('a');
|
||||
item.href = '#';
|
||||
ddMenu.appendChild(item);
|
||||
// Mobile nav — flat list
|
||||
const ma = document.createElement('a');
|
||||
ma.href = '#';
|
||||
mobileNavContainer.appendChild(ma);
|
||||
|
||||
if (isReady) {
|
||||
item.className = 'block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-dark-700 transition';
|
||||
item.textContent = label;
|
||||
item.onclick = (e) => { e.preventDefault(); ddMenu.classList.add('hidden'); window.showScreen(screenId); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||
ma.className = 'text-gray-400 hover:text-white pl-4 text-sm';
|
||||
ma.textContent = label;
|
||||
ma.onclick = (e) => { e.preventDefault(); window.showScreen(screenId); ma.closest('#mobile-menu').classList.add('hidden'); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||
} else {
|
||||
const installing = status === 'installing';
|
||||
const suffix = installing ? ' (installing…)' : ' (failed)';
|
||||
const tip = installing
|
||||
? 'This plugin is installing its dependencies and will become available shortly.'
|
||||
: (plugin.error || 'This plugin failed to load. Check the server startup log for details.');
|
||||
// Disabled appearance: dimmed, default cursor, no nav handler.
|
||||
const cls = 'block px-4 py-2 text-sm text-gray-600 cursor-default select-none'
|
||||
+ (installing ? ' animate-pulse' : '');
|
||||
item.className = cls;
|
||||
item.setAttribute('aria-disabled', 'true');
|
||||
item.title = tip;
|
||||
item.textContent = label + suffix;
|
||||
// Drop disabled entries out of the tab order and strip the
|
||||
// href so keyboard/screen-reader users don't land on a
|
||||
// non-actionable "link" (a11y). Swallow clicks too, in case
|
||||
// it's still reached via mouse.
|
||||
item.removeAttribute('href');
|
||||
item.setAttribute('tabindex', '-1');
|
||||
item.onclick = (e) => { e.preventDefault(); };
|
||||
ma.className = 'pl-4 text-sm text-gray-600 cursor-default select-none' + (installing ? ' animate-pulse' : '');
|
||||
ma.setAttribute('aria-disabled', 'true');
|
||||
ma.title = tip;
|
||||
ma.textContent = label + suffix;
|
||||
ma.removeAttribute('href');
|
||||
ma.setAttribute('tabindex', '-1');
|
||||
ma.onclick = (e) => { e.preventDefault(); };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down stylesheets for plugins that are gone / no longer styled
|
||||
// before (re)injecting for the current set.
|
||||
_reconcilePluginStyles(plugins);
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
// Only ready plugins have their assets available (the backend
|
||||
// guards screen.html/screen.js/settings.html on status=="ready").
|
||||
// Installing/failed plugins contribute only the disabled nav slot
|
||||
// built above — skip screen/settings/script injection for them.
|
||||
if (plugin.status && plugin.status !== 'ready') continue;
|
||||
await _registerLegacyPluginUiContributions(plugin);
|
||||
const screenId = `plugin-${plugin.id}`;
|
||||
|
||||
// Inject the plugin's stylesheet FIRST (before screen HTML/JS) so
|
||||
// its utilities are present on first paint. Idempotent + version-
|
||||
// deduped, so it's safe to call for already-hydrated plugins too.
|
||||
_injectPluginStyles(plugin);
|
||||
|
||||
// Inject screen container. Skip for already-hydrated plugins —
|
||||
// their existing screen DOM still has the listeners that
|
||||
// screen.js bound on first load (rebuilding here would orphan
|
||||
// them, since the script load guard further down won't re-run
|
||||
// screen.js to re-bind).
|
||||
if (plugin.has_screen && !alreadyHydrated.has(plugin.id)) {
|
||||
const screenDiv = document.createElement('div');
|
||||
screenDiv.id = screenId;
|
||||
screenDiv.className = 'screen';
|
||||
screenDiv.dataset.pluginId = plugin.id;
|
||||
screenDiv.dataset.pluginVersion = plugin.version || '';
|
||||
// Insert before the player screen
|
||||
const player = document.getElementById('player');
|
||||
player.parentNode.insertBefore(screenDiv, player);
|
||||
|
||||
const htmlResp = await fetch(`/api/plugins/${plugin.id}/screen.html`);
|
||||
screenDiv.innerHTML = await htmlResp.text();
|
||||
}
|
||||
|
||||
// Inject settings section — wrapped in a collapsible <details>
|
||||
// per plugin so the page stays scannable as plugins accumulate.
|
||||
// Collapsed by default; <details>/<summary> handles state natively.
|
||||
// Skip for already-hydrated plugins — preserved details element
|
||||
// still carries listeners wired by its inline settings script
|
||||
// and by screen.js on first load.
|
||||
// Resolve which settings tab this plugin's panel mounts under
|
||||
// (manifest settings.category), falling back to '#plugin-settings'.
|
||||
const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null;
|
||||
if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group';
|
||||
details.dataset.pluginId = plugin.id;
|
||||
details.dataset.pluginVersion = plugin.version || '';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
// .plugin-settings-summary class hides the browser's native
|
||||
// disclosure triangle (see style.css) so only our chevron shows.
|
||||
// flex-col allows the fallback explanation note to appear below
|
||||
// the name/badges row when plugin.fallback is set.
|
||||
summary.className = 'plugin-settings-summary cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-300 hover:bg-dark-700/70 transition flex flex-col';
|
||||
// Inner row: plugin name/badges (left) + chevron (right).
|
||||
const headerRow = document.createElement('span');
|
||||
headerRow.className = 'flex items-center justify-between';
|
||||
const labelWrap = document.createElement('span');
|
||||
labelWrap.className = 'flex items-center gap-2';
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = plugin.name || plugin.id;
|
||||
labelWrap.appendChild(labelSpan);
|
||||
// "Bundled" marker (feedBack#160). Visually distinguishes
|
||||
// plugins that ship with the default container image from
|
||||
// user-installed ones so users don't try to remove a core
|
||||
// plugin via the manage-plugin flow and brick a feature
|
||||
// that's expected to "just work".
|
||||
if (plugin.bundled) {
|
||||
const bundledDesc = 'This plugin ships with FeedBack core and is expected to be present.';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-purple-400/30 bg-purple-500/10 text-purple-300';
|
||||
badge.title = bundledDesc;
|
||||
badge.setAttribute('aria-label', 'Bundled — ' + bundledDesc);
|
||||
badge.setAttribute('role', 'img');
|
||||
badge.innerHTML = `
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3zM6 11h12a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2v-6a2 2 0 012-2z"/>
|
||||
</svg>
|
||||
Bundled
|
||||
`;
|
||||
labelWrap.appendChild(badge);
|
||||
}
|
||||
// "Fallback" warning badge: the bundled copy failed to load its
|
||||
// routes, so the server fell back to this older user-installed
|
||||
// copy. Warn users so they know the bundled build is broken and
|
||||
// can check the server startup log for the root cause.
|
||||
if (plugin.fallback) {
|
||||
const fbBadge = document.createElement('span');
|
||||
fbBadge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-yellow-400/40 bg-yellow-500/10 text-yellow-300';
|
||||
fbBadge.setAttribute('aria-hidden', 'true');
|
||||
fbBadge.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg> Fallback';
|
||||
labelWrap.appendChild(fbBadge);
|
||||
}
|
||||
// Assemble inner header row: [name/badges (left)] [chevron (right)].
|
||||
// Both are placed in headerRow so the fallback note (if any)
|
||||
// can sit below the entire row as a second flex-col child of
|
||||
// summary, rather than being squeezed inline beside the chevron.
|
||||
headerRow.appendChild(labelWrap);
|
||||
// Chevron icon — built via setAttributeNS so the SVG sits in
|
||||
// the SVG namespace and renders correctly. Plugin label is
|
||||
// appended as text above so manifest values can't inject HTML.
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('class', 'w-4 h-4 text-gray-500 transition-transform group-open:rotate-180');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
const svgPath = document.createElementNS(svgNS, 'path');
|
||||
svgPath.setAttribute('stroke-linecap', 'round');
|
||||
svgPath.setAttribute('stroke-linejoin', 'round');
|
||||
svgPath.setAttribute('stroke-width', '2');
|
||||
svgPath.setAttribute('d', 'M19 9l-7 7-7-7');
|
||||
svg.appendChild(svgPath);
|
||||
headerRow.appendChild(svg);
|
||||
summary.appendChild(headerRow);
|
||||
// Fallback explanation note: a visible <p> below the header row,
|
||||
// accessible to touch/keyboard users (browser tooltip via title/
|
||||
// aria-label alone is hover-only and insufficient). Appended to
|
||||
// summary (not labelWrap) so it renders as the second child in
|
||||
// summary's flex-col layout, appearing below the name+badges row.
|
||||
if (plugin.fallback) {
|
||||
const fbNote = document.createElement('span');
|
||||
fbNote.className = 'block text-xs text-yellow-300/80 mt-1';
|
||||
fbNote.textContent = 'The bundled version failed to start. This user-installed copy is serving as a fallback. Check the server startup log for details.';
|
||||
summary.appendChild(fbNote);
|
||||
}
|
||||
details.appendChild(summary);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.id = `plugin-settings-${plugin.id}`;
|
||||
body.className = 'px-4 py-4 border-t border-gray-800 space-y-4';
|
||||
details.appendChild(body);
|
||||
|
||||
settingsTarget.appendChild(details);
|
||||
|
||||
const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`);
|
||||
body.innerHTML = await settingsResp.text();
|
||||
// <script> tags inserted via innerHTML are intentionally
|
||||
// inert per the HTML5 spec — the browser parses them as
|
||||
// DOM nodes but never runs the body. That silently breaks
|
||||
// any plugin settings.html that wires event handlers via
|
||||
// addEventListener (e.g. file pickers, anything that
|
||||
// can't be expressed as an inline onclick=… attribute),
|
||||
// and any inline IIFE that hydrates form values from
|
||||
// localStorage. Re-create each script node — script
|
||||
// elements created via document.createElement DO execute
|
||||
// when appended — so plugins get the script behavior
|
||||
// they'd expect from a normal HTML document.
|
||||
body.querySelectorAll('script').forEach(oldScript => {
|
||||
const newScript = document.createElement('script');
|
||||
for (const attr of oldScript.attributes) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
newScript.textContent = oldScript.textContent;
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Load plugin JS
|
||||
if (plugin.has_script) {
|
||||
const wantedVersion = plugin.version || '';
|
||||
if (loadedScripts.get(plugin.id) !== wantedVersion) {
|
||||
// A different version (or none) was loaded previously —
|
||||
// remove the prior <script> tag for this plugin id so we
|
||||
// don't accumulate stale versions on upgrade/downgrade.
|
||||
_removePluginScriptTags(plugin.id);
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
// Include version in URL so a plugin upgrade within the
|
||||
// same browser session fetches the new screen.js instead
|
||||
// of a cached copy keyed only by path (matches the art
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
const query = v ? `?v=${v}` : '';
|
||||
script.src = _pluginScriptUrl(plugin, wantedVersion, query);
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
// only after its whole static-import graph evaluates, so
|
||||
// the await-onload completion + _loadingPluginId contract
|
||||
// below is preserved (a classic-IIFE dynamic import()
|
||||
// would not). Classic plugins are unaffected.
|
||||
if (plugin.script_type === 'module') script.type = 'module';
|
||||
script.dataset.pluginId = plugin.id;
|
||||
script.dataset.pluginVersion = wantedVersion;
|
||||
window.feedBack._loadingPluginId = plugin.id;
|
||||
script.onload = () => {
|
||||
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||
loadedScripts.set(plugin.id, wantedVersion);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = (err) => {
|
||||
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||
loadedScripts.delete(plugin.id);
|
||||
reject(err);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Plugin '${plugin.id}' failed to load, skipping:`, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load plugins:', e);
|
||||
// Restore nav so a failed re-hydration call doesn't leave it blank.
|
||||
if (_savedNav !== null && navContainer) navContainer.innerHTML = _savedNav;
|
||||
if (_savedMobileNav !== null && mobileNavContainer) mobileNavContainer.innerHTML = _savedMobileNav;
|
||||
_loadPluginsInFlight = false;
|
||||
return null;
|
||||
}
|
||||
_loadPluginsInFlight = false;
|
||||
return plugins;
|
||||
}
|
||||
|
||||
// Re-run loadPlugins (and the viz picker, since a newly-ready plugin may
|
||||
// register a window.feedBackViz_<id> factory) when plugin status changes.
|
||||
// Debounced so a burst of plugin-registered/plugin-error events during
|
||||
// startup collapses into a single refetch.
|
||||
let _pluginRefreshTimer = null;
|
||||
function _refreshPluginsSoon() {
|
||||
clearTimeout(_pluginRefreshTimer);
|
||||
_pluginRefreshTimer = setTimeout(async () => {
|
||||
const plugins = await loadPlugins();
|
||||
if (plugins) {
|
||||
_populateVizPicker(plugins);
|
||||
} else {
|
||||
// loadPlugins() returned null because a refetch was already in
|
||||
// flight, so this status change would otherwise be dropped. Re-arm
|
||||
// the debounce so the newer state is still applied once the
|
||||
// in-flight load finishes. Reuses the 250ms delay (and the
|
||||
// in-flight guard clears quickly), so this can't tight-loop.
|
||||
_refreshPluginsSoon();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
let _pluginStreamStarted = false;
|
||||
function _streamPluginStartup() {
|
||||
// Watch the SAME /api/startup-status/stream the splash used to gate on.
|
||||
// Instead of blocking, we let the nav render immediately (loadPlugins ran
|
||||
// already) and refetch whenever a plugin graduates to ready or fails — so
|
||||
// its nav slot flips from "installing…" to active/failed without a reload
|
||||
// (#421). loadPlugins is idempotent (in-flight guard + version map), so
|
||||
// extra refetches are cheap and safe.
|
||||
if (_pluginStreamStarted) return;
|
||||
_pluginStreamStarted = true;
|
||||
|
||||
if (typeof EventSource === 'undefined') { _pollPluginStartup(); return; }
|
||||
|
||||
const es = new EventSource('/api/startup-status/stream');
|
||||
es.onmessage = (event) => {
|
||||
let status;
|
||||
try { status = JSON.parse(event.data); } catch { return; }
|
||||
if (!status || status.type === 'keepalive') return;
|
||||
const phase = (status.phase || '').trim();
|
||||
if (phase === 'plugin-registered' || phase === 'plugin-error') {
|
||||
_refreshPluginsSoon();
|
||||
}
|
||||
// Terminal: one last refetch to catch anything missed, then stop.
|
||||
if (!status.running && (phase === 'complete' || phase === 'error')) {
|
||||
_refreshPluginsSoon();
|
||||
es.close();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// Stream dropped (proxy buffering, backend hiccup). Stop retrying the
|
||||
// stream and fall back to a bounded poll so late installs still surface.
|
||||
es.close();
|
||||
_pollPluginStartup();
|
||||
};
|
||||
}
|
||||
|
||||
let _pollStartupStarted = false;
|
||||
async function _pollPluginStartup() {
|
||||
// SSE-unavailable fallback: poll /api/startup-status until the backend
|
||||
// finishes its plugin loader, refetching whenever the ready count changes
|
||||
// or it goes terminal. Bounded so a backend that never finishes doesn't
|
||||
// poll forever.
|
||||
if (_pollStartupStarted) return;
|
||||
_pollStartupStarted = true;
|
||||
// Generous headroom over the documented worst case (whisperx → torch et al.
|
||||
// can take 20-30 min): a 30-min ceiling would stop polling right as a
|
||||
// slipping install — slow mirror, pip retry — actually finishes. 60 min
|
||||
// leaves margin so the late graduation still surfaces. (#421)
|
||||
const DEADLINE_MS = 60 * 60 * 1000;
|
||||
const start = Date.now();
|
||||
// Track a composite signature, not just the ready count: a plugin can fail
|
||||
// (phase → "plugin-error", current_plugin/error change) without changing
|
||||
// `loaded`, e.g. the next plugin breaks after all prior ones succeeded.
|
||||
// Watching only `loaded` would miss that transition until some later
|
||||
// ready-count change or terminal completion, so the failed/error nav state
|
||||
// wouldn't surface. Refetch whenever any of these move.
|
||||
let lastSig = null;
|
||||
while (Date.now() - start < DEADLINE_MS) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
const resp = await fetch('/api/startup-status');
|
||||
if (!resp.ok) continue;
|
||||
const status = await resp.json();
|
||||
const sig = JSON.stringify([
|
||||
Number(status.loaded || 0),
|
||||
status.phase || '',
|
||||
status.current_plugin || '',
|
||||
status.error || '',
|
||||
]);
|
||||
if (sig !== lastSig) { lastSig = sig; _refreshPluginsSoon(); }
|
||||
if (!status.running) { _refreshPluginsSoon(); return; }
|
||||
} catch (_e) { /* network error — keep trying */ }
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapPluginsAndUi() {
|
||||
// #421: never gate the nav on full plugin startup. Render it immediately
|
||||
// from /api/plugins (ready plugins active; installing/failed disabled),
|
||||
// then stream plugin status so each entry resolves in place as its
|
||||
// dependencies finish installing or its load fails.
|
||||
const plugins = await loadPlugins();
|
||||
_streamPluginStartup();
|
||||
return plugins;
|
||||
}
|
||||
|
||||
|
||||
// ── Plugin updates ──────────────────────────────────────────────────────
|
||||
// The Settings-screen "Check for updates" / "Update" buttons. Carved out of
|
||||
// app.js (R3a) into the loader rather than a module of their own: this is plugin
|
||||
// MANAGEMENT, it belongs with the code that loads them. Both are inline handlers,
|
||||
// so app.js re-exposes them on window.
|
||||
|
||||
export async function checkPluginUpdates() {
|
||||
const btn = document.getElementById('btn-check-updates');
|
||||
const status = document.getElementById('updates-status');
|
||||
const list = document.getElementById('plugin-updates-list');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Checking...';
|
||||
status.textContent = '';
|
||||
list.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetch('/api/plugins/updates');
|
||||
const data = await resp.json();
|
||||
const updates = data.updates || {};
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length === 0) {
|
||||
status.textContent = 'All plugins are up to date.';
|
||||
} else {
|
||||
status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`;
|
||||
for (const id of keys) {
|
||||
const u = updates[id];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2';
|
||||
row.innerHTML = `
|
||||
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local} → ${u.remote})</span></span>
|
||||
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = 'Failed to check for updates.';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Check for Updates';
|
||||
}
|
||||
|
||||
// ── Module re-evaluation (#879) ─────────────────────────────────────────────
|
||||
//
|
||||
// ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||
// <script type="module"> whose src the module map has already seen fires `load` but
|
||||
// does NOT re-run the body. So a ROLLBACK — reloading a version already evaluated
|
||||
// this session — silently kept the OLD module live, while onload fired and
|
||||
// loadedScripts recorded the rollback as applied. A no-op that reported success.
|
||||
// (Upgrades were fine: a new version means a new ?v=, hence a new URL.)
|
||||
//
|
||||
// Busting the ENTRY url alone does NOT fix it. A module plugin's screen.js is a
|
||||
// one-line `import './src/main.js'`, and a relative specifier resolves against the
|
||||
// base URL WITH THE QUERY STRING DROPPED — so ?v= never reaches the graph, and
|
||||
// src/main.js (where the plugin actually lives) stays cached no matter what we hang
|
||||
// off screen.js.
|
||||
//
|
||||
// So the token goes in the PATH. From /api/plugins/x/g/7/screen.js, './src/main.js'
|
||||
// resolves to /api/plugins/x/g/7/src/main.js — every relative import in the graph
|
||||
// inherits it, at every depth, with no import-specifier rewriting (which could not
|
||||
// see `import(expr)` anyway). The server ignores the token and serves identical
|
||||
// bytes.
|
||||
//
|
||||
// ─── AND THE UPGRADE PATH WAS BROKEN TOO ────────────────────────────────────
|
||||
//
|
||||
// #879 says "upgrades are fine — a new version yields a new URL". That is true of
|
||||
// screen.js and FALSE of the plugin. Driving a real browser through
|
||||
// install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0) and counting evaluations of
|
||||
// src/main.js gives ONE. Not two, not three: ONE. The upgrade re-evaluates the
|
||||
// one-line screen.js shim at its new ?v= URL, that shim imports './src/main.js',
|
||||
// that resolves to the same URL as before, and the module map hands back the
|
||||
// ALREADY-EVALUATED v1.0.0 module. The plugin's actual code never re-ran.
|
||||
//
|
||||
// So the generation token is not a rollback special case. EVERY re-load of a module
|
||||
// plugin needs it — the key is the plugin id, NOT id@version. Only the first load of
|
||||
// a given plugin in this document takes the stable URL, which is what keeps the
|
||||
// ETag/304 live-edit contract the R0 rails depend on.
|
||||
const _evaluatedModules = new Set(); // plugin ids whose module graph is live in this document
|
||||
let _moduleReloadSeq = 0;
|
||||
|
||||
function _pluginScriptUrl(plugin, wantedVersion, query) {
|
||||
const base = `/api/plugins/${plugin.id}/screen.js${query}`;
|
||||
if (plugin.script_type !== 'module') return base; // classic scripts always re-run
|
||||
if (!_evaluatedModules.has(plugin.id)) {
|
||||
_evaluatedModules.add(plugin.id);
|
||||
return base; // first load: stable URL, 304-able
|
||||
}
|
||||
// Re-load of a module plugin — upgrade OR rollback. Its graph is already in the
|
||||
// module map, so it needs an entirely fresh path or nothing below screen.js re-runs.
|
||||
return `/api/plugins/${plugin.id}/g/${++_moduleReloadSeq}/screen.js${query}`;
|
||||
}
|
||||
|
||||
export async function updatePlugin(pluginId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Updating...';
|
||||
try {
|
||||
const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
btn.textContent = 'Updated — restart to apply';
|
||||
btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs';
|
||||
} else {
|
||||
btn.textContent = 'Failed';
|
||||
btn.title = data.error || '';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Resume last session — the snapshot taken when you leave a song, and the pill that
|
||||
// offers it back.
|
||||
//
|
||||
// The fifth slice out of app.js's strongly-connected core. Small and self-contained:
|
||||
// ONE hook (playSong) plus a currentFilename getter.
|
||||
//
|
||||
// The armed resume request itself lives on the shared container as S.pendingResume,
|
||||
// not here, because app.js WRITES it — playSong({ resume }) arms it and the song:ready
|
||||
// listener consumes it — while this module reads it. An imported binding is read-only,
|
||||
// so shared mutable state has to live on the container. Same reason isPlaying does.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { host } from './host.js';
|
||||
import { _curPlaybackSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
// Leaving a song snapshots where you were — song, arrangement, position, and
|
||||
// speed — so an exit (especially an accidental one, now that Escape reliably
|
||||
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
|
||||
// The snapshot is offered back through a non-blocking "Resume" pill; it never
|
||||
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
|
||||
// (This is the player-session slice; the broader nav/state-resume work — e.g.
|
||||
// returning to a song after wandering into Settings → Tone Builder — is a
|
||||
// separate, larger track.)
|
||||
const _RESUME_KEY = 'feedBack.resumeSession';
|
||||
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
|
||||
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
|
||||
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
export function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!host.currentFilename()) return;
|
||||
const si = (window.highway && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
// glance at the first seconds, and not one that already basically ended.
|
||||
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
|
||||
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
|
||||
const snap = {
|
||||
f: host.currentFilename(),
|
||||
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
|
||||
? si.arrangement_index : undefined,
|
||||
t: pos,
|
||||
sp: _curPlaybackSpeed(),
|
||||
title: si.title || '',
|
||||
artist: si.artist || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
|
||||
// A fresh snapshot earns one offer — undo any earlier dismissal.
|
||||
_resumePillDismissed = false;
|
||||
} catch (_) { /* storage unavailable — resume is best-effort */ }
|
||||
}
|
||||
|
||||
export function _readResumeSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_RESUME_KEY);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
|
||||
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
|
||||
return snap;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
export function _clearResumeSession() {
|
||||
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-enter the snapshotted song and restore arrangement + position + speed.
|
||||
export async function resumeLastSession() {
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return false; }
|
||||
_hideResumePill();
|
||||
try {
|
||||
await host.playSong(snap.f, snap.a, {
|
||||
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
// A transient load/connect failure must not strand the user: keep the
|
||||
// snapshot so the pill can re-offer it on the next non-player screen,
|
||||
// rather than consuming the only copy before the song actually loaded.
|
||||
console.warn('[app] resume failed to load; keeping snapshot:', err);
|
||||
S.pendingResume = null;
|
||||
return false;
|
||||
}
|
||||
_clearResumeSession(); // consumed only after a successful load
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resume pill (non-blocking "continue where you left off") ────────────────
|
||||
// Self-contained, inline-styled, body-appended so it works identically in the
|
||||
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
|
||||
// the player screen, never blocks, and a dismiss forgets the current snapshot
|
||||
// for the session.
|
||||
export function _hideResumePill() {
|
||||
const el = document.getElementById('fb-resume-pill');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
export function _maybeShowResumePill() {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') { _hideResumePill(); return; }
|
||||
if (_resumePillDismissed) return;
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return; }
|
||||
if (document.getElementById('fb-resume-pill')) return; // already shown
|
||||
|
||||
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
|
||||
const pill = document.createElement('div');
|
||||
pill.id = 'fb-resume-pill';
|
||||
pill.setAttribute('role', 'status');
|
||||
pill.style.cssText = [
|
||||
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
|
||||
'display:flex', 'align-items:center', 'gap:10px',
|
||||
'max-width:min(90vw,360px)', 'padding:10px 12px',
|
||||
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
|
||||
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.style.cssText = 'flex:1;min-width:0';
|
||||
const t1 = document.createElement('div');
|
||||
t1.textContent = 'Resume practice';
|
||||
t1.style.cssText = 'font-weight:600;color:#fff';
|
||||
const t2 = document.createElement('div');
|
||||
t2.textContent = label;
|
||||
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
text.appendChild(t1); text.appendChild(t2);
|
||||
|
||||
const resumeBtn = document.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.textContent = 'Resume ▸';
|
||||
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '✕';
|
||||
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
|
||||
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
|
||||
|
||||
pill.appendChild(text);
|
||||
pill.appendChild(resumeBtn);
|
||||
pill.appendChild(dismissBtn);
|
||||
(document.body || document.documentElement).appendChild(pill);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
||||
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
||||
// a best-effort localStorage merge) — the rationale comment below is the contract
|
||||
// and moved with the code.
|
||||
|
||||
//
|
||||
// Bundles server config + every localStorage key + opted-in plugin server
|
||||
// files into a single JSON file.
|
||||
//
|
||||
// Apply semantics — phased, NOT all-or-nothing across the two stores:
|
||||
// 1. Server first (/api/settings/import). Phase-1 validation guards
|
||||
// the whole bundle; phase-2 disk commit is per-file but ordered
|
||||
// so a mid-apply failure surfaces a `partial` field. A server
|
||||
// failure short-circuits before any localStorage write, so the
|
||||
// browser side stays untouched on validation refusals.
|
||||
// 2. localStorage second, only after the server returns ok. Applied
|
||||
// as a MERGE (no clear): bundled keys overwrite, locally-present
|
||||
// keys absent from the bundle are preserved (so a plugin
|
||||
// installed after the export keeps its first-run defaults).
|
||||
// A localStorage exception here (quota / private mode) is
|
||||
// surfaced verbatim — server state is already committed and we
|
||||
// don't pretend the import was clean.
|
||||
//
|
||||
// In short: the server side is atomic in phase 1 and surface-partial in
|
||||
// phase 2; the localStorage side is best-effort merge after server
|
||||
// success. Failures are reported, never silenced.
|
||||
|
||||
export async function exportSettings() {
|
||||
const status = document.getElementById('backup-status');
|
||||
status.textContent = 'Exporting...';
|
||||
try {
|
||||
const resp = await fetch('/api/settings/export');
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Export failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
const bundle = await resp.json();
|
||||
// Layer in the browser's localStorage. Use the standard Storage
|
||||
// iteration API (length + key(i)) rather than Object.keys —
|
||||
// Object.keys on a Storage instance is not deterministic across
|
||||
// browsers and can both miss entries and include non-entry
|
||||
// properties depending on the implementation. Keys are preserved
|
||||
// verbatim as strings; that's how localStorage stores them, and
|
||||
// round-trip fidelity matters more than re-typing values that
|
||||
// were never typed in the first place.
|
||||
const localStorageData = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key === null) continue;
|
||||
const value = localStorage.getItem(key);
|
||||
if (value !== null) localStorageData[key] = value;
|
||||
}
|
||||
bundle.local_storage = localStorageData;
|
||||
|
||||
// Trigger download via blob + temporary <a download>. We honor the
|
||||
// server's Content-Disposition filename when present, otherwise
|
||||
// fall back to a date-stamped default.
|
||||
let filename = 'feedBack-settings.json';
|
||||
const disposition = resp.headers.get('Content-Disposition');
|
||||
if (disposition) {
|
||||
const match = /filename="([^"]+)"/.exec(disposition);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importSettings(file) {
|
||||
if (!file) return;
|
||||
const status = document.getElementById('backup-status');
|
||||
if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) {
|
||||
status.textContent = 'Import cancelled';
|
||||
return;
|
||||
}
|
||||
let bundle;
|
||||
try {
|
||||
bundle = JSON.parse(await file.text());
|
||||
} catch (e) {
|
||||
status.textContent = `Import failed: not valid JSON (${e.message})`;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Importing...';
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch('/api/settings/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
status.textContent = `Import failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
// Two failure shapes to surface: our own validation handler
|
||||
// returns `{ok: false, error: "..."}`, but if the body fails
|
||||
// FastAPI's request-level validation (e.g. top-level value is
|
||||
// an array, not an object), the response is the framework's
|
||||
// `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes
|
||||
// both from success without depending on which path produced
|
||||
// the failure.
|
||||
if (!resp.ok || data.ok === false) {
|
||||
let msg = data.error;
|
||||
if (!msg && data.detail) {
|
||||
msg = typeof data.detail === 'string'
|
||||
? data.detail
|
||||
: JSON.stringify(data.detail);
|
||||
}
|
||||
status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Server applied successfully. Now apply the localStorage portion as
|
||||
// a MERGE (not clear+restore): keys in the bundle overwrite, keys
|
||||
// present locally but absent from the bundle are preserved. This
|
||||
// matters when a plugin was installed *after* the export — wiping
|
||||
// its localStorage would erase first-run defaults the plugin set on
|
||||
// load, leaving it in a worse state than before the import. The
|
||||
// tradeoff is that orphan keys from removed plugins or renamed key
|
||||
// schemes also linger; cleaning those up is the user's job.
|
||||
const ls = bundle.local_storage;
|
||||
if (ls && typeof ls === 'object') {
|
||||
try {
|
||||
for (const [key, value] of Object.entries(ls)) {
|
||||
if (typeof value === 'string') localStorage.setItem(key, value);
|
||||
}
|
||||
} catch (e) {
|
||||
// Quota exceeded / private mode etc. Server side already
|
||||
// committed, so we surface the partial state rather than
|
||||
// pretending it succeeded.
|
||||
status.textContent = `Server applied, but localStorage write failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const warnings = (data.warnings || []).join('; ');
|
||||
status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...';
|
||||
setTimeout(() => location.reload(), 800);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// The playback transport — the play/pause/seek core, and the two clocks it reads.
|
||||
//
|
||||
// WHY THIS IS A MODULE AND NOT A HOOK BUNDLE. Every carve before this one ADDED host
|
||||
// hooks: a module pulled out of app.js still had to call back into it. This one SUBTRACTS
|
||||
// them. count-in, juce-audio, loops, and section-practice were all reaching through the
|
||||
// seam for the same handful of names — _audioSeek, _audioTime, setPlayButtonState,
|
||||
// _songEventPayload, jucePlayer. Those names have an owner, and it isn't app.js. Give
|
||||
// them one and the four consumers import them directly:
|
||||
//
|
||||
// count-in.js 5 hooks -> 0 juce-audio.js 4 hooks -> 0
|
||||
// loops.js 6 hooks -> 4 section-practice.js 10 hooks -> 7
|
||||
//
|
||||
// A hook is a cycle you agreed to live with. An import is a dependency you actually have.
|
||||
// Prefer the import whenever the name has a real owner.
|
||||
//
|
||||
// TWO THINGS DELIBERATELY LEFT IN app.js, both for the same reason — they would close a
|
||||
// cycle, and app.js is the root, so it can import from both sides for free:
|
||||
//
|
||||
// * _currentPlaybackSnapshot reads loopA/loopB from ./loops.js, and loops.js imports
|
||||
// this module. The dependency scan MISSED this at first: it
|
||||
// only walked app.js's own top-level decls, and loopA stopped
|
||||
// being one the moment loops.js was carved out. Any scan of a
|
||||
// partly-carved monolith has to resolve the imports too.
|
||||
// * restartCurrentSong calls _cancelCountIn() from ./count-in.js, which imports
|
||||
// this module.
|
||||
//
|
||||
// The seek generation (_audioSeekGen) stays PRIVATE. It has exactly one writer —
|
||||
// _resetAudioSeekState(), right here — so readers get audioSeekGen() and nobody outside
|
||||
// can desync it. That is strictly better than the host hook it replaces, which handed out
|
||||
// a getter and left the writer in app.js.
|
||||
import { audio } from './audio-el.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// 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 <img> child's src in place rather than rewriting
|
||||
// innerHTML, so any future children (fallback label, loading spinner, …)
|
||||
// survive state changes.
|
||||
export 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;
|
||||
}
|
||||
|
||||
// ── Player ───────────────────────────────────────────────────────────────
|
||||
// `audio` now lives in ./js/audio-el.js so carved-out modules can reach the
|
||||
// player without importing app.js back (which would close a cycle). Same
|
||||
// element, same handle, same lookup — just imported instead of declared here.
|
||||
let _lastSongPositionEventAt = 0;
|
||||
|
||||
export function _emitSongPositionChanged(time, duration) {
|
||||
const now = Date.now();
|
||||
if (now - _lastSongPositionEventAt < 250) return;
|
||||
_lastSongPositionEventAt = now;
|
||||
const payload = (typeof _songEventPayload === 'function') ? _songEventPayload() : { time };
|
||||
window.feedBack.emit('song:position-changed', Object.assign(payload, { duration }));
|
||||
}
|
||||
|
||||
export const jucePlayer = {
|
||||
_timer: null,
|
||||
_pos: 0,
|
||||
_dur: 0,
|
||||
_pollAt: 0, // performance.now() when _pos was last set
|
||||
_polling: false,
|
||||
_speed: 1,
|
||||
get currentTime() {
|
||||
if (!this._polling) return this._pos;
|
||||
// Interpolate between IPC polls so highway motion is smooth at 60fps
|
||||
// Scale by _speed so at 0.7x the interpolated clock advances 0.7s/s
|
||||
const elapsed = (performance.now() - this._pollAt) / 1000;
|
||||
return Math.min(this._pos + elapsed * this._speed, this._dur > 0 ? this._dur : Infinity);
|
||||
},
|
||||
get duration() { return this._dur; },
|
||||
async play() {
|
||||
try {
|
||||
await window.feedBackDesktop.audio.startBacking();
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] startBacking failed:', err);
|
||||
return false;
|
||||
}
|
||||
this._startPolling();
|
||||
return true;
|
||||
},
|
||||
async pause() {
|
||||
// Snapshot the interpolated position before stopping the poll so
|
||||
// _pos stays at the visible pause point rather than jumping back
|
||||
// to the last raw IPC sample (which can be up to 100ms behind).
|
||||
this._pos = this.currentTime;
|
||||
this._pollAt = performance.now();
|
||||
this._stopPolling();
|
||||
try {
|
||||
await window.feedBackDesktop.audio.stopBacking();
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] stopBacking failed:', err);
|
||||
}
|
||||
},
|
||||
async seek(s) {
|
||||
const prev = this._pos;
|
||||
this._pos = s;
|
||||
this._pollAt = performance.now();
|
||||
try {
|
||||
await window.feedBackDesktop.audio.seekBacking(s);
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] seekBacking failed:', err);
|
||||
this._pos = prev;
|
||||
this._pollAt = performance.now();
|
||||
}
|
||||
},
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._polling = true;
|
||||
this._pollAt = performance.now();
|
||||
const self = this;
|
||||
function scheduleNext() {
|
||||
self._timer = setTimeout(async () => {
|
||||
if (!self._polling) return;
|
||||
try {
|
||||
self._pos = await window.feedBackDesktop.audio.getBackingPosition();
|
||||
self._pollAt = performance.now();
|
||||
_emitSongPositionChanged(self.currentTime, self.duration || null);
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] position poll failed:', err);
|
||||
} finally {
|
||||
if (self._polling) scheduleNext();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
scheduleNext();
|
||||
},
|
||||
_stopPolling() {
|
||||
this._polling = false;
|
||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||
},
|
||||
setRate(rate) {
|
||||
this._pos = this.currentTime;
|
||||
this._pollAt = performance.now();
|
||||
this._speed = rate;
|
||||
},
|
||||
async stop() {
|
||||
await this.pause();
|
||||
this._pos = 0;
|
||||
this._dur = 0;
|
||||
this._pollAt = 0;
|
||||
this._speed = 1;
|
||||
},
|
||||
};
|
||||
|
||||
export function _audioTime() { return window._juceMode ? jucePlayer.currentTime : audio.currentTime; }
|
||||
|
||||
export function _audioDuration() { return window._juceMode ? jucePlayer.duration : audio.duration; }
|
||||
|
||||
// Canonical payload for song:play/song:pause/song:ended. Plugins anchor
|
||||
// their own clocks against `perfNow` (a monotonic timestamp at the same
|
||||
// moment audio reports `audioT`) so they don't have to chase the chart
|
||||
// clock with a follow-up call. `time` is kept as an alias for `audioT`
|
||||
// because pre-existing plugins read e.detail.time.
|
||||
export function _songEventPayload() {
|
||||
const audioT = _audioTime();
|
||||
return {
|
||||
time: audioT,
|
||||
audioT,
|
||||
chartT: highway.getTime(),
|
||||
perfNow: performance.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function _markPlaybackPaused() {
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
|
||||
export function _markPlaybackResumed() {
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function _emitPlaybackStopped(time, screen = 'playback-command') {
|
||||
if (window.feedBack) window.feedBack.emit('song:stop', { time: time || 0, screen });
|
||||
}
|
||||
|
||||
export function _waitForSongReady(expectedSeekGen, timeoutMs = 10000) {
|
||||
if (!window.feedBack || typeof window.feedBack.on !== 'function') return Promise.resolve(false);
|
||||
return new Promise(resolve => {
|
||||
let timer = null;
|
||||
const done = value => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
window.feedBack.off('song:ready', onReady);
|
||||
resolve(value);
|
||||
};
|
||||
const onReady = () => done(expectedSeekGen == null || expectedSeekGen === _audioSeekGen);
|
||||
window.feedBack.on('song:ready', onReady);
|
||||
timer = setTimeout(() => done(false), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Serializes seeks so concurrent callers (e.g. user ⏪ during a loop wrap)
|
||||
// don't interleave their from/to reads — each call captures `from` only
|
||||
// once the previous seek + emit have completed. The generation token
|
||||
// lets session teardown invalidate queued seeks so they don't run against
|
||||
// the new player and emit a stale song:seek.
|
||||
let _audioSeekChain = Promise.resolve();
|
||||
|
||||
let _audioSeekGen = 0;
|
||||
|
||||
export function _resetAudioSeekState() {
|
||||
// Bump the generation — in-flight chain callbacks see the mismatch on
|
||||
// their next guard check and short-circuit (no emit, no further state
|
||||
// mutation by us). Don't reset the chain head: new seeks must still
|
||||
// queue behind the in-flight old seek's IPC so two `jucePlayer.seek()`
|
||||
// calls can't race in the JUCE backing engine. The queue drains
|
||||
// quickly because each subsequent old-gen step bails on the first
|
||||
// guard the moment its predecessor resolves.
|
||||
_audioSeekGen++;
|
||||
}
|
||||
|
||||
// Time-box the JUCE IPC so a single hung seek can't block the global
|
||||
// _audioSeekChain forever (which would freeze every subsequent reposition
|
||||
// path: seekBy, loop-wrap, jump-fix, shimmed audio.currentTime).
|
||||
const _JUCE_SEEK_TIMEOUT_MS = 2000;
|
||||
|
||||
function _juceSeekWithTimeout(s) {
|
||||
let timer;
|
||||
const seekP = jucePlayer.seek(s);
|
||||
const timeoutP = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('JUCE seek timed out')), _JUCE_SEEK_TIMEOUT_MS);
|
||||
});
|
||||
// Clear the timer once the race settles either way; without this the
|
||||
// pending timeout keeps the event loop alive (and eventually rejects
|
||||
// an unawaited promise) even after a successful seek.
|
||||
return Promise.race([seekP, timeoutP]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// Resolves to `{ completed, from, to }`:
|
||||
// - completed: true if the seek ran to completion and emitted song:seek;
|
||||
// false if cancelled by a teardown gen bump (or threw).
|
||||
// - from: chart clock just before the seek (NaN on cancel before from-read).
|
||||
// - to: verified post-seek clock (NaN on cancel/throw).
|
||||
// Callers that fire follow-up work after the seek (count-in, arrangement
|
||||
// restore, etc.) should check `completed` so they don't act on a torn-down
|
||||
// session. Callers that need the actual landed position (because JUCE may
|
||||
// clamp or HTML5 may snap to the seekable range) should read `to` rather
|
||||
// than re-using the requested `s`.
|
||||
export async function _audioSeek(s, reason) {
|
||||
// Single funnel for every audio repositioning. Emits song:seek so
|
||||
// plugins (notedetect detection-suppression during seek transients,
|
||||
// practice-journal segment tracking) can react to any chart-time
|
||||
// jump regardless of which UI path triggered it. `reason` is a
|
||||
// free-form short string ('seek-by', 'loop-wrap', 'loop-set',
|
||||
// 'arrangement-restore', 'jump-fix') so subscribers can filter.
|
||||
const gen = _audioSeekGen;
|
||||
_audioSeekChain = _audioSeekChain.then(async () => {
|
||||
if (gen !== _audioSeekGen) return { completed: false, from: NaN, to: NaN };
|
||||
const from = _audioTime();
|
||||
if (window._juceMode) await _juceSeekWithTimeout(s);
|
||||
else audio.currentTime = s;
|
||||
if (gen !== _audioSeekGen) return { completed: false, from, to: NaN };
|
||||
// Read the verified post-seek position rather than the requested `s`
|
||||
// so plugins observe the actual clock — JUCE may clamp or roll back,
|
||||
// and HTML5 may snap to the nearest seekable range.
|
||||
const to = _audioTime();
|
||||
// Sync the jump-fix tracker so the next 60Hz tick doesn't see a
|
||||
// legitimate far seek (e.g. saved-loop jump > 30s) as a browser
|
||||
// bug and revert it.
|
||||
S.lastAudioTime = to;
|
||||
// Sync the chart clock too so any song:* emit fired right after
|
||||
// _audioSeek resolves (e.g. the auto-resume song:play in
|
||||
// changeArrangement) sees an in-sync chartT via _songEventPayload.
|
||||
// Without this, chartT lags by one 60Hz tick after a seek.
|
||||
if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
|
||||
highway.setTime(to);
|
||||
}
|
||||
window.feedBack.emit('song:seek', { from, to, reason: reason || null });
|
||||
return { completed: true, from, to };
|
||||
}).catch((err) => {
|
||||
// Don't let one failed seek poison subsequent ones.
|
||||
console.warn('[_audioSeek]', err);
|
||||
return { completed: false, from: NaN, to: NaN };
|
||||
});
|
||||
return _audioSeekChain;
|
||||
}
|
||||
|
||||
// Per-attempt counter for HTML5 audio.play() invocations. Bumped on
|
||||
// every play branch entry so a slow rejection from attempt N can't
|
||||
// clobber the UI of a newer attempt N+1 within the same session.
|
||||
let _playAttemptGen = 0;
|
||||
|
||||
export async function togglePlay() {
|
||||
if (window._juceMode) {
|
||||
if (S.isPlaying) {
|
||||
await jucePlayer.pause();
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
} else {
|
||||
const started = await jucePlayer.play();
|
||||
if (!started) return; // startBacking() failed — IPC error already logged
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (S.isPlaying) {
|
||||
audio.pause(); S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
} else {
|
||||
// Flip the UI optimistically before awaiting the play() Promise so
|
||||
// a quick second click during a slow start (buffering, device
|
||||
// wake, etc.) still enters the pause branch above. Two stale-
|
||||
// resolution guards:
|
||||
// - _audioSeekGen: bumped in showScreen() teardown and
|
||||
// playSong(), so a rejection from a torn-down session can't
|
||||
// touch new-session UI. Survives same-URL reloads.
|
||||
// - _playAttemptGen: bumped on every play branch entry, so
|
||||
// within a single session a slow rejection from attempt N
|
||||
// can't clobber a faster attempt N+1 (Play → Pause → Play).
|
||||
const sessionGen = _audioSeekGen;
|
||||
const attempt = ++_playAttemptGen;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
try {
|
||||
await audio.play();
|
||||
} catch (err) {
|
||||
if (sessionGen !== _audioSeekGen) return;
|
||||
if (attempt !== _playAttemptGen) return;
|
||||
// An engine reroute (HTML5 -> JUCE) deliberately pauses the <audio>
|
||||
// element mid-migration, which rejects this in-flight play() with an
|
||||
// AbortError even though playback continues on the JUCE transport.
|
||||
// The reroute owns isPlaying / the button while it runs (same guard
|
||||
// the <audio> 'play'/'pause' listeners use); resetting here would
|
||||
// leave the button showing Play while the song keeps playing — the
|
||||
// "two clicks to pause on the first song after a fresh load" bug.
|
||||
if (window._juceRerouteInProgress) return;
|
||||
console.error('[app] audio.play() rejected:', err);
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function seekBy(s) {
|
||||
await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the seek generation. Bumped by _resetAudioSeekState() on session
|
||||
* teardown; callers capture it before an await and compare after, so a resolution from a
|
||||
* torn-down session can't touch new-session state.
|
||||
*/
|
||||
export function audioSeekGen() { return _audioSeekGen; }
|
||||
@@ -0,0 +1,228 @@
|
||||
// Tuning display — naming, string counts, and target frequencies.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Turns raw per-string semitone offsets into things a human reads: a tuning NAME
|
||||
// ("Drop D", "Eb Standard", or a raw-offsets fallback), whether an arrangement is
|
||||
// bass, its effective string count, and the target FREQUENCIES + note names the
|
||||
// tuner checks against. Pure functions over a small MIDI/note-name table.
|
||||
//
|
||||
// The window / window.feedBack assignments for these stay in app.js — they are the
|
||||
// public contract (constitution II names window.feedBack), and app.js re-exposes
|
||||
// the imported bindings from exactly where it always did, so nothing about the
|
||||
// surface or its ordering changes.
|
||||
|
||||
// Display-only tuning label helpers — never mutate offsets or affect playback.
|
||||
function _looksLikeRawTuningOffsets(str) {
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
const s = str.trim();
|
||||
if (!s) return false;
|
||||
if (/^-?\d+$/.test(s)) return true;
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _tuningNameFromOffsets(offsets) {
|
||||
if (!offsets || !offsets.length) return '';
|
||||
const standard = {
|
||||
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
|
||||
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
|
||||
'-6': 'Bb Standard', '-7': 'A Standard',
|
||||
1: 'F Standard', 2: 'F# Standard',
|
||||
};
|
||||
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
|
||||
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
|
||||
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
|
||||
const name = standard[offsets[0]];
|
||||
if (name) return name;
|
||||
}
|
||||
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
|
||||
&& offsets.slice(1).every((o) => o === offsets[1])) {
|
||||
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
|
||||
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
|
||||
}
|
||||
const named = {
|
||||
'-2,0,0,0,0,0': 'Drop D',
|
||||
'-4,-2,-2,-2,-2,-2': 'Drop C',
|
||||
'-2,-2,0,0,0,0': 'Double Drop D',
|
||||
'0,0,0,-1,0,0': 'Open G',
|
||||
'-2,-2,0,0,-2,-2': 'Open D',
|
||||
'-2,0,0,0,-2,0': 'DADGAD',
|
||||
'0,2,2,1,0,0': 'Open E',
|
||||
'-2,0,0,2,3,2': 'Open D (alt)',
|
||||
};
|
||||
if (offsets.length === 6) {
|
||||
const key = offsets.join(',');
|
||||
if (named[key]) return named[key];
|
||||
}
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
|
||||
export function displayTuningName(value, offsets) {
|
||||
// Explicit offsets win — always name them.
|
||||
if (Array.isArray(offsets) && offsets.length > 0) {
|
||||
return _tuningNameFromOffsets(offsets);
|
||||
}
|
||||
if (value && typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'Unknown') return '';
|
||||
if (!_looksLikeRawTuningOffsets(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// A raw offset string (now served by the API) — parse and name it so a
|
||||
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
|
||||
// collapsing to "Custom Tuning".
|
||||
const parsed = (typeof parseRawTuningOffsets === 'function')
|
||||
? parseRawTuningOffsets(trimmed) : null;
|
||||
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function isBassArrangement(context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
|
||||
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
|
||||
if (/\bbass\b/.test(label)) return true;
|
||||
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function effectiveStringCount(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return 0;
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const isBass = isBassArrangement(ctx);
|
||||
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
|
||||
if (!isBass) {
|
||||
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
|
||||
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
|
||||
} else if (!sc) {
|
||||
sc = offsets.length >= 5 ? offsets.length : 4;
|
||||
}
|
||||
return Math.min(sc, offsets.length);
|
||||
}
|
||||
|
||||
export function songTuningContext(songInfo) {
|
||||
if (!songInfo || typeof songInfo !== 'object') return {};
|
||||
return {
|
||||
stringCount: songInfo.stringCount,
|
||||
arrangement: songInfo.arrangement,
|
||||
arrangement_smart_name: songInfo.arrangement_smart_name,
|
||||
};
|
||||
}
|
||||
|
||||
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
|
||||
const _TUNING_BASE_MIDI = {
|
||||
4: [28, 33, 38, 43],
|
||||
5: [23, 28, 33, 38, 43],
|
||||
6: [40, 45, 50, 55, 59, 64],
|
||||
7: [35, 40, 45, 50, 55, 59, 64],
|
||||
8: [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
};
|
||||
|
||||
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
function _tuningMidiToFreq(m) {
|
||||
return Math.pow(2, (m - 69) / 12) * 440;
|
||||
}
|
||||
|
||||
function _tuningOffsetsToFreqs(offsets, isBass) {
|
||||
const len = offsets.length;
|
||||
let base;
|
||||
if (len === 4 || len === 5) {
|
||||
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
|
||||
} else {
|
||||
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
|
||||
}
|
||||
return offsets.map((offset, i) => {
|
||||
const root = i < base.length ? base[i] : base[base.length - 1];
|
||||
return _tuningMidiToFreq(root + offset);
|
||||
});
|
||||
}
|
||||
|
||||
function _noteNameFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
|
||||
return names[((rounded % 12) + 12) % 12];
|
||||
}
|
||||
|
||||
function _octaveNoteFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const octave = Math.floor(rounded / 12) - 1;
|
||||
return _noteNameFromFreq(freq, useFlats) + octave;
|
||||
}
|
||||
|
||||
function _stringOrdinalLabel(n) {
|
||||
const v = n % 100;
|
||||
if (v >= 11 && v <= 13) return n + 'th';
|
||||
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
|
||||
return n + suffix;
|
||||
}
|
||||
|
||||
function _tuningTargetFreqs(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return [];
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const stringCount = effectiveStringCount(offsets, ctx);
|
||||
const trimmed = offsets.slice(0, stringCount);
|
||||
if (!trimmed.length) return [];
|
||||
const isBass = isBassArrangement(ctx);
|
||||
try {
|
||||
return _tuningOffsetsToFreqs(trimmed, isBass);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat vs sharp spelling. A caller that knows the preference can pass
|
||||
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
|
||||
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
|
||||
// to sharps unless an explicit useFlats is supplied.
|
||||
function _resolveTargetUseFlats(ctx) {
|
||||
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
|
||||
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
|
||||
}
|
||||
|
||||
export function displayTuningTargetDetails(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
return freqs.map((f, i) => {
|
||||
const stringNumber = freqs.length - i;
|
||||
const note = _noteNameFromFreq(f, useFlats);
|
||||
const octaveNote = _octaveNoteFromFreq(f, useFlats);
|
||||
return {
|
||||
stringNumber,
|
||||
note,
|
||||
octaveNote,
|
||||
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function displayTuningTargets(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
if (!freqs.length) return '';
|
||||
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
|
||||
}
|
||||
|
||||
export function parseRawTuningOffsets(value) {
|
||||
if (Array.isArray(value) && value.length) return value;
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const s = value.trim();
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
|
||||
return s.split(/\s+/).map((n) => Number(n));
|
||||
}
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
|
||||
return s.split(',').map((n) => Number(n));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
// The visualization layer — the viz picker, renderer selection, and Auto-match.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: it imports NOTHING,
|
||||
// which is what lets static/js/plugin-loader.js take _populateVizPicker straight
|
||||
// from here and drop the configurePluginLoader() host seam it needed while this
|
||||
// code still lived in app.js.
|
||||
//
|
||||
// It owns the state behind those decisions (the one-shot WebGL2 probe, the
|
||||
// 3D-promotion flag, the Auto label, the notation-hint memo) — all
|
||||
// module-private, because nothing outside reads them.
|
||||
|
||||
// ── Visualization picker (feedBack#36) ─────────────────────────────────
|
||||
//
|
||||
// Discovers viz plugins via /api/plugins and adds them to the #viz-picker
|
||||
// dropdown. A viz plugin declares itself by setting `"type": "visualization"`
|
||||
// in its plugin.json AND exposing a factory function on
|
||||
// window.feedBackViz_<id> that returns an object matching the setRenderer
|
||||
// contract ({init, draw, resize, destroy}).
|
||||
//
|
||||
// The "default" option in the dropdown is the built-in 2D highway that
|
||||
// lives inside createHighway(); selecting it calls setRenderer(null) which
|
||||
// restores the default renderer. The bundled 3D Highway plugin
|
||||
// (plugins/highway_3d/) registers as id `highway_3d` and is the new
|
||||
// fresh-install default per feedBack#160 PR 3.
|
||||
|
||||
// ── WebGL2 detection (one-shot probe) ────────────────────────────────────
|
||||
// 3D Highway requires WebGL2. On environments where it's unavailable
|
||||
// (older browsers, some embedded webviews, software-only contexts), we
|
||||
// silently fall back to the Classic 2D Highway and flash a single toast
|
||||
// so the user knows why their highway looks different. Cached so we don't
|
||||
// thrash the GPU with repeat throwaway-canvas creations.
|
||||
let _webgl2Probe = null;
|
||||
function _canRun3D() {
|
||||
if (_webgl2Probe !== null) return _webgl2Probe;
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
const gl = c.getContext('webgl2');
|
||||
_webgl2Probe = !!gl;
|
||||
// Lose the context immediately — the probe canvas is never reused.
|
||||
if (gl && gl.getExtension) {
|
||||
const ext = gl.getExtension('WEBGL_lose_context');
|
||||
if (ext && ext.loseContext) ext.loseContext();
|
||||
}
|
||||
} catch (_) { _webgl2Probe = false; }
|
||||
return _webgl2Probe;
|
||||
}
|
||||
|
||||
// ── Migration / nag flags ────────────────────────────────────────────────
|
||||
// `feedBack_3d_promoted_v1` is set the first time we auto-flip an existing
|
||||
// `vizSelection='default'` user to `'highway_3d'`. Persistence ensures we
|
||||
// don't re-nag on every reload — and ensures the WebGL2 fallback path
|
||||
// doesn't ping-pong (one fallback toast, not one per page load).
|
||||
const _3D_PROMOTED_FLAG_KEY = 'feedBack_3d_promoted_v1';
|
||||
function _markPromoted() {
|
||||
try { localStorage.setItem(_3D_PROMOTED_FLAG_KEY, '1'); } catch (_) {}
|
||||
}
|
||||
function _hasPromotedFlag() {
|
||||
try { return localStorage.getItem(_3D_PROMOTED_FLAG_KEY) === '1'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
// Pending nag: queued during _populateVizPicker, fired on the first
|
||||
// `song:ready` (so the toast lands when the user actually opens the
|
||||
// player, not at page load when they're still in the library).
|
||||
// `song:ready` is emitted by highway.js via window.feedBack.emit(), so
|
||||
// subscribe through the same EventTarget. window.feedBack is created in
|
||||
// this same file before _populateVizPicker is reachable, so the global
|
||||
// is guaranteed to exist by the time this listener registers — but guard
|
||||
// anyway in case this module is ever loaded standalone for tests.
|
||||
let _pendingPromotionNag = false;
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
window.feedBack.on('song:ready', () => {
|
||||
if (!_pendingPromotionNag) return;
|
||||
_pendingPromotionNag = false;
|
||||
_showPromotionNag();
|
||||
});
|
||||
}
|
||||
|
||||
function _showPromotionNag() {
|
||||
// Lightweight toast — no dependency on a generic toast helper, since
|
||||
// app.js doesn't currently have one. Fixed bottom-center, dismissed
|
||||
// by clicking either action button or the × close.
|
||||
const existing = document.getElementById('feedBack-3d-nag');
|
||||
if (existing) existing.remove();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'feedBack-3d-nag';
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
wrap.setAttribute('aria-modal', 'false');
|
||||
wrap.setAttribute('aria-label', '3D Highway upgrade notification');
|
||||
wrap.style.cssText = `
|
||||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||
background: linear-gradient(145deg, #1a1a30 0%, #0d0d18 100%);
|
||||
border: 1px solid rgba(64,128,224,0.4);
|
||||
border-radius: 12px; padding: 12px 16px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(64,128,224,0.15);
|
||||
font-size: 13px; color: #e2e8f0; z-index: 10000;
|
||||
max-width: 480px; display: flex; align-items: center; gap: 12px;
|
||||
`;
|
||||
wrap.innerHTML = `
|
||||
<span aria-live="polite" style="flex:1;">Your highway was upgraded to <strong>3D</strong>.</span>
|
||||
<button type="button" data-act="tour" style="background:rgba(64,128,224,0.25);color:#e2e8f0;border:1px solid rgba(64,128,224,0.5);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Try the tour</button>
|
||||
<button type="button" data-act="back" style="background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,0.1);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Switch back to 2D</button>
|
||||
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
|
||||
`;
|
||||
wrap.addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('button[data-act]');
|
||||
if (!btn) return;
|
||||
const act = btn.dataset.act;
|
||||
if (act === 'tour') {
|
||||
try {
|
||||
if (window.feedBackTour && typeof window.feedBackTour.start === 'function') {
|
||||
window.feedBackTour.start('highway_3d');
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (act === 'back') {
|
||||
setViz('default');
|
||||
}
|
||||
wrap.remove();
|
||||
});
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
|
||||
function _showWebGL2FallbackToast() {
|
||||
// One-time fallback notice. Same lightweight DOM as the nag, simpler
|
||||
// copy and only a dismiss button.
|
||||
if (document.getElementById('feedBack-3d-fallback')) return;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'feedBack-3d-fallback';
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
wrap.setAttribute('aria-modal', 'false');
|
||||
wrap.setAttribute('aria-label', 'WebGL2 not available');
|
||||
wrap.style.cssText = `
|
||||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||
background: #181830; border: 1px solid rgba(255,180,80,0.4);
|
||||
border-radius: 12px; padding: 10px 14px;
|
||||
font-size: 12px; color: #e2e8f0; z-index: 10000;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
`;
|
||||
wrap.innerHTML = `
|
||||
<span aria-live="polite">3D Highway needs WebGL2 — falling back to Classic 2D.</span>
|
||||
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:16px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
|
||||
`;
|
||||
wrap.addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('button[data-act]')) wrap.remove();
|
||||
});
|
||||
document.body.appendChild(wrap);
|
||||
setTimeout(() => { try { wrap.remove(); } catch (_) {} }, 8000);
|
||||
}
|
||||
|
||||
// The "default" option in the dropdown is the built-in 2D highway that
|
||||
// lives inside createHighway(); selecting it calls setRenderer(null) which
|
||||
// restores the default renderer.
|
||||
function _ensureVenueVizOption(sel) {
|
||||
if (!sel) return;
|
||||
if (Array.from(sel.options).some(opt => opt.value === 'venue')) return;
|
||||
if (!Array.from(sel.options).some(opt => opt.value === 'highway_3d')) return;
|
||||
const h3dOpt = Array.from(sel.options).find(opt => opt.value === 'highway_3d');
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'venue';
|
||||
opt.textContent = 'Venue';
|
||||
if (h3dOpt && h3dOpt.nextSibling) sel.insertBefore(opt, h3dOpt.nextSibling);
|
||||
else sel.appendChild(opt);
|
||||
}
|
||||
|
||||
function _syncVenueVizPlayerClass(vizId) {
|
||||
if (window.v3VenueViz && typeof window.v3VenueViz.setSelectedVizId === 'function') {
|
||||
window.v3VenueViz.setSelectedVizId(vizId);
|
||||
return;
|
||||
}
|
||||
if (window.v3VenueViz && typeof window.v3VenueViz.syncPlayerVizClass === 'function') {
|
||||
window.v3VenueViz.syncPlayerVizClass(vizId);
|
||||
return;
|
||||
}
|
||||
const player = document.getElementById('player');
|
||||
if (player) player.classList.toggle('is-venue-visualization', vizId === 'venue');
|
||||
}
|
||||
|
||||
export async function _populateVizPicker(plugins) {
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (!sel) return;
|
||||
// Clear any previously-appended plugin options so calling this
|
||||
// function more than once (e.g. from DevTools, or a hot-reloaded
|
||||
// plugin) doesn't produce duplicates. The built-in "auto" and
|
||||
// "default" options are static markup — preserve them.
|
||||
const BUILTIN_OPT_VALUES = new Set(['auto', 'default', 'venue']);
|
||||
Array.from(sel.options).forEach(opt => {
|
||||
if (!BUILTIN_OPT_VALUES.has(opt.value)) sel.removeChild(opt);
|
||||
});
|
||||
// Accept a pre-fetched plugins array (normal startup path reuses
|
||||
// loadPlugins' fetch). Fall back to our own fetch if called
|
||||
// standalone — e.g. from the DevTools console for debugging.
|
||||
if (!Array.isArray(plugins)) {
|
||||
plugins = [];
|
||||
try {
|
||||
const resp = await fetch('/api/plugins');
|
||||
if (resp.ok) plugins = await resp.json();
|
||||
} catch (e) {
|
||||
console.warn('viz picker: /api/plugins fetch failed', e);
|
||||
}
|
||||
}
|
||||
const vizPlugins = plugins.filter(p => p && p.type === 'visualization');
|
||||
// "default" is reserved for the built-in 2D renderer option and
|
||||
// "auto" is reserved for the Auto-mode entry — both already in the
|
||||
// <select>. A plugin with either id would collide: the
|
||||
// restore-from-localStorage lookup would find the built-in entry,
|
||||
// dragging the plugin into never-selected land silently. Fail
|
||||
// loudly instead.
|
||||
const RESERVED_IDS = new Set(['default', 'auto']);
|
||||
for (const p of vizPlugins) {
|
||||
if (RESERVED_IDS.has(p.id)) {
|
||||
console.error(`viz picker: plugin id '${p.id}' collides with a reserved built-in picker entry ('auto' = Auto mode, 'default' = built-in 2D highway); rename the plugin's id in plugin.json to include it in the picker.`);
|
||||
continue;
|
||||
}
|
||||
// Skip entries where the plugin script hasn't exposed a factory —
|
||||
// likely means the script failed to load, or the plugin declared
|
||||
// itself as a viz without shipping the factory yet.
|
||||
const factoryName = 'feedBackViz_' + p.id;
|
||||
if (typeof window[factoryName] !== 'function') {
|
||||
console.warn(`viz picker: plugin '${p.id}' has type=visualization but ${factoryName} is not a function; skipping`);
|
||||
continue;
|
||||
}
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name || p.id;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
_ensureVenueVizOption(sel);
|
||||
// Refresh the visualization capability domain's provider registry from
|
||||
// the picker entries just built (the domain host introspects each
|
||||
// factory global for contextType / predicate metadata).
|
||||
if (window.feedBack.vizDomain && typeof window.feedBack.vizDomain.refreshProviders === 'function') {
|
||||
try {
|
||||
// The host reads manifest-declared per-instance settings
|
||||
// (capabilities.visualization.settings, feedBack#849) from the
|
||||
// registered capability participant by id — no need to pass them
|
||||
// through the picker here.
|
||||
window.feedBack.vizDomain.refreshProviders(
|
||||
Array.from(sel.options)
|
||||
.filter(opt => !BUILTIN_OPT_VALUES.has(opt.value))
|
||||
.map(opt => ({ id: opt.value, label: opt.text }))
|
||||
);
|
||||
} catch (e) { console.warn('viz picker: capability provider refresh failed', e); }
|
||||
}
|
||||
// Restore previous selection if still available. Direct option
|
||||
// scan instead of a CSS-selector lookup so we don't depend on
|
||||
// CSS.escape (missing in some test environments / older runtimes)
|
||||
// and so a weird saved string (e.g. with a quote) can't throw.
|
||||
// localStorage.getItem can itself throw when storage is blocked
|
||||
// (private mode, sandboxed iframes, some strict test runners);
|
||||
// fall back to null so the startup chain doesn't abort.
|
||||
let saved = null;
|
||||
try { saved = localStorage.getItem('vizSelection'); }
|
||||
catch (e) { console.warn('viz picker: unable to read vizSelection', e); }
|
||||
|
||||
// ── 3D promotion migration (feedBack#160 PR 3) ──────────────────────
|
||||
// Existing users with `vizSelection='default'` (the old built-in 2D
|
||||
// highway) are auto-flipped to the bundled 3D Highway exactly once,
|
||||
// and a non-modal nag toast offers them "Try the tour" / "Switch
|
||||
// back to 2D" the first time they open the player. Users on `auto`
|
||||
// are left alone (auto-pick semantics unchanged). Users on a custom
|
||||
// viz plugin are left alone. WebGL2 absence falls back via setViz.
|
||||
if (saved === 'default' && !_hasPromotedFlag()) {
|
||||
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
|
||||
if (has3D && _canRun3D()) {
|
||||
saved = 'highway_3d';
|
||||
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
|
||||
_markPromoted();
|
||||
_pendingPromotionNag = true;
|
||||
// Race guard: if song:ready already fired before _populateVizPicker
|
||||
// ran (e.g. a deeplink or a fast-loading song), getSongInfo() will
|
||||
// already be non-empty and we'll never receive another song:ready
|
||||
// in this session. Show the nag immediately in that case.
|
||||
const _si = window.highway && window.highway.getSongInfo();
|
||||
if (_si && _si.title) {
|
||||
_pendingPromotionNag = false;
|
||||
_showPromotionNag();
|
||||
}
|
||||
} else if (has3D && !_canRun3D()) {
|
||||
// 3D registered but WebGL2 absent — promote in name but
|
||||
// immediately fall back so we don't ping-pong on every load.
|
||||
// Set the flag so we don't try again next reload.
|
||||
_markPromoted();
|
||||
_showWebGL2FallbackToast();
|
||||
}
|
||||
// No `highway_3d` option (plugin unloaded?) → leave saved as
|
||||
// 'default'. We'll retry the migration once the plugin is back.
|
||||
}
|
||||
|
||||
const savedMatches = saved && Array.from(sel.options).some(opt => opt.value === saved);
|
||||
if (savedMatches) {
|
||||
sel.value = saved;
|
||||
// 'default' needs no setViz — the highway already starts with
|
||||
// the built-in renderer. 'auto' runs setViz so _autoMatchViz
|
||||
// fires, though it's a no-op before the first song_info frame.
|
||||
if (saved !== 'default') setViz(saved);
|
||||
} else if (saved) {
|
||||
// Saved selection references an option that no longer exists —
|
||||
// plugin uninstalled since last session, renamed, or the plugin
|
||||
// script failed to register its factory this time. Clear the
|
||||
// stale value so we don't keep trying the same missing viz on
|
||||
// every reload, and fall through to the fresh-install default
|
||||
// below.
|
||||
try { localStorage.removeItem('vizSelection'); }
|
||||
catch (_) { /* storage blocked; ignore */ }
|
||||
saved = null;
|
||||
}
|
||||
if (!saved) {
|
||||
// Fresh install (or post-cleanup fallthrough): default to the
|
||||
// bundled 3D Highway when available + WebGL2-capable, falling
|
||||
// back to Auto otherwise so the arrangement-matching plugins
|
||||
// (piano on Keys songs, drums on Drums songs, ...) still take
|
||||
// over for non-3D arrangements.
|
||||
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
|
||||
if (has3D && _canRun3D()) {
|
||||
sel.value = 'highway_3d';
|
||||
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
|
||||
setViz('highway_3d');
|
||||
} else {
|
||||
sel.value = 'auto';
|
||||
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
|
||||
if (has3D && !_canRun3D()) { _markPromoted(); _showWebGL2FallbackToast(); }
|
||||
}
|
||||
}
|
||||
// Close a startup race: if playback began before loadPlugins
|
||||
// finished, song:ready already fired while the picker had no
|
||||
// plugin options — _autoMatchViz saw no candidates and left the
|
||||
// default active. Now that plugins are registered, re-evaluate
|
||||
// against whatever song is currently loaded (a no-op when no song
|
||||
// has been loaded yet, since highway.getSongInfo() returns {}).
|
||||
if (sel.value === 'auto') _autoMatchViz();
|
||||
}
|
||||
|
||||
function _tagVizRenderer(renderer, id) {
|
||||
if (!renderer || !id) return renderer;
|
||||
try {
|
||||
if (!renderer.pluginId) renderer.pluginId = id;
|
||||
if (!renderer.source) renderer.source = id;
|
||||
} catch (_) {}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
// Attribution hooks into the visualization capability domain (cap:6).
|
||||
// Guarded no-ops when the domain host isn't loaded (minimal/test pages).
|
||||
function _notifyVizDomain(id, source) {
|
||||
const domain = window.feedBack && window.feedBack.vizDomain;
|
||||
if (domain && typeof domain.notifyRendererChanged === 'function') {
|
||||
try { domain.notifyRendererChanged(id, source); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _noteVizAutoMatch(id, matched) {
|
||||
const domain = window.feedBack && window.feedBack.vizDomain;
|
||||
if (domain && typeof domain.noteAutoMatch === 'function') {
|
||||
try { domain.noteAutoMatch(id, matched); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _installVizRenderer(renderer, id, source = 'user-select') {
|
||||
highway.setRenderer(_tagVizRenderer(renderer, id));
|
||||
// Drop any stale notation-view hint now that we have a resolved renderer id.
|
||||
// This is also the path used by _autoMatchViz() after it resolves 'auto' to
|
||||
// a real plugin id, so the null passed at evaluation start is corrected here.
|
||||
_dropStaleNotationHint(id);
|
||||
_notifyVizDomain(id, source);
|
||||
if (window.v3VenueViz && typeof window.v3VenueViz.notifyRendererInstalled === 'function') {
|
||||
window.v3VenueViz.notifyRendererInstalled(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function setViz(id) {
|
||||
// Helper: reset the UI and persisted selection to the built-in
|
||||
// "default" entry. Called whenever the requested viz can't be
|
||||
// applied (missing factory, factory threw, factory returned a
|
||||
// non-conforming renderer) so the picker, localStorage, and the
|
||||
// highway's active renderer stay in sync.
|
||||
const fallbackToDefault = () => {
|
||||
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (sel) sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
}
|
||||
_notifyVizDomain('default', 'fallback');
|
||||
_maybeShowNotationViewHint('default');
|
||||
};
|
||||
|
||||
// When switching away from Auto, reset the closed-state label so the
|
||||
// Auto option shows base text the next time the user opens the dropdown.
|
||||
// Also cancel any pending viz:renderer:ready listener from the previous
|
||||
// Auto match cycle so it can't set a stale label after we've moved on.
|
||||
if (id !== 'auto') {
|
||||
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
|
||||
_setAutoVizLabel(null);
|
||||
}
|
||||
|
||||
if (id === 'default' || !id) {
|
||||
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
|
||||
const _sel = document.getElementById('viz-picker');
|
||||
if (_sel) _sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
}
|
||||
_notifyVizDomain('default', 'user-select');
|
||||
_maybeShowNotationViewHint('default');
|
||||
return;
|
||||
}
|
||||
if (id === 'auto') {
|
||||
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
|
||||
_syncVenueVizPlayerClass('auto');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('auto');
|
||||
}
|
||||
_autoMatchViz();
|
||||
return;
|
||||
}
|
||||
if (id === 'venue') {
|
||||
if (!_canRun3D()) {
|
||||
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
|
||||
_markPromoted();
|
||||
_showWebGL2FallbackToast();
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
const venueFactory = window['feedBackViz_highway_3d'];
|
||||
if (typeof venueFactory !== 'function') {
|
||||
console.error('viz picker: venue requires feedBackViz_highway_3d');
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
let venueRenderer;
|
||||
try { venueRenderer = venueFactory(); }
|
||||
catch (e) {
|
||||
console.error('viz picker: feedBackViz_highway_3d threw for venue mode', e);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
if (!venueRenderer || typeof venueRenderer.draw !== 'function') {
|
||||
console.error('viz picker: feedBackViz_highway_3d returned an invalid renderer for venue mode');
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
try { localStorage.setItem('vizSelection', 'venue'); } catch (_) {}
|
||||
const _venueSel = document.getElementById('viz-picker');
|
||||
if (_venueSel) _venueSel.value = 'venue';
|
||||
_installVizRenderer(venueRenderer, 'highway_3d');
|
||||
_syncVenueVizPlayerClass('venue');
|
||||
console.info('[venue-viz] selected venue -> renderer highway_3d, venueClass=true');
|
||||
if (window.v3VenueMoodFx && typeof window.v3VenueMoodFx.onVenueVisualizationSelected === 'function') {
|
||||
window.v3VenueMoodFx.onVenueVisualizationSelected();
|
||||
}
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('venue');
|
||||
}
|
||||
_maybeShowNotationViewHint('highway_3d');
|
||||
return;
|
||||
}
|
||||
// 3D Highway specifically gates on WebGL2. Any future WebGL viz
|
||||
// plugin should declare its own probe — for now the bundled 3D
|
||||
// Highway is the only viz with this requirement, so the gate is
|
||||
// hardcoded. Falling back to 'default' (Classic 2D) keeps the
|
||||
// picker in sync; toast informs the user.
|
||||
if (id === 'highway_3d' && !_canRun3D()) {
|
||||
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
|
||||
_markPromoted();
|
||||
_showWebGL2FallbackToast();
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
const factory = window['feedBackViz_' + id];
|
||||
if (typeof factory !== 'function') {
|
||||
console.error(`viz picker: factory feedBackViz_${id} not available`);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
let renderer;
|
||||
try { renderer = factory(); }
|
||||
catch (e) {
|
||||
console.error(`viz picker: factory feedBackViz_${id} threw`, e);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
// Validate shape — highway.setRenderer will itself fall back to
|
||||
// default on a bad renderer, but without this check the UI and
|
||||
// localStorage would still advertise the broken selection.
|
||||
if (!renderer || typeof renderer.draw !== 'function') {
|
||||
console.error(`viz picker: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
// Persist only once we know the renderer is valid.
|
||||
try { localStorage.setItem('vizSelection', id); } catch (_) {}
|
||||
_installVizRenderer(renderer, id);
|
||||
_syncVenueVizPlayerClass(id);
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz(id);
|
||||
}
|
||||
_maybeShowNotationViewHint(id);
|
||||
}
|
||||
|
||||
// Auto mode: evaluate each registered viz factory's static
|
||||
// `matchesArrangement(songInfo)` predicate and install the first
|
||||
// matching renderer. No match → fall back to the built-in 2D highway.
|
||||
//
|
||||
// vizSelection stays 'auto' across invocations so the next song:ready
|
||||
// re-evaluates. An explicit picker choice overrides Auto by persisting
|
||||
// a different vizSelection.
|
||||
//
|
||||
// Enumerates viz plugins by walking the picker's own <option> list —
|
||||
// that's the canonical set built by _populateVizPicker above and keeps
|
||||
// us from needing a second module-level registry.
|
||||
// Helper: update the closed-state label of the Auto option to show what was resolved.
|
||||
// Resets to the base label when called with no argument (at evaluation start).
|
||||
// _autoVizBaseLabel is captured from the DOM on first call so the reset text
|
||||
// always matches the initial markup rather than a hardcoded duplicate.
|
||||
let _autoVizBaseLabel = null;
|
||||
function _setAutoVizLabel(resolvedText) {
|
||||
const opt = document.querySelector('#viz-picker option[value="auto"]');
|
||||
if (!opt) return;
|
||||
if (_autoVizBaseLabel === null) _autoVizBaseLabel = opt.text;
|
||||
opt.text = resolvedText != null ? `Auto \u2192 ${resolvedText}` : _autoVizBaseLabel;
|
||||
}
|
||||
|
||||
// Holds a cleanup function for the pending viz:renderer:ready listener
|
||||
// registered by _autoMatchViz(). Called at the start of each new evaluation
|
||||
// to remove any listener left over from the previous match cycle.
|
||||
let _cancelPendingAutoLabel = null;
|
||||
|
||||
// One-shot (per song) hint shown when a notation-only arrangement falls back
|
||||
// to the built-in 2D highway. Such arrangements carry no wire notes
|
||||
// (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so
|
||||
// the default renderer draws an empty board — without this the user is left
|
||||
// staring at a silently blank highway. Core ships no notation view; point at
|
||||
// the viz picker instead.
|
||||
let _notationHintShownFor = null;
|
||||
function _showNotationViewHint(arrangementIndex, activeVizId) {
|
||||
const filename = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.filename) || '';
|
||||
if (_notationHintShownFor === filename) return;
|
||||
_notationHintShownFor = filename;
|
||||
const player = document.getElementById('player');
|
||||
if (!player) return;
|
||||
const prev = document.getElementById('notation-view-hint');
|
||||
if (prev) prev.remove();
|
||||
const el = document.createElement('div');
|
||||
el.id = 'notation-view-hint';
|
||||
el.className = 'notation-view-hint';
|
||||
el.dataset.filename = filename;
|
||||
if (arrangementIndex != null) el.dataset.arrangementIndex = String(arrangementIndex);
|
||||
if (activeVizId) el.dataset.vizId = String(activeVizId);
|
||||
el.textContent = 'This arrangement is notation-only — the built-in highway has nothing to draw. '
|
||||
+ 'Install a notation view plugin (e.g. Staff View or Keys Highway 3D) and select it in the visualization picker.';
|
||||
const close = document.createElement('button');
|
||||
close.className = 'notation-view-hint-close';
|
||||
close.setAttribute('aria-label', 'Dismiss');
|
||||
close.textContent = '×';
|
||||
close.addEventListener('click', () => el.remove());
|
||||
el.appendChild(close);
|
||||
player.appendChild(el);
|
||||
setTimeout(() => { el.remove(); }, 15000);
|
||||
}
|
||||
|
||||
// Decide whether the active song needs the notation-view hint: the song is
|
||||
// notation-only (has_notation + zero wire notes on the active arrangement)
|
||||
// AND the given viz doesn't claim it via matchesArrangement. Covers both the
|
||||
// Auto fallthrough (activeVizId='default') and explicit selections, where the
|
||||
// renderer persists across songs — e.g. the fresh-install default highway_3d
|
||||
// would otherwise show a silently empty 3D board on a notation-only song.
|
||||
// Returns true when the hint was shown.
|
||||
// A hint left over from a previous song refers to the wrong arrangement —
|
||||
// drop it whenever the viz evaluation runs for a different filename, a
|
||||
// different arrangement index, or a different active viz.
|
||||
function _dropStaleNotationHint(activeVizId) {
|
||||
const stale = document.getElementById('notation-view-hint');
|
||||
if (!stale) return;
|
||||
const curFilename = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.filename) || '';
|
||||
if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null;
|
||||
if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined
|
||||
&& stale.dataset.arrangementIndex !== curArrIdx) {
|
||||
stale.remove(); return;
|
||||
}
|
||||
if (activeVizId && stale.dataset.vizId !== undefined && stale.dataset.vizId !== String(activeVizId)) {
|
||||
stale.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function _maybeShowNotationViewHint(activeVizId) {
|
||||
_dropStaleNotationHint(activeVizId);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const activeArr = Array.isArray(songInfo.arrangements)
|
||||
? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index)
|
||||
: null;
|
||||
if (!(songInfo.has_notation && activeArr && activeArr.notes === 0)) {
|
||||
// Condition no longer holds (arrangement switched to one with notes, or
|
||||
// notation flag cleared) — remove any residual hint so it doesn't
|
||||
// linger and contradict current state.
|
||||
const existing = document.getElementById('notation-view-hint');
|
||||
if (existing) existing.remove();
|
||||
return false;
|
||||
}
|
||||
if (activeVizId && activeVizId !== 'default' && activeVizId !== 'auto') {
|
||||
const factory = window['feedBackViz_' + activeVizId];
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = typeof factory === 'function'
|
||||
&& typeof factory.matchesArrangement === 'function'
|
||||
&& !!factory.matchesArrangement(songInfo);
|
||||
} catch (_) { /* predicate threw — treat as unclaimed */ }
|
||||
if (claimed) {
|
||||
// Renderer now claims notation — drop any existing hint.
|
||||
const existing = document.getElementById('notation-view-hint');
|
||||
if (existing) existing.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_showNotationViewHint(songInfo.arrangement_index, activeVizId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function _autoMatchViz() {
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (!sel) return;
|
||||
// Pass null here: sel.value is 'auto', which is never a valid viz-id hint
|
||||
// key. Passing 'auto' would incorrectly drop hints whose data-viz-id is
|
||||
// 'default' (the resolved renderer after a no-match pass), making the
|
||||
// hint unshowable for the rest of the song. Drop using the resolved id
|
||||
// happens later inside _installVizRenderer once the id is known.
|
||||
_dropStaleNotationHint(null);
|
||||
// Cancel any pending viz:renderer:ready listener from a previous match
|
||||
// cycle. The song may change before the previous renderer's async init
|
||||
// settles; we don't want that stale listener to clobber the new label.
|
||||
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
|
||||
// Reset label at evaluation start so a stale resolved label never persists
|
||||
// if the song changes or the picker re-evaluates with a different outcome.
|
||||
_setAutoVizLabel(null);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
// Only update the label when a real song is loaded. Before the first
|
||||
// song_info frame, getSongInfo() returns {} — leaving the reset state
|
||||
// ("Auto (match arrangement)") is correct; we haven't evaluated yet.
|
||||
const hasSong = Object.keys(songInfo).length > 0;
|
||||
// Options are stable in DOM order, which matches what users see in
|
||||
// the picker. The underlying order comes from /api/plugins →
|
||||
// _populateVizPicker, and /api/plugins reflects the order the
|
||||
// plugin loader discovered plugins in — plugins/__init__.py walks
|
||||
// `sorted(plugins_base_dir.iterdir())`, i.e. sorted by the on-disk
|
||||
// PLUGIN DIRECTORY name (e.g. "feedBack-plugin-drums" sorts
|
||||
// before "feedBack-plugin-piano"), not by the plugin id declared
|
||||
// in plugin.json. Two consequences worth noting:
|
||||
// 1. First match wins among registered viz plugins — keep each
|
||||
// plugin's matchesArrangement predicate narrow to avoid
|
||||
// stealing songs from more specialized viz.
|
||||
// 2. If you need a strict priority when multiple plugins match
|
||||
// the same song, name the higher-priority plugin's directory
|
||||
// earlier alphabetically. The picker dropdown reveals the
|
||||
// actual tiebreaker at a glance.
|
||||
const candidateIds = Array.from(sel.options)
|
||||
.map(o => o.value)
|
||||
.filter(v => v !== 'auto' && v !== 'default');
|
||||
for (const id of candidateIds) {
|
||||
const factory = window['feedBackViz_' + id];
|
||||
if (typeof factory !== 'function') continue;
|
||||
// If the factory statically declares contextType='webgl2', gate on
|
||||
// WebGL2 availability so a match never installs a renderer that'll
|
||||
// fail at init. This is the generic version of the old hard-coded
|
||||
// highway_3d check — any future WebGL2 viz gets the same protection
|
||||
// for free without needing a special-case here.
|
||||
const factoryCtxType = typeof factory.contextType === 'string' ? factory.contextType : '2d';
|
||||
if (factoryCtxType === 'webgl2' && !_canRun3D()) continue;
|
||||
const predicate = factory.matchesArrangement;
|
||||
if (typeof predicate !== 'function') continue;
|
||||
let matched = false;
|
||||
try { matched = !!predicate(songInfo); }
|
||||
catch (err) {
|
||||
console.error(`viz auto: matchesArrangement for ${id} threw`, err);
|
||||
continue;
|
||||
}
|
||||
if (!matched) continue;
|
||||
let renderer;
|
||||
try { renderer = factory(); }
|
||||
catch (err) {
|
||||
console.error(`viz auto: factory feedBackViz_${id} threw`, err);
|
||||
continue;
|
||||
}
|
||||
if (!renderer || typeof renderer.draw !== 'function') {
|
||||
console.error(`viz auto: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
|
||||
continue;
|
||||
}
|
||||
// Deliberately NOT persisting id — vizSelection stays 'auto' so
|
||||
// the next song:ready re-evaluates against the new arrangement.
|
||||
//
|
||||
// Register the viz:renderer:ready listener BEFORE setRenderer() so we
|
||||
// don't miss the event for sync renderers (no readyPromise), which emit
|
||||
// it immediately inside setRenderer(). The _onReady guard still checks
|
||||
// sel.value so a sync init failure (viz:reverted → sel.value='default')
|
||||
// that fires during setRenderer() is handled correctly — the listener
|
||||
// fires but finds sel.value !== 'auto' and skips the label update.
|
||||
if (hasSong) {
|
||||
const matchedOpt = Array.from(sel.options).find(o => o.value === id);
|
||||
const labelText = matchedOpt ? matchedOpt.text : id;
|
||||
function _onReady() { if (sel.value === 'auto') _setAutoVizLabel(labelText); }
|
||||
window.feedBack.on('viz:renderer:ready', _onReady, { once: true });
|
||||
_cancelPendingAutoLabel = () => window.feedBack.off('viz:renderer:ready', _onReady);
|
||||
}
|
||||
_installVizRenderer(renderer, id, 'auto-match');
|
||||
_noteVizAutoMatch(id, true);
|
||||
return;
|
||||
}
|
||||
// No match — restore the built-in 2D highway. setRenderer(null) is
|
||||
// a no-op when the default is already active. If the previous Auto
|
||||
// pick was a WebGL renderer, highway.setRenderer() handles the
|
||||
// context-type change by replacing the canvas element (cloneNode +
|
||||
// replaceWith) so the default 2D renderer's getContext('2d') always
|
||||
// succeeds — no canvas-lock limitation here.
|
||||
highway.setRenderer(null);
|
||||
_notifyVizDomain('default', 'auto-match');
|
||||
_noteVizAutoMatch('default', false);
|
||||
// Update the label so the user can see Auto resolved to the built-in
|
||||
// highway. Read from the DOM rather than hard-coding the name so a
|
||||
// future rename of the default entry is automatically reflected.
|
||||
if (hasSong) {
|
||||
const defaultOpt = Array.from(sel.options).find(o => o.value === 'default');
|
||||
// Notation-only arrangement falling through to the default renderer:
|
||||
// there are no wire notes, so the board would be silently empty.
|
||||
// Flag it in the Auto label and show the one-shot install hint.
|
||||
if (_maybeShowNotationViewHint('default')) {
|
||||
_setAutoVizLabel('no notation view installed');
|
||||
} else {
|
||||
_setAutoVizLabel(defaultOpt ? defaultOpt.text : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── viz:reverted ────────────────────────────────────────────────────────
|
||||
// Lifted out of a top-level listener block in app.js that it shared with the
|
||||
// non-viz song:loaded / arrangement:changed / song:ready handlers (those stay).
|
||||
//
|
||||
// It has to move WITH the state: it REASSIGNS `_cancelPendingAutoLabel`, and an
|
||||
// imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
|
||||
// this listener stayed behind in app.js. Same guard as the block it came from.
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
// Highway signals when it's auto-reverted to the default renderer
|
||||
// after a broken plugin (init failure or repeated draw failures).
|
||||
// Sync the picker + persisted selection so the UI stops advertising
|
||||
// the broken choice and the user doesn't hit the same failure on
|
||||
// next reload.
|
||||
window.feedBack.on('viz:reverted', (e) => {
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (sel) sel.value = 'default';
|
||||
// Cancel any pending viz:renderer:ready label listener — the renderer
|
||||
// that was queued never became (or stayed) active.
|
||||
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
|
||||
// Clear any Auto-resolved label — the renderer that was advertised
|
||||
// never became (or stayed) active.
|
||||
_setAutoVizLabel(null);
|
||||
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
|
||||
console.warn(
|
||||
`viz picker: reverted to default renderer (${e.detail?.reason || 'unknown'}).`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+3
-1
@@ -592,6 +592,8 @@
|
||||
sm.on('working-tuning-changed', () => renderInstrument());
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -271,6 +271,8 @@
|
||||
sm.on('v3:profile-updated', () => render());
|
||||
}
|
||||
function boot() { render(); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -273,7 +273,9 @@
|
||||
// the stage observer attaches.
|
||||
window.addEventListener('feedBack-minigames-ready', () => { ensureStageObserver(); refresh(); });
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
+79
-62
@@ -1,14 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
fee[dB]ack v0.3.0 shell (FEEDBACK_UI=v3 / GET /v3).
|
||||
fee[dB]ack v0.3.0 shell — the app's only UI, served at `/` (and `/v3`, a
|
||||
back-compat alias). The classic v2 shell it was forked from is deleted.
|
||||
|
||||
This is a re-chromed copy of the legacy static/index.html: the v0.3.0
|
||||
sidebar + topbar replace the (hidden) legacy navbar, new #v3-* screens are
|
||||
added, and all the legacy screens (#home library, #favorites, #settings,
|
||||
#player, #audio, plugin nav containers) are kept verbatim so static/app.js
|
||||
boots UNMODIFIED and the whole engine — player/highway, plugin loader,
|
||||
capabilities, audio, library, settings — is reused as-is. Navigation is the
|
||||
shared window.showScreen across both #v3-* and legacy/#plugin-* screens.
|
||||
Originally a re-chromed copy of that shell: the v0.3.0 sidebar + topbar
|
||||
replace the (hidden) legacy navbar, new #v3-* screens are added, and all the
|
||||
legacy screens (#home library, #favorites, #settings, #player, #audio, plugin
|
||||
nav containers) are kept verbatim so static/app.js boots UNMODIFIED and the
|
||||
whole engine — player/highway, plugin loader, capabilities, audio, library,
|
||||
settings — is reused as-is. Navigation is the shared window.showScreen across
|
||||
both #v3-* and legacy/#plugin-* screens.
|
||||
See ~/Repositories/feedBack-feedback-v030/prompts/12-app-shell.md.
|
||||
-->
|
||||
<html lang="en" class="dark scroll-smooth">
|
||||
@@ -98,23 +99,39 @@
|
||||
<link rel="stylesheet" href="/static/tour-engine.css">
|
||||
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
|
||||
<link rel="stylesheet" href="/static/v3/v3.css">
|
||||
<!-- EVERY external script below is `defer`. Do not add a plain one.
|
||||
`defer` and `type="module"` scripts share a single "execute after
|
||||
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
|
||||
DURING parse, ahead of all of them. So one plain tag would jump the
|
||||
queue — and once the capabilities become modules (they defer), a
|
||||
still-plain app.js would run BEFORE the bus exists and die on its
|
||||
top-level `window.feedBack.on(...)` calls. Keeping every tag deferred
|
||||
is what preserves this file's order through the ES-module migration.
|
||||
Enforced by test_every_external_script_defers_so_document_order_is_execution_order.
|
||||
|
||||
The scripts themselves boot on DOMContentLoaded, which fires only after
|
||||
all of the above have evaluated — that is what lets a script's boot()
|
||||
use a global another script defines further down this list (there are
|
||||
~43 such forward references). Their readyState guards therefore treat
|
||||
'interactive' as not-ready; see the note at each one. -->
|
||||
|
||||
<!-- Diagnostics console capture must wrap console.* before any other
|
||||
script logs anything; load it as early as possible. See
|
||||
docs/diagnostics-bundle-spec.md (feedBack#166). -->
|
||||
<script src="/static/diagnostics.js"></script>
|
||||
<script src="/static/capabilities.js"></script>
|
||||
<script src="/static/capabilities/library.js"></script>
|
||||
<script src="/static/capabilities/tuning.js"></script>
|
||||
<script src="/static/capabilities/working-tuning.js"></script>
|
||||
<script src="/static/capabilities/audio-session.js"></script>
|
||||
<script src="/static/capabilities/audio-effects.js"></script>
|
||||
<script src="/static/capabilities/playback.js"></script>
|
||||
<script defer src="/static/diagnostics.js"></script>
|
||||
<script type="module" src="/static/capabilities.js"></script>
|
||||
<script type="module" src="/static/capabilities/library.js"></script>
|
||||
<script type="module" src="/static/capabilities/tuning.js"></script>
|
||||
<script type="module" src="/static/capabilities/working-tuning.js"></script>
|
||||
<script type="module" src="/static/capabilities/audio-session.js"></script>
|
||||
<script type="module" src="/static/capabilities/audio-effects.js"></script>
|
||||
<script type="module" src="/static/capabilities/playback.js"></script>
|
||||
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
|
||||
<script src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
<script src="/static/capabilities/interface-scale.js"></script>
|
||||
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script type="module" src="/static/capabilities/visualization.js"></script>
|
||||
<script type="module" src="/static/capabilities/note-detection.js"></script>
|
||||
<script type="module" src="/static/capabilities/midi-input.js"></script>
|
||||
<script type="module" src="/static/capabilities/interface-scale.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
|
||||
|
||||
@@ -1224,64 +1241,64 @@
|
||||
</main>
|
||||
<!-- /#v3-main -->
|
||||
|
||||
<script src="/static/highway.js"></script>
|
||||
<script src="/static/vendor/lottie.min.js"></script>
|
||||
<script src="/static/lottie-api.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/audio-mixer.js"></script>
|
||||
<script src="/static/vendor/shepherd.min.js"></script>
|
||||
<script src="/static/tour-engine.js"></script>
|
||||
<script defer src="/static/highway.js"></script>
|
||||
<script defer src="/static/vendor/lottie.min.js"></script>
|
||||
<script defer src="/static/lottie-api.js"></script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
<script defer src="/static/audio-mixer.js"></script>
|
||||
<script defer src="/static/vendor/shepherd.min.js"></script>
|
||||
<script defer src="/static/tour-engine.js"></script>
|
||||
<!-- fee[dB]ack v0.3.0 shell: brand helper, then the shell (sidebar/topbar/
|
||||
routing). Loaded after app.js/audio-mixer so window.showScreen and
|
||||
window.feedBack(.audio) exist; dashboard.js is filled in prompt 13. -->
|
||||
<script src="/static/v3/brand.js"></script>
|
||||
<script src="/static/v3/shell.js"></script>
|
||||
<script defer src="/static/v3/brand.js"></script>
|
||||
<script defer src="/static/v3/shell.js"></script>
|
||||
<!-- Progression (spec 010): theme-core before profile.js so the equipped
|
||||
theme/avatar frame apply with the first badge render; progression-core
|
||||
registers the `progression` capability owner + window.v3Progression. -->
|
||||
<script src="/static/v3/theme-core.js"></script>
|
||||
<script src="/static/v3/progression-core.js"></script>
|
||||
<script src="/static/v3/notifications.js"></script>
|
||||
<script src="/static/v3/profile.js"></script>
|
||||
<script src="/static/v3/progress.js"></script>
|
||||
<script src="/static/v3/shop.js"></script>
|
||||
<script src="/static/v3/tuner-core.js"></script>
|
||||
<script src="/static/v3/badges.js"></script>
|
||||
<script src="/static/v3/stats-recorder.js"></script>
|
||||
<script src="/static/v3/live-performance-hud.js"></script>
|
||||
<script src="/static/v3/scoreboard-pref.js"></script>
|
||||
<script src="/static/v3/venue-viz.js"></script>
|
||||
<script src="/static/v3/venue-instrument-pov.js"></script>
|
||||
<script defer src="/static/v3/theme-core.js"></script>
|
||||
<script defer src="/static/v3/progression-core.js"></script>
|
||||
<script defer src="/static/v3/notifications.js"></script>
|
||||
<script defer src="/static/v3/profile.js"></script>
|
||||
<script defer src="/static/v3/progress.js"></script>
|
||||
<script defer src="/static/v3/shop.js"></script>
|
||||
<script defer src="/static/v3/tuner-core.js"></script>
|
||||
<script defer src="/static/v3/badges.js"></script>
|
||||
<script defer src="/static/v3/stats-recorder.js"></script>
|
||||
<script defer src="/static/v3/live-performance-hud.js"></script>
|
||||
<script defer src="/static/v3/scoreboard-pref.js"></script>
|
||||
<script defer src="/static/v3/venue-viz.js"></script>
|
||||
<script defer src="/static/v3/venue-instrument-pov.js"></script>
|
||||
<!-- venue-mood-fx must load before venue-scene-3d: the scene bridge reads
|
||||
window.v3VenueMoodFx.getMotion() synchronously at boot when the saved
|
||||
viz is 'venue'; loading it after falls back to 'subtle' and ignores a
|
||||
saved 'off'/'full' motion preference on first paint. -->
|
||||
<script src="/static/v3/venue-mood-fx.js"></script>
|
||||
<script src="/static/v3/venue-scene-3d.js"></script>
|
||||
<script src="/static/v3/playlists.js"></script>
|
||||
<script src="/static/v3/audio-routing.js"></script>
|
||||
<script src="/static/v3/live-guitar-tone-source.js"></script>
|
||||
<script src="/static/v3/pedal-cables.js"></script>
|
||||
<script src="/static/v3/plugins-page.js"></script>
|
||||
<script src="/static/v3/card-actions-core.js"></script>
|
||||
<script defer src="/static/v3/venue-mood-fx.js"></script>
|
||||
<script defer src="/static/v3/venue-scene-3d.js"></script>
|
||||
<script defer src="/static/v3/playlists.js"></script>
|
||||
<script defer src="/static/v3/audio-routing.js"></script>
|
||||
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
|
||||
<script defer src="/static/v3/pedal-cables.js"></script>
|
||||
<script defer src="/static/v3/plugins-page.js"></script>
|
||||
<script defer src="/static/v3/card-actions-core.js"></script>
|
||||
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
|
||||
on build, so the module must already be registered. -->
|
||||
<script src="/static/v3/match-review.js"></script>
|
||||
<script defer src="/static/v3/match-review.js"></script>
|
||||
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
|
||||
the cover picker (window.__fbOpenImagePicker). -->
|
||||
<script src="/static/v3/image-picker.js"></script>
|
||||
<script src="/static/v3/songs.js"></script>
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
<script src="/static/v3/settings.js"></script>
|
||||
<script src="/static/v3/interface-size-ui.js"></script>
|
||||
<script defer src="/static/v3/image-picker.js"></script>
|
||||
<script defer src="/static/v3/songs.js"></script>
|
||||
<script defer src="/static/v3/lessons.js"></script>
|
||||
<script defer src="/static/v3/dashboard.js"></script>
|
||||
<script defer src="/static/v3/settings.js"></script>
|
||||
<script defer src="/static/v3/interface-size-ui.js"></script>
|
||||
<!-- First-run home tour: spotlights the home cards via the shared tour
|
||||
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
|
||||
(triggered from profile.js finish()); replayable from the "?" menu. -->
|
||||
<script src="/static/v3/onboarding-tour.js"></script>
|
||||
<script src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script src="/static/v3/feedbarcade.js"></script>
|
||||
<script src="/static/v3/player-chrome.js"></script>
|
||||
<script defer src="/static/v3/onboarding-tour.js"></script>
|
||||
<script defer src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script defer src="/static/v3/feedbarcade.js"></script>
|
||||
<script defer src="/static/v3/player-chrome.js"></script>
|
||||
<script>
|
||||
// Navbar scroll effect
|
||||
window.addEventListener('scroll', () => {
|
||||
|
||||
@@ -71,7 +71,9 @@
|
||||
setTimeout(maybeNudge, 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
// Settings markup is static, but re-sync when settings.js signals it wired.
|
||||
document.addEventListener('v3:settings-rendered', function () { sync(); });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
|
||||
} else {
|
||||
sync();
|
||||
|
||||
@@ -94,7 +94,9 @@
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
|
||||
@@ -303,7 +303,9 @@
|
||||
const sm = root && root.feedBack;
|
||||
if (sm) bindRuntime(sm);
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -913,7 +913,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
|
||||
@@ -362,6 +362,8 @@
|
||||
syncActivation();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
|
||||
@@ -440,6 +440,8 @@
|
||||
});
|
||||
}
|
||||
function boot() { renderPlaylists(); renderSaved(); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -579,6 +579,8 @@
|
||||
}, { passive: true });
|
||||
|
||||
function boot() { render(); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -792,7 +792,9 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -320,7 +320,9 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -241,7 +241,9 @@
|
||||
};
|
||||
|
||||
_registerOwner();
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', () => { refresh(); }, { once: true });
|
||||
} else {
|
||||
refresh();
|
||||
|
||||
@@ -200,7 +200,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
|
||||
+5
-1
@@ -48,6 +48,7 @@
|
||||
// above. Screens are injected async by the plugin loader, so go()'s
|
||||
// plugin- guard applies.
|
||||
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
|
||||
{ key: 'career', screen: 'plugin-career', label: 'Career', group: null, icon: 'trophy' },
|
||||
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
|
||||
{ key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' },
|
||||
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
|
||||
@@ -60,6 +61,7 @@
|
||||
// that group. Each is gated on the plugin actually being installed.
|
||||
const PROMOTED_PLUGINS = [
|
||||
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
|
||||
{ navKey: 'career', pluginId: 'career', slotId: 'v3-nav-career', anchorAfter: 'feedbarcade' },
|
||||
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
|
||||
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
|
||||
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
|
||||
@@ -399,7 +401,9 @@
|
||||
setTimeout(refreshHomeTitle, 700);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
+3
-1
@@ -203,7 +203,9 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -511,7 +511,9 @@
|
||||
const sm = root && root.feedBack;
|
||||
if (sm) bindRuntime(sm);
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -242,7 +242,9 @@
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const boot = () => bindRuntime();
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
|
||||
+3
-3
@@ -13,9 +13,9 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
'./static/**/*.{html,js}',
|
||||
// fee[dB]ack v0.3.0 shell + screens (additive, behind FEEDBACK_UI=v3).
|
||||
// Subsumed by the recursive ./static/** glob above, but listed
|
||||
// explicitly so the v3 tree's Tailwind coverage is obvious.
|
||||
// fee[dB]ack v0.3.0 shell + screens — the only UI since the classic v2
|
||||
// shell was removed. Subsumed by the recursive ./static/** glob above,
|
||||
// but listed explicitly so the v3 tree's Tailwind coverage is obvious.
|
||||
'./static/v3/**/*.{html,js}',
|
||||
// One recursive plugin glob subsumes the previous four narrow ones
|
||||
// (static/**, screen.js, settings.html, *.html) and additionally
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Shared pytest fixtures for the feedBack test suite."""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
@@ -76,3 +78,92 @@ def isolate_logging():
|
||||
lg.setLevel(original_level)
|
||||
lg.propagate = original_propagate
|
||||
structlog.reset_defaults()
|
||||
|
||||
|
||||
# ── Plugin-loader isolation ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Lifted verbatim out of tests/test_plugins.py so more than one test module can drive
|
||||
# the real plugins.load_plugins(). It has to be ONE fixture, not a copy per file:
|
||||
# load_plugins() mutates sys.path, sys.modules, PENDING_PLUGINS and LOADED_PLUGINS, and a
|
||||
# partial restore makes the suite order- and environment-dependent (Codex [P2] on
|
||||
# test_plugin_context_contract.py — it was right).
|
||||
|
||||
# Bare module names that this test module pre-populates into
|
||||
# sys.modules to simulate the bare-import path. Saved/restored by
|
||||
# the reset_plugin_state fixture so they don't leak to other test
|
||||
# files. Codex / Copilot review on PR for feedBack#33.
|
||||
_BARE_NAMES_USED = ("util", "extractor")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def reset_plugin_state(monkeypatch):
|
||||
"""Clear loader module-level state and restore on teardown.
|
||||
|
||||
Saves and restores:
|
||||
* `plugins.LOADED_PLUGINS`
|
||||
* any `plugin_*` keys we add to `sys.modules`
|
||||
* the bare names this module simulates (`util`, `extractor`)
|
||||
* `sys.path` — `plugins.load_plugins()` mutates it
|
||||
Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration
|
||||
(via monkeypatch) so a CI env that pre-sets it can't leak
|
||||
real user plugins into a tmp_path-driven test. Per-module
|
||||
locks are owned by the standard import system
|
||||
(`importlib._bootstrap._module_locks`) and are not our
|
||||
responsibility to reset.
|
||||
"""
|
||||
monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False)
|
||||
plugins = importlib.import_module("plugins")
|
||||
saved_loaded = list(plugins.LOADED_PLUGINS)
|
||||
saved_pending = dict(plugins.PENDING_PLUGINS)
|
||||
saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")}
|
||||
saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules}
|
||||
saved_path = list(sys.path)
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
try:
|
||||
yield plugins
|
||||
finally:
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.extend(saved_loaded)
|
||||
plugins.PENDING_PLUGINS.clear()
|
||||
plugins.PENDING_PLUGINS.update(saved_pending)
|
||||
for k in list(sys.modules):
|
||||
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
|
||||
del sys.modules[k]
|
||||
sys.modules.update(saved_modules)
|
||||
sys.modules.update(saved_bare)
|
||||
sys.path[:] = saved_path
|
||||
|
||||
|
||||
# ── Scanner isolation ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# lib/scan.py holds MODULE-LEVEL state (_scan_status, and the kick/runner bookkeeping),
|
||||
# and `scan` is NOT re-imported by the fixtures that re-import `server` — so unlike the
|
||||
# old server-globals arrangement, that state now outlives a test.
|
||||
#
|
||||
# It matters because of a deliberate asymmetry in the scanner: background_scan() never
|
||||
# sets `running` back to False. Ownership of that flag lives in _scan_runner, so that a
|
||||
# kick_scan() racing the terminal write cannot see a stale False and start a second runner.
|
||||
# Correct in production — but a test that calls background_scan() DIRECTLY skips the runner
|
||||
# entirely and therefore leaves the scanner marked "running" forever. Every later scan or
|
||||
# rescan then returns "already in progress" and quietly does nothing.
|
||||
#
|
||||
# The suite passed anyway, on ordering luck. Codex [P2] caught it. So: snapshot and restore.
|
||||
@pytest.fixture()
|
||||
def reset_scan_state():
|
||||
"""Restore lib/scan.py's module-level state around a test that drives it directly."""
|
||||
import scan
|
||||
|
||||
saved_status = scan._scan_status
|
||||
saved_thread = scan._scan_thread
|
||||
saved_pending = scan._scan_rescan_pending
|
||||
scan._scan_status = dict(scan._SCAN_STATUS_INIT)
|
||||
try:
|
||||
yield scan
|
||||
finally:
|
||||
scan._scan_status = saved_status
|
||||
scan._scan_thread = saved_thread
|
||||
scan._scan_rescan_pending = saved_pending
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Verify the alpha-build heads-up banner: markup is present in
|
||||
// static/index.html and `_updateAlphaWarningBanner(version)` in
|
||||
// static/v3/index.html and `_updateAlphaWarningBanner(version)` in
|
||||
// static/app.js toggles its visibility correctly per the version string.
|
||||
|
||||
const { test } = require('node:test');
|
||||
@@ -8,7 +8,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
test('index.html ships the alpha-warning banner inside the library section', () => {
|
||||
|
||||
@@ -14,10 +14,16 @@ const vm = require('node:vm');
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// _autoplayExitEnabled was carved out into static/js/player-controls.js (R3a); the
|
||||
// auto-exit machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin)
|
||||
// stayed in app.js.
|
||||
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
// the module is ESM; these sandboxes evaluate plain script text
|
||||
const CONTROLS_SRC = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||
|
||||
function runEnabled(stored) {
|
||||
const fnSrc = extractFunction(SRC, 'function _autoplayExitEnabled(');
|
||||
const fnSrc = extractFunction(CONTROLS_SRC, 'function _autoplayExitEnabled(');
|
||||
const sandbox = {
|
||||
localStorage: {
|
||||
getItem: () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
|
||||
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
|
||||
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
|
||||
@@ -28,7 +28,7 @@ test('capability inspector manifest ships settings but no default nav entry', ()
|
||||
});
|
||||
|
||||
test('capability inspector plugins menu entry is localStorage opt-in', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
|
||||
const menu = region(src, 'const navPlugins = plugins.map', 1000);
|
||||
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
|
||||
@@ -106,7 +106,7 @@ test('capability inspector screen ships scoped graph lane CSS', () => {
|
||||
assert.match(html, /left: -1\.75rem/);
|
||||
});
|
||||
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
|
||||
assert.ok(m, 'could not extract _navLabel from app.js');
|
||||
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
|
||||
@@ -123,7 +123,7 @@ test('_navLabel resolves string, object, synthesized, and empty nav values', ()
|
||||
});
|
||||
|
||||
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
// Regression guard for the string/synthesized-nav label fix: the dropdown
|
||||
// label must derive from the loop's computed nav via _navLabel, not from
|
||||
// plugin.nav?.label (which drops string and synthesized labels).
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
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, '..', '..');
|
||||
const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career');
|
||||
const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js');
|
||||
|
||||
test('career plugin manifest is complete and bundled', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
||||
assert.equal(manifest.id, 'career');
|
||||
assert.equal(manifest.bundled, true);
|
||||
assert.equal(manifest.screen, 'screen.html');
|
||||
assert.equal(manifest.script, 'screen.js');
|
||||
assert.equal(manifest.routes, 'routes.py');
|
||||
for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) {
|
||||
assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test('venues.json defines the 3 ascending tiers with star thresholds', () => {
|
||||
const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8'));
|
||||
assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]);
|
||||
const venues = content.venues;
|
||||
assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']);
|
||||
assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked');
|
||||
for (let i = 1; i < venues.length; i++) {
|
||||
assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold,
|
||||
'thresholds must ascend');
|
||||
}
|
||||
});
|
||||
|
||||
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'/);
|
||||
assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/);
|
||||
});
|
||||
|
||||
test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8');
|
||||
assert.match(src, /v3VenueCrowd/);
|
||||
assert.match(src, /setManifest\(manifest\)/);
|
||||
assert.match(src, /manifest\.base = /);
|
||||
assert.match(src, /feedBack-career-venue/);
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
@@ -8,7 +8,9 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The highway string-colour manager was carved out of app.js into its own
|
||||
// module (R3a).
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
|
||||
@@ -13,7 +13,9 @@ const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The highway string-colour manager was carved out of app.js into its own
|
||||
// module (R3a).
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
|
||||
function extractBlock(src, signature) {
|
||||
@@ -86,7 +88,7 @@ test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
|
||||
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
|
||||
});
|
||||
|
||||
// ── Core color manager (static/app.js) ────────────────────────────────────
|
||||
// ── Core color manager (static/js/highway-colors.js) ──────────────────────
|
||||
|
||||
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
|
||||
const src = fs.readFileSync(appJs, 'utf8');
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// The host-seam contract: the hooks the modules USE must be exactly the hooks
|
||||
// app.js WIRES.
|
||||
//
|
||||
// This is the test that makes the seam safe. static/js/host.js already throws at
|
||||
// runtime when an unwired hook is read — but a runtime throw only fires if the
|
||||
// broken path actually executes, and the entire danger of a host seam is the paths
|
||||
// that DON'T run in a smoke test. That is not hypothetical: the plugin loader's
|
||||
// seam defaulted a hook to `() => {}`, and a dropped wiring line would have left
|
||||
// the viz picker silently not refreshing with no test, boot check, or bot noticing.
|
||||
//
|
||||
// So this closes it statically. Rename a hook in app.js, drop a line from the
|
||||
// configureHost({…}) call, or typo a `host.foo` in a module, and CI fails — on a
|
||||
// path nobody ever ran.
|
||||
//
|
||||
// It is deliberately symmetric:
|
||||
// * used but not wired -> a latent crash (host.js would throw at runtime)
|
||||
// * wired but not used -> dead weight, and usually the fossil of a rename
|
||||
// Both fail.
|
||||
|
||||
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, '..', '..');
|
||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const JS_DIR = path.join(ROOT, 'static', 'js');
|
||||
|
||||
// Strip comments, so prose about `host.foo` in a header block is not read as a call
|
||||
// site.
|
||||
//
|
||||
// NOTHING ELSE. An earlier version also tried to strip import statements (to stop
|
||||
// `from './host.js'` reading as a hook called `js`) and its `[\s\S]*?` spanned lines
|
||||
// and silently ate 14,000 characters of the file — including, in the bite test, the
|
||||
// very drift it was supposed to catch. A guard with a hole in it is worse than no
|
||||
// guard, because you trust it. The `host.js` path is excluded far more cheaply,
|
||||
// below, by refusing a match followed by a quote.
|
||||
function scrub(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/[^\n]*$/gm, '');
|
||||
}
|
||||
|
||||
// `host.<name>` — but not `host.js'` from the `from './host.js'` import path, which is
|
||||
// the one string in these files that looks like a hook and isn't.
|
||||
//
|
||||
// The trailing class must forbid a WORD character as well as a quote. With only
|
||||
// `(?!['"])`, `host.js'` fails on `js` (a quote follows), then BACKTRACKS to `j` —
|
||||
// where the next char is `s`, not a quote — and happily reports a hook called `j`.
|
||||
// Forbidding `[\w$]` too leaves it nowhere to backtrack to.
|
||||
const HOOK_RE = /(?<![\w$.])host\.([A-Za-z_$][\w$]*)(?![\w$'"])/g;
|
||||
|
||||
/** Every `host.<name>` referenced by a carved module. */
|
||||
function hooksUsed() {
|
||||
const used = new Map(); // name -> [files]
|
||||
for (const file of fs.readdirSync(JS_DIR)) {
|
||||
if (!file.endsWith('.js') || file === 'host.js') continue;
|
||||
const raw = fs.readFileSync(path.join(JS_DIR, file), 'utf8');
|
||||
if (!/from\s+'\.\/host\.js'/.test(raw)) continue;
|
||||
for (const m of scrub(raw).matchAll(HOOK_RE)) {
|
||||
if (!used.has(m[1])) used.set(m[1], []);
|
||||
used.get(m[1]).push(file);
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
/** Every hook app.js passes to configureHost({ … }). */
|
||||
function hooksWired() {
|
||||
const src = scrub(fs.readFileSync(APP_JS, 'utf8'));
|
||||
// NB the closing brace is INDENTED (the call sits inside the boot function), so
|
||||
// anchoring on `\n});` at column 0 runs straight past it and swallows the next
|
||||
// object literal in the file — which is how this first read 77 "hooks", most of
|
||||
// them app.js's window contract.
|
||||
const call = src.match(/configureHost\(\{([\s\S]*?)\n\s*\}\);/);
|
||||
if (!call) return null; // no seam wired yet — fine until there is one
|
||||
const wired = new Set();
|
||||
for (const m of call[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*(?=[,:}]|$)/gm)) {
|
||||
wired.add(m[1]);
|
||||
}
|
||||
return wired;
|
||||
}
|
||||
|
||||
test('every host.<hook> a module uses is wired by app.js', () => {
|
||||
const used = hooksUsed();
|
||||
if (used.size === 0) return; // no consumers yet
|
||||
const wired = hooksWired();
|
||||
assert.ok(wired, 'modules import ./host.js but app.js never calls configureHost({ … })');
|
||||
|
||||
const missing = [...used.keys()]
|
||||
.filter((h) => !wired.has(h))
|
||||
.map((h) => `${h} (used in ${used.get(h).join(', ')})`);
|
||||
|
||||
assert.deepEqual(
|
||||
missing, [],
|
||||
'these hooks are read by a module but never wired by app.js — they would throw at runtime, '
|
||||
+ 'on whatever path happens to reach them',
|
||||
);
|
||||
});
|
||||
|
||||
test('every hook app.js wires is actually used by a module', () => {
|
||||
const wired = hooksWired();
|
||||
if (!wired || wired.size === 0) return;
|
||||
const used = hooksUsed();
|
||||
|
||||
const unused = [...wired].filter((h) => !used.has(h));
|
||||
|
||||
assert.deepEqual(
|
||||
unused, [],
|
||||
'these hooks are wired by app.js but no module reads them — dead weight, and usually '
|
||||
+ 'the fossil of a rename that left the other half behind',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Behavioral tests for the JUCE engine-reroute watcher in static/app.js.
|
||||
// Behavioral tests for the JUCE engine-reroute watcher in static/js/juce-audio.js.
|
||||
//
|
||||
// The watcher (an IIFE, `_installJuceEngineRoutingWatcher`) migrates a loaded
|
||||
// song between the HTML5 <audio> element and the native JUCE backing transport
|
||||
@@ -14,14 +14,15 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The JUCE audio shims were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
|
||||
|
||||
// Brace-balanced extraction of the watcher IIFE, starting at its `(function`
|
||||
// and ending after the matching `})();`.
|
||||
function extractWatcherIIFE(src) {
|
||||
const marker = '(function _installJuceEngineRoutingWatcher() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'watcher IIFE not found in app.js');
|
||||
assert.ok(start !== -1, 'watcher IIFE not found in static/js/juce-audio.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
@@ -84,7 +85,11 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
||||
json: () => Promise.resolve({ path: '/local/song.ogg' }),
|
||||
}),
|
||||
document: { hidden: false },
|
||||
isPlaying: true,
|
||||
// `isPlaying` moved onto the shared player-state container so a carved module
|
||||
// can WRITE it (an imported binding is read-only). The sliced code now reads and
|
||||
// writes S.isPlaying, so the sandbox provides the same container — the
|
||||
// assertions below are unchanged.
|
||||
S: { isPlaying: true, lastAudioTime: 0 },
|
||||
audio,
|
||||
jucePlayer,
|
||||
__calls: calls,
|
||||
@@ -96,6 +101,17 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const iife = extractWatcherIIFE(src);
|
||||
// The shims reach back into app.js through the host seam (static/js/host.js).
|
||||
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
|
||||
// swallow the calls and the assertions below would pass vacuously.
|
||||
sandbox.host = {
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
|
||||
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
|
||||
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
|
||||
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
|
||||
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(iife, sandbox);
|
||||
return sandbox;
|
||||
|
||||
@@ -71,7 +71,17 @@ test('native audio-mix participant suppresses matching legacy fader and records
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
// The plugin loader was carved out of app.js into its own module (R3a); the
|
||||
// library-provider code below still lives in app.js.
|
||||
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||
// The viz layer was carved out of app.js too (R3a).
|
||||
const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
// The library itself was carved out of app.js into ./static/js/library.js (R3a). Note the
|
||||
// two are DIFFERENT files: LIBRARY_JS above is the capability; this is the UI module.
|
||||
// syncLibrarySong deliberately stayed behind in app.js — it reaches showScreen/playSong,
|
||||
// and moving it would have dragged the whole playback core into the library module.
|
||||
const LIBRARY_MODULE_JS = path.join(ROOT, 'static', 'js', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
|
||||
@@ -87,17 +97,21 @@ function region(src, needle, length = 1200) {
|
||||
}
|
||||
|
||||
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
||||
const src = source(APP_JS);
|
||||
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
// Anchored on the ASSIGNMENT, not the URL literal: the URL is built in
|
||||
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL for the whole
|
||||
// import graph), so the old literal no longer appears at the injection site.
|
||||
const block = region(src, 'script.src = _pluginScriptUrl(');
|
||||
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
||||
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
||||
});
|
||||
|
||||
test('library providers route through native library capability', () => {
|
||||
const src = source(APP_JS);
|
||||
const libModule = source(LIBRARY_MODULE_JS);
|
||||
const librarySrc = source(LIBRARY_JS);
|
||||
const loader = region(src, 'async function loadLibraryProviders', 1800);
|
||||
const selector = region(src, 'async function setLibraryProvider(providerId, options = {})', 1600);
|
||||
const loader = region(libModule, 'async function loadLibraryProviders', 1800);
|
||||
const selector = region(libModule, 'async function setLibraryProvider(providerId, options = {})', 1600);
|
||||
const sync = region(src, 'async function syncLibrarySong(providerId, songId', 1600);
|
||||
|
||||
assert.match(librarySrc, /capabilities\.registerOwner\(['"]library['"]/);
|
||||
@@ -113,7 +127,7 @@ test('library providers route through native library capability', () => {
|
||||
});
|
||||
|
||||
test('visualization renderer installs preserve plugin attribution', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(VIZ_JS);
|
||||
const tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
|
||||
const setViz = region(src, 'function setViz(id)', 3600);
|
||||
const autoViz = region(src, 'function _autoMatchViz()', 5200);
|
||||
|
||||
+44
-12
@@ -11,11 +11,16 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// The A-B loop was carved out of app.js into its own module (R3a). The
|
||||
// window.feedBack API surface it is published through stayed in app.js.
|
||||
const LOOPS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'loops.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
function extractFunction(rawSrc, signature) {
|
||||
// loops.js is an ES module; the vm sandbox evaluates plain script text.
|
||||
const src = rawSrc.replace(/^export /gm, '');
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in static/js/loops.js`);
|
||||
let scan = start + signature.length;
|
||||
if (src[scan] === '(') {
|
||||
let parenDepth = 1;
|
||||
@@ -44,10 +49,18 @@ function buildSandbox() {
|
||||
const seekCalls = [];
|
||||
const sectionPracticeModeCalls = [];
|
||||
const transportEvents = [];
|
||||
// clearLoop() used to zero section-practice's three selection scalars by hand.
|
||||
// They now live in static/js/section-practice.js, which owns them, so clearLoop
|
||||
// calls its exported resetSelection() instead. This is a SPY, not a stub — the
|
||||
// test below still asserts the reset happens, it just asserts it through the
|
||||
// seam rather than by reaching into someone else's state.
|
||||
const resetSelectionCalls = [];
|
||||
const sandbox = {
|
||||
seekCalls,
|
||||
sectionPracticeModeCalls,
|
||||
transportEvents,
|
||||
resetSelectionCalls,
|
||||
resetSelection: () => resetSelectionCalls.push(true),
|
||||
// Mutable state (declared as `var` in eval prelude so it lives on
|
||||
// the sandbox global and the extracted functions can read/write).
|
||||
// The actual values are set below.
|
||||
@@ -81,6 +94,7 @@ function buildSandbox() {
|
||||
// updateLoopUI references formatTime for the label; we don't
|
||||
// assert on the label text in these tests, so a stub is enough.
|
||||
formatTime: (s) => String(s),
|
||||
_updateEditRegionBtn: () => {},
|
||||
window: {
|
||||
feedBack: {
|
||||
playback: {
|
||||
@@ -89,6 +103,19 @@ function buildSandbox() {
|
||||
},
|
||||
},
|
||||
};
|
||||
// The loop module reaches back into app.js through the host seam
|
||||
// (static/js/host.js), so the extracted bodies call host._audioSeek(),
|
||||
// host._audioTime(), and so on. Point the seam at the SAME spies the sandbox
|
||||
// already had: the assertions below are unchanged, they just travel through the
|
||||
// indirection the real code now uses.
|
||||
sandbox.host = {
|
||||
_audioSeek: (...a) => sandbox._audioSeek(...a),
|
||||
_audioTime: () => sandbox._audioTime(),
|
||||
formatTime: (...a) => sandbox.formatTime(...a),
|
||||
_updateEditRegionBtn: () => sandbox._updateEditRegionBtn(),
|
||||
currentFilename: () => 'test-song.sloppak',
|
||||
startCountIn: () => {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
@@ -121,7 +148,7 @@ function loadFunctions(sandbox, src) {
|
||||
}
|
||||
|
||||
test('setLoop mutates loopA/loopB and seeks to A', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -137,7 +164,7 @@ test('setLoop mutates loopA/loopB and seeks to A', async () => {
|
||||
test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => {
|
||||
// Plugin-facing contract: cancelled seek (teardown gen bump) returns
|
||||
// false; the loop is NOT armed.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
|
||||
loadFunctions(sandbox, src);
|
||||
@@ -154,7 +181,7 @@ test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek',
|
||||
test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => {
|
||||
// JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms
|
||||
// from the requested a. The loop is NOT armed.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
|
||||
loadFunctions(sandbox, src);
|
||||
@@ -172,7 +199,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
|
||||
// loadSavedLoop passes parseFloat(dataset.start) — but the dataset
|
||||
// values may already be strings. Number() coercion in setLoop must
|
||||
// accept finite numeric strings.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -183,7 +210,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
|
||||
});
|
||||
|
||||
test('setLoop rejects non-finite inputs', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -193,7 +220,7 @@ test('setLoop rejects non-finite inputs', async () => {
|
||||
});
|
||||
|
||||
test('setLoop rejects b <= a', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -201,8 +228,8 @@ test('setLoop rejects b <= a', async () => {
|
||||
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
|
||||
});
|
||||
|
||||
test('clearLoop resets loopA/loopB to null', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => {
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -211,6 +238,11 @@ test('clearLoop resets loopA/loopB to null', async () => {
|
||||
const { loopA, loopB } = sandbox.__getLoop();
|
||||
assert.equal(loopA, null);
|
||||
assert.equal(loopB, null);
|
||||
assert.equal(
|
||||
sandbox.resetSelectionCalls.length, 1,
|
||||
'clearLoop must ask section-practice to drop its selection (it used to zero the '
|
||||
+ 'scalars by hand; the module owns them now)',
|
||||
);
|
||||
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
|
||||
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
|
||||
// Field-wise: vm-context objects break deepStrictEqual across realms.
|
||||
@@ -218,7 +250,7 @@ test('clearLoop resets loopA/loopB to null', async () => {
|
||||
});
|
||||
|
||||
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -256,7 +288,7 @@ test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () =>
|
||||
// re-implementing the loopA/loopB assignment. Catches a future drift
|
||||
// where someone "fixes" loadSavedLoop and forgets to keep setLoop in
|
||||
// sync.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'async function loadSavedLoop(');
|
||||
assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop');
|
||||
// The pre-refactor body assigned loopA = parseFloat(...) directly;
|
||||
|
||||
@@ -14,7 +14,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// startCountIn was carved out of app.js into its own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
|
||||
|
||||
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
|
||||
// and brace-matching to the closing brace. Skips an optional `( ... )` param
|
||||
@@ -55,8 +56,10 @@ function buildSandbox() {
|
||||
loopA: 10,
|
||||
loopB: 20,
|
||||
_countingIn: false,
|
||||
isPlaying: false,
|
||||
lastAudioTime: 0,
|
||||
// isPlaying / lastAudioTime moved onto the shared player-state container
|
||||
// (static/js/player-state.js) so a carved module can WRITE them — an imported
|
||||
// binding is read-only. Same values, same assertions, one indirection.
|
||||
S: { isPlaying: false, lastAudioTime: 0 },
|
||||
|
||||
// Browser-ish globals.
|
||||
performance: { now: () => Date.now() },
|
||||
@@ -109,12 +112,23 @@ function buildSandbox() {
|
||||
__emitCalls: emitCalls,
|
||||
queueMicrotask,
|
||||
};
|
||||
// startCountIn was carved into static/js/count-in.js and now reaches back into
|
||||
// app.js through the host seam (static/js/host.js). Point the seam at the SAME
|
||||
// stubs the sandbox already had: the assertions below are unchanged, they just
|
||||
// travel through the indirection the real code now uses.
|
||||
sandbox.host = {
|
||||
_audioSeek: (...a) => sandbox._audioSeek(...a),
|
||||
setPlayButtonState: () => {},
|
||||
_songEventPayload: () => ({}),
|
||||
togglePlay: () => {},
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('loop:restart fires once when wrap path runs', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
||||
|
||||
// Sanity check: the change under test is present at all. Catches
|
||||
@@ -135,8 +149,7 @@ test('loop:restart fires once when wrap path runs', async () => {
|
||||
var _countInGen = 0;
|
||||
var _countInTimer = null;
|
||||
var _countInRaf = 0;
|
||||
var isPlaying = false;
|
||||
var lastAudioTime = 0;
|
||||
var S = { isPlaying: false, lastAudioTime: 0 };
|
||||
${startCountInSrc}
|
||||
globalThis.__startCountIn = startCountIn;
|
||||
`;
|
||||
@@ -166,7 +179,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
|
||||
// _audioSeek resolves with completed:true but r.to !== loopA. The
|
||||
// wrap handler must abort instead of running beginCount on the wrong
|
||||
// position and emitting a misleading loop:restart.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
||||
|
||||
const sandbox = buildSandbox();
|
||||
@@ -180,8 +193,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
|
||||
var _countInGen = 0;
|
||||
var _countInTimer = null;
|
||||
var _countInRaf = 0;
|
||||
var isPlaying = false;
|
||||
var lastAudioTime = 0;
|
||||
var S = { isPlaying: false, lastAudioTime: 0 };
|
||||
${startCountInSrc}
|
||||
globalThis.__startCountIn = startCountIn;
|
||||
globalThis.__getCountingIn = () => _countingIn;
|
||||
@@ -202,7 +214,7 @@ test('count-in cancellation token bails delayed callbacks (rewindStep + tick)',
|
||||
// teardown can interrupt an in-flight count-in. Behavioral simulation
|
||||
// of timer cancellation is out of scope for the static extractor; this
|
||||
// verifies the contract is wired into the source.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const fn = extractFunction(src, 'async function startCountIn');
|
||||
// Captures gen at entry
|
||||
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
|
||||
@@ -218,7 +230,7 @@ test('loop:restart fires after highway.setTime, before beginCount', () => {
|
||||
// Source-order assertion on the A-B wrap path only. Section-practice
|
||||
// `opts.immediate` also emits loop:restart but is a separate entry path;
|
||||
// the wrap handler lives inside the `_audioSeek(loopA, 'loop-wrap')` then.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const fn = extractFunction(src, 'async function startCountIn');
|
||||
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
|
||||
const wrapStart = fn.indexOf(wrapMarker);
|
||||
|
||||
@@ -18,7 +18,7 @@ const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
const TOGGLE_PLAY_SRC = extractFunction(SRC, 'async function togglePlay(');
|
||||
|
||||
@@ -29,8 +29,11 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
|
||||
const buttonStates = [];
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
// not-playing -> togglePlay takes the HTML5 play branch
|
||||
isPlaying: false,
|
||||
// not-playing -> togglePlay takes the HTML5 play branch.
|
||||
// isPlaying / lastAudioTime moved onto the shared player-state container
|
||||
// (static/js/player-state.js) so a carved module can WRITE them — an imported
|
||||
// binding is read-only. Same values, same assertions, one indirection.
|
||||
S: { isPlaying: false, lastAudioTime: 0 },
|
||||
_audioSeekGen: 0,
|
||||
_playAttemptGen: 0,
|
||||
setPlayButtonState(v) { buttonStates.push(v); },
|
||||
@@ -51,7 +54,7 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
|
||||
await vm.runInContext('togglePlay()', sandbox);
|
||||
return { buttonStates, isPlaying: sandbox.isPlaying };
|
||||
return { buttonStates, isPlaying: sandbox.S.isPlaying };
|
||||
}
|
||||
|
||||
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
|
||||
|
||||
@@ -5,6 +5,10 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// SPLIT. _installPlaybackTransportAdapter stayed in app.js — it reads loopA/loopB from
|
||||
// ./js/loops.js, and loops.js imports transport, so moving it would close a cycle.
|
||||
// _waitForSongReady went with the rest of the seek machinery.
|
||||
const TRANSPORT_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -54,7 +58,7 @@ function loadReadyHelper(sandbox, src) {
|
||||
}
|
||||
|
||||
test('_waitForSongReady rejects a ready event from a different audio generation', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(TRANSPORT_JS, 'utf8');
|
||||
const sandbox = buildReadySandbox();
|
||||
loadReadyHelper(sandbox, src);
|
||||
|
||||
@@ -72,7 +76,7 @@ test('playback adapter scopes startTime readiness and validates seek targets', (
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
||||
|
||||
assert.match(fn, /const expectedSeekGen\s*=\s*_audioSeekGen\s*\+\s*1;/);
|
||||
assert.match(fn, /const expectedSeekGen\s*=\s*audioSeekGen\(\)\s*\+\s*1;/);
|
||||
assert.match(fn, /_waitForSongReady\(expectedSeekGen\)/);
|
||||
assert.match(fn, /const seconds\s*=\s*Number\(time\);/);
|
||||
assert.match(fn, /!Number\.isFinite\(seconds\)\s*\|\|\s*seconds\s*<\s*0/);
|
||||
@@ -84,5 +88,8 @@ test('playback adapter suppresses duplicate HTML5 pause events before emitting c
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
||||
|
||||
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
|
||||
// isPlaying moved onto the shared player-state container so a carved module can
|
||||
// WRITE it (an imported binding is read-only). window.feedBack.isPlaying — the
|
||||
// public mirror — is unchanged.
|
||||
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*S\.isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/js/plugin-loader.js: a plugin that is
|
||||
// merely ABSENT from the current /api/plugins response (transient partial
|
||||
// response while the backend's plugin registry is repopulating after a
|
||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||
@@ -14,7 +14,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
|
||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||
// nav reset that opens it to the comment introducing the next section.
|
||||
@@ -40,7 +40,7 @@ function makeEl(pluginId, id) {
|
||||
}
|
||||
|
||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||
const block = extractWipeBlock(src);
|
||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||
const container = { children: settingsChildren };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Guards the R0 module-migration loader change in static/app.js: a migrated
|
||||
// Guards the R0 module-migration loader change in static/js/plugin-loader.js: a migrated
|
||||
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
||||
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
||||
// graph loads, while classic plugins stay untouched.
|
||||
@@ -16,16 +16,22 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||
|
||||
// Isolate the screen.js <script> injection block: from where its src is built
|
||||
// to where the element is appended.
|
||||
// Isolate the screen.js <script> injection block: from where its src is assigned to
|
||||
// where the element is appended.
|
||||
//
|
||||
// Anchored on the ASSIGNMENT, not on the URL literal. The URL is built in
|
||||
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL), so the literal
|
||||
// '/api/plugins/${plugin.id}/screen.js' appears FURTHER DOWN the file than the block
|
||||
// that uses it, and slicing from it ran off the end of the injection block entirely.
|
||||
const SRC_ASSIGN = 'script.src = _pluginScriptUrl(';
|
||||
function injectionBlock() {
|
||||
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
|
||||
const start = src.indexOf(SRC_ASSIGN);
|
||||
assert.ok(start !== -1, 'screen.js src assignment not found — loader moved?');
|
||||
const end = src.indexOf('document.body.appendChild(script)', start);
|
||||
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
|
||||
assert.ok(end !== -1, 'appendChild(script) not found after the src assignment');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ test('the module type is gated, never set unconditionally', () => {
|
||||
|
||||
test('the module guard sits before appendChild, after the src assignment', () => {
|
||||
const guardAt = src.indexOf('script.type = \'module\'');
|
||||
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
const srcAt = src.indexOf(SRC_ASSIGN);
|
||||
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
|
||||
assert.ok(guardAt > srcAt && guardAt < appendAt,
|
||||
'the module guard must live inside the screen.js injection block');
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// #879 — a plugin ROLLBACK must actually re-evaluate a module plugin.
|
||||
//
|
||||
// ES modules are evaluated once per URL per document. Re-inserting a
|
||||
// <script type="module"> whose src the module map has already seen fires `load` but
|
||||
// does NOT re-run the body — so rolling back to a version already evaluated this
|
||||
// session left the OLD module live while the loader recorded success.
|
||||
//
|
||||
// The fix puts a generation token in the PATH (/api/plugins/x/g/7/screen.js), not the
|
||||
// query, because a relative specifier resolves against the base URL with the query
|
||||
// DROPPED — so './src/main.js' would otherwise keep resolving to the same cached URL
|
||||
// and the plugin's actual code would never re-run.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
const LOADER = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
|
||||
function makeUrlBuilder() {
|
||||
const src = fs.readFileSync(LOADER, 'utf8');
|
||||
const sandbox = { _evaluatedModules: new Set(), _moduleReloadSeq: 0 };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(`
|
||||
${extractFunction(src, 'function _pluginScriptUrl(')}
|
||||
globalThis.url = _pluginScriptUrl;
|
||||
`, sandbox);
|
||||
return sandbox.url;
|
||||
}
|
||||
|
||||
const MOD = { id: 'editor', script_type: 'module' };
|
||||
const CLASSIC = { id: 'legacy', script_type: 'classic' };
|
||||
|
||||
test('a module plugin first load uses the stable ?v= URL (ETag/304 stays intact)', () => {
|
||||
const url = makeUrlBuilder();
|
||||
assert.equal(url(MOD, '1.0.0', '?v=1.0.0'), '/api/plugins/editor/screen.js?v=1.0.0');
|
||||
});
|
||||
|
||||
// An UPGRADE has to bust the graph too, and this is the part #879 got wrong. It says
|
||||
// "upgrades are fine — a new version yields a new URL". True of screen.js; FALSE of the
|
||||
// plugin. Driving a real browser through install -> upgrade -> rollback and counting
|
||||
// evaluations of src/main.js gives ONE: the upgrade re-runs the one-line screen.js shim
|
||||
// at its new ?v= URL, the shim imports './src/main.js', that resolves to the SAME url,
|
||||
// and the module map hands back the already-evaluated old module. So the key here is the
|
||||
// plugin ID, not id@version — every re-load of a module plugin needs a fresh path.
|
||||
test('an UPGRADE also gets a fresh /g/<n>/ path — a new ?v= does NOT reach the graph', () => {
|
||||
const url = makeUrlBuilder();
|
||||
url(MOD, '1.0.0', '?v=1.0.0');
|
||||
assert.equal(url(MOD, '1.1.0', '?v=1.1.0'), '/api/plugins/editor/g/1/screen.js?v=1.1.0');
|
||||
});
|
||||
|
||||
test('a ROLLBACK to an already-evaluated version gets a fresh /g/<n>/ PATH', () => {
|
||||
const url = makeUrlBuilder();
|
||||
url(MOD, '1.0.0', '?v=1.0.0'); // installed
|
||||
url(MOD, '1.1.0', '?v=1.1.0'); // upgraded -> /g/1/
|
||||
const back = url(MOD, '1.0.0', '?v=1.0.0'); // rolled back -> /g/2/
|
||||
assert.equal(back, '/api/plugins/editor/g/2/screen.js?v=1.0.0');
|
||||
|
||||
// The token must be in the PATH so a relative import INHERITS it — the whole point.
|
||||
// A query token is dropped by URL resolution and never reaches src/main.js.
|
||||
const resolved = new URL('./src/main.js', `http://h${back}`).pathname;
|
||||
assert.equal(resolved, '/api/plugins/editor/g/2/src/main.js',
|
||||
'the token must reach the module GRAPH, not just the entry point');
|
||||
});
|
||||
|
||||
test('every re-load gets a distinct URL (no reuse across a bounce)', () => {
|
||||
const url = makeUrlBuilder();
|
||||
url(MOD, '1.0.0', '?v=1.0.0');
|
||||
const seen = new Set();
|
||||
for (const v of ['1.1.0', '1.0.0', '1.1.0', '1.0.0']) seen.add(url(MOD, v, `?v=${v}`));
|
||||
assert.equal(seen.size, 4, 'each re-load must be a URL the module map has never seen');
|
||||
});
|
||||
|
||||
test('classic-script plugins are untouched — they always re-run on re-insert', () => {
|
||||
const url = makeUrlBuilder();
|
||||
const first = url(CLASSIC, '1.0.0', '?v=1.0.0');
|
||||
url(CLASSIC, '1.1.0', '?v=1.1.0');
|
||||
const back = url(CLASSIC, '1.0.0', '?v=1.0.0');
|
||||
assert.equal(first, '/api/plugins/legacy/screen.js?v=1.0.0');
|
||||
assert.equal(back, first, 'a classic script needs no cache-busting and must not get a /g/ path');
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Verify the plugin `styles` capability in static/app.js: _injectPluginStyles
|
||||
// Verify the plugin `styles` capability in static/js/plugin-loader.js: _injectPluginStyles
|
||||
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
|
||||
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
|
||||
// without `styles`, and routes the URL through the sandboxed asset endpoint.
|
||||
@@ -9,7 +9,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
|
||||
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
|
||||
// nested object/template literal can't make a naive regex stop early.
|
||||
@@ -84,7 +84,7 @@ function setupSandbox() {
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
|
||||
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
|
||||
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/js/juce-audio.js.
|
||||
//
|
||||
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
|
||||
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
|
||||
@@ -16,12 +16,13 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The JUCE audio shims were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
|
||||
|
||||
function extractFeederIIFE(src) {
|
||||
const marker = '(function _installRendererBusFeeder() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in static/js/juce-audio.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
@@ -50,17 +51,42 @@ function makeFakeContext(sampleRate = 48000) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
createMediaStreamSource(stream) {
|
||||
this.mediaStreamSource = stream;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
close() { this.closed = true; return Promise.resolve(); },
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
// Fake getDisplayMedia stream for the loopback-capture path.
|
||||
function makeLoopbackStream({ suppressed = true } = {}) {
|
||||
const stopped = [];
|
||||
const audioTrack = {
|
||||
kind: 'audio',
|
||||
stop() { stopped.push('audio'); },
|
||||
getSettings: () => (suppressed ? { suppressLocalAudioPlayback: true } : {}),
|
||||
};
|
||||
const videoTrack = { kind: 'video', stop() { stopped.push('video'); } };
|
||||
return {
|
||||
__stopped: stopped,
|
||||
getAudioTracks: () => [audioTrack],
|
||||
getVideoTracks: () => [videoTrack],
|
||||
getTracks: () => [videoTrack, audioTrack],
|
||||
};
|
||||
}
|
||||
|
||||
// `displayMedia`: undefined → loopback capture unavailable (Docker sphere /
|
||||
// old desktop main); a function → used as navigator.mediaDevices.getDisplayMedia.
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, displayMedia } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [], setPageMuted: [] };
|
||||
|
||||
const api = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
setPageMuted: (m) => { calls.setPageMuted.push(m); return Promise.resolve(m); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
@@ -85,6 +111,7 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
@@ -97,6 +124,17 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
// The shims reach back into app.js through the host seam (static/js/host.js).
|
||||
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
|
||||
// swallow the calls and the assertions below would pass vacuously.
|
||||
sandbox.host = {
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
|
||||
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
|
||||
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
|
||||
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
|
||||
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(extractFeederIIFE(src), sandbox);
|
||||
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
|
||||
@@ -111,12 +149,21 @@ function makeStemsGraph() {
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
// Surface-mode (stems/element) tests run WITHOUT getDisplayMedia: the first
|
||||
// tick probes loopback, fails, and latches _loopbackUnavailable; the second
|
||||
// tick exercises the fallback surface mode. This mirrors an old desktop main
|
||||
// without the display-media handler.
|
||||
async function reevaluateWithFallback(sb) {
|
||||
await sb.window._reevaluateRendererBus(); // loopback probe → unavailable
|
||||
await sb.window._reevaluateRendererBus(); // surface fallback
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked (loopback unavailable)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||
@@ -128,7 +175,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
@@ -145,26 +192,27 @@ test('stems graph + shared output → feeder stays off (no double audio)', async
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
test('element song + exclusive → element captured into bus (loopback unavailable)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
test('native-transport song, loopback unavailable → surface modes stay off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
|
||||
'bus never ENABLED (failed-probe cleanup may disable it)');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
@@ -172,7 +220,7 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
@@ -182,6 +230,105 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||
});
|
||||
|
||||
// ── Loopback mode (whole-app capture) ────────────────────────────────────────
|
||||
|
||||
test('exclusive output + loopback available → engages without any song loaded', async () => {
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled for whole session');
|
||||
assert.ok(stream.__stopped.includes('video'), 'unused video track stopped');
|
||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback stream captured');
|
||||
assert.equal(sb.__calls.setPageMuted.length, 0, 'suppress constraint honoured — no page mute');
|
||||
});
|
||||
|
||||
test('loopback context is closed on disengage (no orphaned tap worklet)', async () => {
|
||||
let excl = true;
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus(); // engage loopback
|
||||
const lbCtx = sb.__createdContexts.at(-1);
|
||||
assert.equal(lbCtx?.mediaStreamSource, stream, 'loopback engaged');
|
||||
assert.notEqual(lbCtx.closed, true, 'context live while engaged');
|
||||
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus(); // disengage
|
||||
assert.equal(lbCtx.closed, true, 'loopback context closed on disengage');
|
||||
assert.ok(stream.__stopped.includes('audio'), 'capture stream stopped');
|
||||
});
|
||||
|
||||
test('loopback preferred over stems when both available', async () => {
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(graph.context.sinkIdCalls.length, 0, 'stems ctx untouched — loopback owns capture');
|
||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback engaged');
|
||||
});
|
||||
|
||||
test('suppressLocalAudioPlayback unsupported → page-mute fallback, unmuted on disengage', async () => {
|
||||
let excl = true;
|
||||
const stream = makeLoopbackStream({ suppressed: false });
|
||||
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
assert.deepEqual(sb.__calls.setPageMuted, [true], 'page muted as fallback');
|
||||
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
assert.deepEqual(sb.__calls.setPageMuted, [true, false], 'page unmuted on disengage');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||
});
|
||||
|
||||
test('getDisplayMedia rejected → sticky fallback to surface modes', async () => {
|
||||
const sb = makeSandbox({
|
||||
exclusive: () => true,
|
||||
displayMedia: () => Promise.reject(new DOMException('denied', 'NotAllowedError')),
|
||||
});
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus(); // probe fails, latches unavailable
|
||||
await sb.window._reevaluateRendererBus(); // falls back to stems
|
||||
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems fallback engaged');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled via fallback');
|
||||
});
|
||||
|
||||
test('element capture collision (createMediaElementSource throws) → no poisoned state, clean retry', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true }); // loopback unavailable
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
// First capture attempt collides (highway analyser owns the element).
|
||||
let collide = true;
|
||||
const origFactory = sb.AudioContext;
|
||||
sb.__createdContexts.length = 0;
|
||||
// Patch contexts so createMediaElementSource throws while colliding.
|
||||
sb.AudioContext = function () {
|
||||
const c = origFactory();
|
||||
const orig = c.createMediaElementSource.bind(c);
|
||||
c.createMediaElementSource = (el) => {
|
||||
if (collide) throw new DOMException('already connected', 'InvalidStateError');
|
||||
return orig(el);
|
||||
};
|
||||
c.close = () => Promise.resolve();
|
||||
return c;
|
||||
};
|
||||
|
||||
await reevaluateWithFallback(sb); // element engage fails (collision)
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true), 'bus never left enabled');
|
||||
|
||||
collide = false;
|
||||
await sb.window._reevaluateRendererBus(); // retry succeeds — no TypeError, fresh ctx
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'element engaged after collision cleared');
|
||||
});
|
||||
|
||||
test('engine stops → bus disabled', async () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
|
||||
@@ -15,9 +15,10 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
// _installSectionPracticeDismiss was carved out of app.js into its own module (R3a).
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'section-practice.js'), 'utf8');
|
||||
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
|
||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
|
||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/js/section-practice.js');
|
||||
const body = m[0];
|
||||
|
||||
test('the outside-click dismiss binds in the CAPTURE phase', () => {
|
||||
|
||||
@@ -14,8 +14,9 @@ const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
// the song-credits overlay was carved out of app.js into its own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
|
||||
// Minimal fake DOM element: records className, children, and textContent.
|
||||
// Setting textContent clears children (matching real DOM) so we can assert
|
||||
|
||||
@@ -12,7 +12,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -129,11 +129,24 @@ test('every song:play/pause/ended emit uses _songEventPayload', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
|
||||
// carve keeps moving them between app.js and static/js/*.js — point it at a single file
|
||||
// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
|
||||
// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
|
||||
function allFrontendSources() {
|
||||
const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('there are at least 8 song:* emit sites threaded through the helper', () => {
|
||||
// Sanity-check that the helper actually got wired everywhere. If the
|
||||
// count drops, someone removed an emit (regression) or refactored an
|
||||
// event away (intentional — this test then needs updating).
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = allFrontendSources();
|
||||
const matches = src.match(/(?:window\.feedBack|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"][^)]*\)/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 8,
|
||||
|
||||
@@ -14,13 +14,14 @@ const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
const sandbox = {
|
||||
loopA,
|
||||
loopB,
|
||||
isPlaying,
|
||||
// isPlaying moved onto the shared player-state container so a carved module can
|
||||
// WRITE it (an imported binding is read-only). Same value, same assertions.
|
||||
S: { isPlaying, lastAudioTime: 0 },
|
||||
__cancelCountInCalls: 0,
|
||||
__seekCalls: [],
|
||||
__startCountInCalls: [],
|
||||
@@ -43,7 +44,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
},
|
||||
__togglePlay() {
|
||||
sandbox.__togglePlayCalls++;
|
||||
sandbox.isPlaying = true;
|
||||
sandbox.S.isPlaying = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
@@ -54,7 +55,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
|
||||
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
|
||||
const code = `
|
||||
var isPlaying = ${sandbox.isPlaying};
|
||||
var S = { isPlaying: ${sandbox.S.isPlaying}, lastAudioTime: 0 };
|
||||
function _cancelCountIn() { __cancelCountInCalls++; }
|
||||
async function _audioSeek(s, reason) {
|
||||
return (${audioSeekImpl || '__audioSeek'})(s, reason);
|
||||
@@ -138,10 +139,3 @@ test('V3 transport restart button exists with correct attributes', () => {
|
||||
assert.match(html, /title="Restart song"/);
|
||||
assert.match(html, /aria-label="Restart song"/);
|
||||
});
|
||||
|
||||
test('V2 transport restart button exists with correct attributes', () => {
|
||||
const html = fs.readFileSync(V2_HTML, 'utf8');
|
||||
assert.match(html, /#player-controls|player-controls[\s\S]*onclick="restartCurrentSong\(\)"/);
|
||||
assert.match(html, /title="Restart song"/);
|
||||
assert.match(html, /aria-label="Restart song"/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Verify static/app.js emits `song:seek` for every audio repositioning,
|
||||
// Verify static/js/transport.js emits `song:seek` for every audio repositioning,
|
||||
// with `{ from, to, reason }` payload. Plugins (notedetect detection-
|
||||
// suppression during seek transients) consume this contract.
|
||||
//
|
||||
@@ -11,7 +11,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -77,7 +77,10 @@ function loadFunctions(sandbox, src) {
|
||||
// _audioSeek now syncs the jump-fix tracker so far seeks don't
|
||||
// trigger an immediate revert; declare it here so the sandbox
|
||||
// assignment lands on a real binding rather than an implicit global.
|
||||
let lastAudioTime = 0;
|
||||
// lastAudioTime moved onto the shared player-state container
|
||||
// (static/js/player-state.js) so a carved module can WRITE it — an imported
|
||||
// binding is read-only. The sliced code writes S.lastAudioTime now.
|
||||
let S = { isPlaying: false, lastAudioTime: 0 };
|
||||
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
|
||||
// helper + constant. Tests can override jucePlayer.seek to vary
|
||||
// behavior; the timeout (2 s) is well above any test setTimeout.
|
||||
@@ -284,13 +287,26 @@ test('seekBy floors at zero (does not seek to negative time)', async () => {
|
||||
assert.equal(seek.detail.to, 0);
|
||||
});
|
||||
|
||||
// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
|
||||
// carve keeps moving them between app.js and static/js/*.js — point it at a single file
|
||||
// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
|
||||
// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
|
||||
function allFrontendSources() {
|
||||
const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('every documented seek callsite passes a reason', () => {
|
||||
// Source-order assertion: every _audioSeek call outside the
|
||||
// implementation must pass a kebab-case reason string. Catches a
|
||||
// future contributor adding a new seek path without threading the
|
||||
// reason. Line-based — regex argument capture can't balance parens
|
||||
// through Math.max/_audioTime calls.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = allFrontendSources();
|
||||
const fnSrc = extractFunction(src, 'async function _audioSeek(');
|
||||
const withoutImpl = src.replace(fnSrc, '');
|
||||
const callLines = withoutImpl.split('\n').filter((l) => /_audioSeek\(/.test(l));
|
||||
|
||||
@@ -5,6 +5,9 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The speed controls were carved out into static/js/player-controls.js (R3a); playSong,
|
||||
// which resets them on a new song, stayed in app.js. This test spans both.
|
||||
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -130,20 +133,30 @@ function extractConstLine(src, name) {
|
||||
|
||||
function loadPlaySong(sandbox) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const resetHelper = src.includes('function _resetPlaybackSpeedForNewSong')
|
||||
? extractFunction(src, 'function _resetPlaybackSpeedForNewSong')
|
||||
// the module is ESM; the vm sandbox evaluates plain script text
|
||||
const controls = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||
const resetHelper = controls.includes('function _resetPlaybackSpeedForNewSong')
|
||||
? extractFunction(controls, 'function _resetPlaybackSpeedForNewSong')
|
||||
: '';
|
||||
const speedPresetHelpers = src.includes('function _updateSpeedPresetButtons')
|
||||
const speedPresetHelpers = controls.includes('function _updateSpeedPresetButtons')
|
||||
? `
|
||||
${extractConstLine(src, 'SPEED_PRESET_PCTS')}
|
||||
${extractConstLine(src, 'SPEED_SNAP_THRESHOLD')}
|
||||
${extractFunction(src, 'function _speedPresetPctFromActive')}
|
||||
${extractFunction(src, 'function _updateSpeedPresetButtons')}
|
||||
${extractConstLine(controls, 'SPEED_PRESET_PCTS')}
|
||||
${extractConstLine(controls, 'SPEED_SNAP_THRESHOLD')}
|
||||
${extractFunction(controls, 'function _speedPresetPctFromActive')}
|
||||
${extractFunction(controls, 'function _updateSpeedPresetButtons')}
|
||||
`
|
||||
: '';
|
||||
const code = `
|
||||
var artAbortController = null;
|
||||
var isPlaying = true;
|
||||
// isPlaying moved onto the shared player-state container so a carved module can
|
||||
// WRITE it (an imported binding is read-only). NB window.feedBack.isPlaying — the
|
||||
// public mirror stubbed above — is a different thing and is unchanged.
|
||||
var S = { isPlaying: true, lastAudioTime: 0 };
|
||||
// The speed controls reach app.js through the host seam (static/js/host.js).
|
||||
// Route it at the sandbox's EXISTING handleSliderInput spy — a fresh stub would
|
||||
// swallow the call and the assertion below (which checks the slider was actually
|
||||
// refreshed) would pass vacuously.
|
||||
var host = { handleSliderInput: (el) => handleSliderInput(el) };
|
||||
var currentFilename = null;
|
||||
var _playerOriginScreen = null;
|
||||
var _pendingAutostart = false;
|
||||
@@ -163,7 +176,7 @@ function loadPlaySong(sandbox) {
|
||||
function _scheduleSectionPracticeRetries() {}
|
||||
function loadSavedLoops() {}
|
||||
function _songEventPayload() { return { time: 7, audioT: 7, chartT: 7, perfNow: 7 }; }
|
||||
${extractFunction(src, 'function setSpeed')}
|
||||
${extractFunction(controls, 'function setSpeed')}
|
||||
${speedPresetHelpers}
|
||||
${resetHelper}
|
||||
${extractFunction(src, 'async function playSong')}
|
||||
|
||||
@@ -7,20 +7,23 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a);
|
||||
// the autoplay-gate test below still reads app.js.
|
||||
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
const src = fs.readFileSync(TUNING_JS, 'utf8');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length),
|
||||
body,
|
||||
sandbox
|
||||
);
|
||||
return sandbox.window.feedBack;
|
||||
|
||||
@@ -6,10 +6,10 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
@@ -97,11 +97,6 @@ test('V3 index.html defines hud-tuning', () => {
|
||||
assert.match(html, /id="hud-tuning"/);
|
||||
});
|
||||
|
||||
test('V2 index.html defines hud-tuning', () => {
|
||||
const html = fs.readFileSync(V2_HTML, 'utf8');
|
||||
assert.match(html, /id="hud-tuning"/);
|
||||
});
|
||||
|
||||
test('highway.js updates hud-tuning from song_info tuning offsets', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(src, /getElementById\('hud-tuning'\)/);
|
||||
|
||||
@@ -6,23 +6,23 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
|
||||
+ 'exports.isBassArrangement = isBassArrangement;\n'
|
||||
|
||||
@@ -16,7 +16,11 @@ const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
|
||||
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
|
||||
// The rescan path moved into ./static/js/library.js with the rest of the library (R3a).
|
||||
// Read BOTH: this asserts the emit exists SOMEWHERE in the app, and pinning it to one file
|
||||
// just means the test starts lying the next time the code moves.
|
||||
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8')
|
||||
+ '\n' + fs.readFileSync(path.join(root, 'static', 'js', 'library.js'), 'utf8');
|
||||
|
||||
test('app.js emits library:changed when a Settings rescan completes', () => {
|
||||
assert.match(APP, /emit\(\s*['"]library:changed['"]/,
|
||||
|
||||
@@ -7,7 +7,8 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningName = displayTuningName;\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
|
||||
|
||||
@@ -9,6 +9,9 @@ const venueScene = require('../../static/v3/venue-scene-3d.js');
|
||||
const venueViz = require('../../static/v3/venue-viz.js');
|
||||
const pov = require('../../static/v3/venue-instrument-pov.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The viz layer (setViz / the venue option / the picker) was carved out of
|
||||
// app.js into its own module (R3a).
|
||||
const VIZ_JS = path.join(__dirname, '..', '..', 'static', 'js', 'viz.js');
|
||||
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const ASSET_DIR = path.join(__dirname, '..', '..', 'static', 'assets', 'venue', 'themes', 'small-club');
|
||||
@@ -185,8 +188,8 @@ test('venue-scene-3d exports bg plate asset ids', () => {
|
||||
assert.equal(venueScene.ASSET_BASE, '/static/assets/venue/themes/small-club/');
|
||||
});
|
||||
|
||||
test('app.js syncs venue 3D scene on viz changes', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('viz.js syncs venue 3D scene on viz changes', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\('venue'\)/);
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\(id\)/);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user