mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 09:24:31 +00:00
Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72212e7bfd | ||
|
|
c59883aac9 | ||
|
|
6272af8d33 | ||
|
|
831117fb96 | ||
|
|
6cc0312661 | ||
|
|
329cc86315 | ||
|
|
d876ded00f | ||
|
|
18d77d2d41 | ||
|
|
5921157f35 | ||
|
|
3e57ba0345 | ||
|
|
b85496fe58 | ||
|
|
4027c31a61 | ||
|
|
0fc6a4beed | ||
|
|
d26347981c | ||
|
|
45caa86ab8 | ||
|
|
8f1906a0c1 | ||
|
|
8b6829a946 | ||
|
|
3050c7b1d3 | ||
|
|
ba796b0f27 | ||
|
|
ddc06ff1e7 | ||
|
|
ac5c5ad20d | ||
|
|
f8012a8ce4 | ||
|
|
99b974a5a1 | ||
|
|
7ffa6e2c51 | ||
|
|
3832a5762b | ||
|
|
a60dcd10c2 | ||
|
|
5dcf39cd62 | ||
|
|
c485f02211 | ||
|
|
203f82b6fe | ||
|
|
0158286d06 | ||
|
|
1e2ce29cf6 | ||
|
|
b54b65d35c | ||
|
|
ab2e68a638 | ||
|
|
d806d12c22 | ||
|
|
32d723b774 | ||
|
|
ceb1e143cd | ||
|
|
d0626f5618 | ||
|
|
0dc9fd7ba8 | ||
|
|
22332bef22 | ||
|
|
342def3851 | ||
|
|
a0278bd3a7 | ||
|
|
67e6b25c43 | ||
|
|
503716acbf | ||
|
|
41bb4482fe | ||
|
|
0955f0b6f2 | ||
|
|
3a50e593bf | ||
|
|
5049be0523 | ||
|
|
de2a42bd35 | ||
|
|
671aba950c | ||
|
|
95d6d8a46e | ||
|
|
f43779c99e | ||
|
|
82aa8a757e | ||
|
|
cb425ed48d | ||
|
|
b74a364857 | ||
|
|
859b0036e5 | ||
|
|
a7348052ae | ||
|
|
1e5282e27e | ||
|
|
d364529919 | ||
|
|
81ef11d855 | ||
|
|
9e9f0fdac6 | ||
|
|
188bdaa837 | ||
|
|
330995588c | ||
|
|
254e26bb3a | ||
|
|
d508380532 | ||
|
|
fefb9051a4 | ||
|
|
e2215df753 | ||
|
|
e5cbea2e9f | ||
|
|
ea0ca94742 | ||
|
|
e779c72396 | ||
|
|
ffc52f13ce | ||
|
|
8d3db5f42c | ||
|
|
f27d4f623c | ||
|
|
57e7db5c2a | ||
|
|
545e569ad6 | ||
|
|
84fe29688c | ||
|
|
69aac32278 | ||
|
|
0a6e0309e5 | ||
|
|
36cf77dc44 | ||
|
|
12eb73aee9 | ||
|
|
1a386c272d | ||
|
|
8e89b39ad3 | ||
|
|
c6963fdf30 | ||
|
|
d9fa6d3f55 | ||
|
|
23ecddc721 | ||
|
|
79825af28e | ||
|
|
db3ca34fcb | ||
|
|
34215fbd32 | ||
|
|
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 |
@@ -0,0 +1,17 @@
|
||||
## What
|
||||
|
||||
<!-- What does this PR do, and why? Link the issue it addresses. -->
|
||||
|
||||
## feedpak surface
|
||||
|
||||
<!-- The feedpak spec is sacrosanct: the spec defines the format, this app implements it.
|
||||
Delete this section ONLY if your change doesn't touch how the app reads or writes packs. -->
|
||||
|
||||
- [ ] This PR does **not** change how the app reads/writes feedpaks (manifest keys, pack files, folder layout)
|
||||
- [ ] …or it does, and the spec change landed first via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) — FEP / spec PR: `got-feedback/feedpak-spec#___` (once it merges, re-run this PR's checks and the gate goes green)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `CHANGELOG.md` `[Unreleased]` updated (user-visible changes)
|
||||
- [ ] Tests added/updated for new behaviour
|
||||
- [ ] Commits are DCO signed off (`git commit -s`)
|
||||
@@ -124,6 +124,94 @@ jobs:
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
feedpak-spec:
|
||||
# Guard that core stays faithful to the feedpak format spec, which lives in
|
||||
# its own repo (got-feedback/feedpak-spec) and is the contract third-party
|
||||
# packers and players build against. Four surface checks: core reads/writes
|
||||
# only manifest keys the spec declares (and the scanned-module list can't
|
||||
# fall behind); the exception allowlist never grows, so the FEP process is
|
||||
# the only way a new key lands; core ingests the spec's example packs; packs
|
||||
# committed here pass the spec's reference validator. Motivated by
|
||||
# #933, where a manifest key (`original_audio`) shipped in core without ever
|
||||
# reaching the spec.
|
||||
#
|
||||
# The gate checks against the spec repo's HEAD, deliberately: the app must
|
||||
# conform to the LIVING spec, always. The dev flow is self-serve — a gated
|
||||
# PR opens a FEP, the spec PR merges, re-running this job goes green; no
|
||||
# pin file to bump, nothing to maintain. Accepted trade-off: a BREAKING
|
||||
# spec change (rare, deliberate, MAJOR per the spec's compatibility policy)
|
||||
# reddens every PR here until core conforms — which is the correct
|
||||
# org-wide signal that the app is out of conformance. The normal FEP is
|
||||
# additive and can never redden this job.
|
||||
name: feedpak-spec
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# This job runs repository code (tools/check_spec_conformance.py) and
|
||||
# never pushes; don't leave the token in git config for it.
|
||||
# fetch-depth: 0 so the base branch is available — the gate must prove
|
||||
# the exception allowlist didn't grow in this PR.
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Check out feedpak-spec at HEAD
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: got-feedback/feedpak-spec
|
||||
ref: main
|
||||
path: .feedpak-spec
|
||||
persist-credentials: false
|
||||
|
||||
- name: Record the spec commit this run verified against
|
||||
# HEAD-tracking means CI results can differ across time on the same
|
||||
# commit. Log the exact spec SHA so a red run is reproducible.
|
||||
run: git -C .feedpak-spec rev-parse HEAD
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
# CI-only: the spec's reference validator needs jsonschema. Not a
|
||||
# runtime dependency — this gate never runs on the serve/Docker path
|
||||
# (constitution Principle I). Pinned for the same reason the spec SHA
|
||||
# is: an upstream release must not turn this job red on a PR that
|
||||
# changed neither this repo nor the spec.
|
||||
pip install 'jsonschema==4.26.0'
|
||||
|
||||
- name: Fetch the base branch's exception allowlist
|
||||
id: baseline
|
||||
run: |
|
||||
# The allowlist is closed: it grandfathers keys that predate this gate
|
||||
# and may only shrink. Prove that by diffing against the base branch —
|
||||
# without this, anyone could append an entry and route around the FEP
|
||||
# process from inside this repo.
|
||||
#
|
||||
# Resolve the base rather than hardcoding `main`: ship-ci.yml also runs
|
||||
# this workflow for PRs into release/** and for pushes to release/**,
|
||||
# where a main baseline would diff against the wrong branch.
|
||||
# PR -> the branch it merges into
|
||||
# push -> the branch itself (its tip already contains the change, so
|
||||
# this is a no-op; enforcement happens at PR time)
|
||||
BASE="${{ github.event.pull_request.base.ref || github.ref_name }}"
|
||||
echo "diffing the allowlist against origin/$BASE"
|
||||
git fetch --no-tags --depth=1 origin "$BASE"
|
||||
if git cat-file -e FETCH_HEAD:feedpak-spec-exceptions.yml 2>/dev/null; then
|
||||
git show FETCH_HEAD:feedpak-spec-exceptions.yml > "$RUNNER_TEMP/baseline-exceptions.yml"
|
||||
echo "args=--baseline-exceptions $RUNNER_TEMP/baseline-exceptions.yml" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Only true until the PR that introduces this gate lands.
|
||||
echo "args=--bootstrap-allowlist" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check feedpak spec conformance
|
||||
run: python tools/check_spec_conformance.py --spec .feedpak-spec ${{ steps.baseline.outputs.args }}
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
|
||||
@@ -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__/
|
||||
|
||||
+157
@@ -7,6 +7,158 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Career on the Profile and Home pages** — the Profile gains a passport
|
||||
wall (earned-badge covers per instrument, hours, gig count; absent until a
|
||||
passport exists), injected through the same mount-point + rendered-event
|
||||
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
|
||||
The home page's plugin-count stat tile becomes a career trading card
|
||||
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
|
||||
built-in fallback when career has no state. Earned passports gain **Save
|
||||
card / Copy card** — a natively-drawn PNG passport card, downloadable or
|
||||
copied straight to the clipboard for pasting outside the app (shared
|
||||
`blob-io` helpers replace the download idiom previously duplicated in
|
||||
settings-io and diagnostics-export).
|
||||
- **Gigs (backend)** — career mode gains its verb: `POST
|
||||
/api/plugins/career/gigs/propose` builds a playable setlist for an
|
||||
instrument+genre (your qualifying songs plus a couple of stakes songs near
|
||||
the bar; a young passport fills from unplayed genre songs — the first gig
|
||||
is how stubs start; re-roll by calling again), naming the room your stars
|
||||
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
|
||||
from the set's own freshly-recorded stats, an encore flag at the
|
||||
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
|
||||
log (no fail state: the gig you finished is the gig you played). Passports
|
||||
carry their gig log; instruments their gig count.
|
||||
|
||||
### Changed
|
||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
||||
(feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it
|
||||
from now.
|
||||
|
||||
`full` is a mixdown, not a layer — it already contains every instrument — so
|
||||
`load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`.
|
||||
Nothing that sums stems or renders one fader per stem can see it, which is what
|
||||
makes retaining it safe; leaving it in the list would double the whole song and
|
||||
leave "guitar" audible with the guitar fader muted. That trap is exactly why the
|
||||
packer invented the key instead of putting the mixdown where the format says it
|
||||
goes — the bug was in the reader, and this fixes the reader.
|
||||
|
||||
Consequences worth knowing:
|
||||
- The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`.
|
||||
`original_audio_url` / `has_original_audio` remain as **deprecated aliases**
|
||||
(same values) for one release so a client built against the old frame keeps
|
||||
working; they go with the fallback below (#945).
|
||||
- `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now
|
||||
describe *instrument* stems only — a separated pack that retains its mixdown no
|
||||
longer advertises a bogus "full" stem chip or an inflated stem count.
|
||||
- Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same
|
||||
way, which **widens** its coverage: it previously returned `None` for any pack
|
||||
without the invented key, so fingerprinting silently did nothing for the
|
||||
overwhelming majority of packs.
|
||||
- Core still **reads** `original_audio:` as a deprecated fallback, because every
|
||||
pack written before the spec caught up carries it and would otherwise lose its
|
||||
pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the
|
||||
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
|
||||
at `default: off`, drops the key); the fallback and the aliases are removed once
|
||||
they are migrated (#945).
|
||||
|
||||
### Added
|
||||
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
|
||||
resolves override → pack genre → the enrichment match's primary genre
|
||||
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
|
||||
which starved the library genre facet and career passports on real
|
||||
libraries; with the fallback, every enriched song's genre is browsable and
|
||||
passport-able immediately, and coverage grows as enrichment runs.
|
||||
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
|
||||
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
|
||||
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
|
||||
a no-op without a venue pack) and a full-screen overlay drops the bronze
|
||||
stamp with a shine sweep and a confetti burst over whatever screen is active
|
||||
(badges land right after `stats:recorded`, while the player is still up).
|
||||
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
|
||||
chime + notification only. The stamp still slams into the passport book on
|
||||
next open, unchanged.
|
||||
- **Hours-per-genre odometer (career passports)** — the app now measures real
|
||||
play time: the stats recorder accrues **wall-clock** seconds across
|
||||
play/resume ↔ pause/stop/end spans (wall time, not song position — position
|
||||
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
|
||||
against suspend/sleep inflation) and piggybacks them as `seconds` on the
|
||||
`POST /api/stats` calls it already makes. New additive
|
||||
`song_stats.seconds_total` column; a seconds-only POST banks time for
|
||||
unscored plays that run to the song's natural end without touching the
|
||||
resume position (and still counts as playing today for the streak).
|
||||
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
|
||||
shelf cover — a true fact that only grows, never a target or a meter.
|
||||
- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz
|
||||
now also asks for that genre's signature Virtuoso drill (Blues Shuffle,
|
||||
Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one
|
||||
per genre, data-driven in `passports.json` with display labels). Drill
|
||||
lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat
|
||||
list still means guitar), so a keys passport never demands a guitar drill.
|
||||
A drill counts as cleared on the first real completion artifact — a
|
||||
top-tier clean pass in one key (`keysCleared`), any depth rung, or
|
||||
mastery — rather than only the maxed-speed depth flips. Genres without a
|
||||
curated drill stay songs-only.
|
||||
- **Career passport visuals pack** — earned covers and badge stamps become
|
||||
trading cards (pointer-tracked tilt + light glint, hover-capable devices
|
||||
only); the ghost stamp visibly "carves in" as qualifying songs land (a
|
||||
conic ink fill, no numbers added); the Gold rung preview is a small foil
|
||||
chip with a shimmer sweep, still honestly labeled coming. All theatrics
|
||||
disabled under `prefers-reduced-motion`.
|
||||
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||
effective genre — Bronze = N genre songs at K★, data-driven in
|
||||
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
|
||||
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
|
||||
(instrument commitment), `POST /passports/open` (open a genre
|
||||
passport), and `POST /drill-state` (intake for the relayed Virtuoso
|
||||
`virtuoso.progress` snapshot, so drill requirements can gate badges
|
||||
server-side). Badges are never stored; the only persisted state (commitments,
|
||||
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
|
||||
settings export/import bundle via `settings.server_files`. Instruments are
|
||||
attributed via the existing progression arrangement→instrument mapping;
|
||||
non-graded instruments (bass, drums) render shown-not-judged — repertoire
|
||||
without a pass bar, never a false badge denial.
|
||||
- **Career passports (UI)** — the Career screen gains a Passports tab beside
|
||||
Venues: a physical per-instrument passport book (embossed leather cover, 3D
|
||||
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
|
||||
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
|
||||
collected ticket stubs, and unopened genres as an "Explore next"
|
||||
travel-brochure rack (invitations, never greyed-out slots or completion
|
||||
meters). Badge earns chime + notify immediately; the stamp slam plays when
|
||||
the passport is next opened. Four small synthesized sound effects ship as
|
||||
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
|
||||
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
|
||||
events (debounced, plus a one-time bootstrap), closing the
|
||||
fires-into-a-void seam without touching the virtuoso plugin.
|
||||
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
|
||||
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
|
||||
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
|
||||
`original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties
|
||||
in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's
|
||||
`manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and
|
||||
`lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including
|
||||
`setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared
|
||||
one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module
|
||||
list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows;
|
||||
(3) **forward** — core's `load_song()` ingests every example pack the spec ships;
|
||||
(4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today).
|
||||
The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the
|
||||
flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to
|
||||
pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible.
|
||||
**There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the
|
||||
spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then
|
||||
re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed
|
||||
grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**)
|
||||
diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink.
|
||||
`original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the
|
||||
*next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is
|
||||
removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs:
|
||||
[docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md).
|
||||
|
||||
### 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
|
||||
@@ -27,6 +179,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||
|
||||
### Fixed
|
||||
- **Career passports review polish** — the passport tabs and book overlay carry
|
||||
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
||||
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
||||
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
|
||||
`"null"`) can no longer throw on every passport refresh.
|
||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
||||
|
||||
@@ -465,6 +465,40 @@ window.feedBack.diagnostics.contribute('my_plugin', {
|
||||
|
||||
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
|
||||
|
||||
### Detachable panes — pop your panel out into its own window
|
||||
|
||||
If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls:
|
||||
|
||||
```js
|
||||
feedBack.panes.register({
|
||||
id: 'camera_director',
|
||||
title: 'Camera Director',
|
||||
icon: '🎥',
|
||||
element: () => panelEl, // your existing panel, exactly as it is
|
||||
});
|
||||
feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||
```
|
||||
|
||||
**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync.
|
||||
|
||||
The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**.
|
||||
|
||||
- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`.
|
||||
|
||||
- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window.
|
||||
|
||||
- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible.
|
||||
|
||||
- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown.
|
||||
|
||||
- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback.
|
||||
|
||||
- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen.
|
||||
|
||||
- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model.
|
||||
|
||||
- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
|
||||
@@ -554,6 +588,21 @@ tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still
|
||||
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
|
||||
a local pointer + code map.
|
||||
|
||||
**The spec is sacrosanct — read it BEFORE changing how this app reads or writes packs.** The
|
||||
spec repo defines the format; this app merely implements it ("a change is not part of the format
|
||||
until it lands here" — feedpak-spec/GOVERNANCE.md). Any new manifest key, file, or directory the
|
||||
app touches must land in the spec **first**, via the
|
||||
[FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) (proposal
|
||||
issue → one spec PR updating spec + schemas + example + changelog → then re-run your PR's checks
|
||||
here; the gate verifies against the spec's HEAD, so it goes green the moment your key is real).
|
||||
CI enforces this: the `feedpak-spec` job
|
||||
([docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md)) fails any PR whose code touches a
|
||||
manifest key the spec doesn't declare, and there is **no in-repo bypass** — the exceptions
|
||||
file is a closed grandfather list that only shrinks. If the format seems to be missing something
|
||||
you need, that's a FEP conversation, not a workaround. (Cautionary tale: `original_audio`, #933 —
|
||||
shipped without a spec entry, and third-party packers reverse-engineered a folder convention out
|
||||
of a code comment.)
|
||||
|
||||
**Key code:**
|
||||
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
|
||||
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# The feedpak spec-conformance gate
|
||||
|
||||
`tools/check_spec_conformance.py`, run in CI as the `feedpak-spec` job.
|
||||
|
||||
## Why
|
||||
|
||||
feedpak is published as an **open format**: its own repo
|
||||
([got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)), a normative spec, JSON
|
||||
Schemas, and a reference validator. That is a promise to everyone outside this codebase — third-party
|
||||
packers, converters, and players build against the spec, and the spec is meant to be the complete and
|
||||
authoritative description of a pack.
|
||||
|
||||
The moment core reads a manifest key the spec doesn't define, that promise breaks silently:
|
||||
|
||||
- A spec-compliant pack is no longer guaranteed to be a fully-working pack.
|
||||
- The reference validator can't warn authors about a key it has never heard of — it will happily green-light
|
||||
the key, and every misspelling of it.
|
||||
- The format's real definition drifts into our source tree. In the case that motivated this gate
|
||||
([#933](https://github.com/got-feedback/feedback/issues/933)), third-party tooling started emitting an
|
||||
`original/` directory that no code anywhere requires — the convention was reverse-engineered from an
|
||||
example in a *code comment*.
|
||||
|
||||
The rule this gate enforces: **any manifest key core reads _or writes_ must be in the spec before core
|
||||
ships code that depends on it.** Spec first, implementation second. Writes are not exempt — a key core
|
||||
writes lands in every pack we emit, so an undeclared one seeds the ecosystem with non-spec data.
|
||||
|
||||
Note that "get it into the spec" is not automatically the right fix for an existing violation — for
|
||||
`original_audio` it isn't. The spec already carries the pre-separation mixdown as a stem
|
||||
(`{id: full, file: stems/full.ogg}`), so that key added a *second, redundant* location for audio to a format
|
||||
that already had one, and the resolution is to remove it rather than bless it. The gate takes no position on
|
||||
which way a violation resolves; it only insists that one of the two happens deliberately, in the open,
|
||||
before the code merges.
|
||||
|
||||
## What it checks
|
||||
|
||||
We can't mechanically prove core *interprets* a key the way the spec means. We can prove four surface
|
||||
properties, and they cover the drift that actually occurs.
|
||||
|
||||
| Layer | Check | Catches |
|
||||
|---|---|---|
|
||||
| 1. key-coverage | Every manifest key core reads **or writes** is declared in the spec's `manifest.schema.json`. | Core growing a key the spec never defined — the #933 class. |
|
||||
| 2. allowlist-closed | `feedpak-spec-exceptions.yml` has not **grown** relative to the base branch. | Someone routing around the FEP process by allowlisting their own new key. |
|
||||
| 3. forward | Core's `load_song()` ingests every example pack the spec ships. | The spec adding or tightening something core ignores or breaks on. |
|
||||
| 4. reverse | Every pack committed to this repo passes the spec's `tools/validate.py`. | Core (or a contributor) committing a pack the spec would reject. |
|
||||
|
||||
Layer 1 works by walking the AST of the modules listed in `READERS` and collecting every literal key touched
|
||||
on a manifest dict (`manifest.get("x")`, `manifest["x"]`, and the wrapped
|
||||
`(load_manifest(p) or {}).get("x")` form used in `lib/enrichment.py`).
|
||||
|
||||
**Reads and writes are both checked, and reported differently.** A key core *writes*
|
||||
(`manifest["x"] = v`, as `lib/songmeta.py` does) is spec surface pointed outward: it puts a key into every
|
||||
pack we emit, so an undeclared one seeds the ecosystem with non-spec data. Subscripts are classified by AST
|
||||
context — `Store` is a write, `Load` is a read — so `manifest["year"] = ...` is not miscounted as a read.
|
||||
|
||||
## When it fails
|
||||
|
||||
You added a manifest key the spec doesn't define. **There is exactly one way forward, and it is not in this
|
||||
repo.**
|
||||
|
||||
Land the key in the spec through the **feedpak Enhancement Proposal (FEP)** process
|
||||
([feedpak-spec/CONTRIBUTING.md](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md)):
|
||||
|
||||
1. **Open a FEP issue** on `got-feedback/feedpak-spec` — the problem, the proposed on-disk shape (manifest
|
||||
key and/or side-file), backward compatibility, and the version bump it implies.
|
||||
2. **Discuss**, until it has a clear shape and rough consensus.
|
||||
3. **Land one PR there** that updates the normative spec (`spec/feedpak-v1.md`), the relevant JSON
|
||||
Schema(s), an example in `examples/` that exercises it, and the changelog — *together*. A PR touching
|
||||
only one of those is incomplete.
|
||||
4. **Back here**, just re-run your PR's checks. The gate verifies against the spec's HEAD, so the moment
|
||||
your key is genuinely part of the format, your PR goes green — nothing to bump, nothing to maintain.
|
||||
|
||||
That's deliberately the only route — no experimental prefix, no self-serve allowlist — and it's usually a
|
||||
quick one for additive keys. The reason it's worth the round-trip: the gate checks the whole repo against
|
||||
the living spec, so if non-conformance ever lands, it shows up as red CI on *every* teammate's open PR, and
|
||||
only the person who introduced it can clear it. Going through the FEP keeps your change clean and keeps
|
||||
everyone else unblocked.
|
||||
|
||||
The spec's own governance says the same thing:
|
||||
|
||||
> This repository defines the format only. Applications that read or write feedpak ... track this spec as a
|
||||
> dependency; they do not drive it. **A change is not part of the format until it lands here.**
|
||||
> — [feedpak-spec/GOVERNANCE.md](https://github.com/got-feedback/feedpak-spec/blob/main/GOVERNANCE.md)
|
||||
|
||||
### `feedpak-spec-exceptions.yml` is a closed grandfather list, not a hatch
|
||||
|
||||
It exists solely because `original_audio` predates the gate. **CI fails any PR that adds an entry** (layer 2
|
||||
diffs it against the base branch), so the list can only ever shrink. Entries are debt, each carries a
|
||||
tracking issue, and each disappears when the underlying key is removed from core. The gate also fails on a
|
||||
*stale* entry — the spec caught up, or core stopped touching the key — so the file cannot quietly become
|
||||
somewhere drift accumulates.
|
||||
|
||||
Deleting an entry does not, by itself, get you past the gate: layer 1 still fails while core reads the key.
|
||||
The entry goes when the **code** goes.
|
||||
|
||||
## Tracking the spec's HEAD
|
||||
|
||||
The gate checks out `feedpak-spec` at **HEAD**, on purpose: the app must conform to the *living* spec, and
|
||||
nobody should have to maintain a pin. The dev flow is fully self-serve — gated PR → FEP → spec merge →
|
||||
re-run checks → green.
|
||||
|
||||
Two properties to know about:
|
||||
|
||||
- **The normal FEP is additive** (a new optional key), which only ever makes the gate *looser* — it cannot
|
||||
redden anyone's PR. Only a **breaking** spec change (removing/renaming a key the app uses, tightening the
|
||||
validator against committed packs) turns PRs red repo-wide — and per the spec's compatibility policy that
|
||||
is a rare, deliberate MAJOR event, exactly when an org-wide "the app is out of conformance" signal is the
|
||||
right outcome. The CI job logs the exact spec SHA each run verified against, so a red run is reproducible.
|
||||
- **CI results can change over time on the same commit** — that is inherent to tracking a living contract,
|
||||
and it is the point: green means "conformant *now*", not "conformant when written".
|
||||
|
||||
## Limitations
|
||||
|
||||
Known, and worth fixing in follow-ups rather than blocking on:
|
||||
|
||||
- **Layer 1's receiver detection is heuristic.** Locals *assigned from* `load_manifest(...)` are discovered
|
||||
flow-aware whatever they're called (chart.py's `m` taught us that), and the inline
|
||||
`(load_manifest(p) or {}).get(...)` form is recognised — but a manifest that arrives as a **function
|
||||
parameter** is only recognised by name (`MANIFEST_VARS`: `manifest`, `mf`). A parameter called something
|
||||
else would slip. The hardening step is to route all manifest access through a single declared
|
||||
`KNOWN_MANIFEST_KEYS` registry in `lib/sloppak.py`; the gate then compares registry against schema exactly
|
||||
instead of inferring.
|
||||
- **Layer 1 covers top-level keys only.** Nested structure (`arrangements[].file`, `.id`, `.notation`) isn't
|
||||
checked. Extending to it means walking the schema's `$ref` subschemas.
|
||||
- **Layer 1 recognises `get`, `setdefault`, subscripts, and the known gap-fill helper** as key access.
|
||||
`update()` and `pop()` aren't used against a feedpak manifest anywhere in the tree, so they're deliberately
|
||||
not special-cased rather than speculatively handled. `readers-complete` reuses the same scanner
|
||||
(`keys_touched()`), so this blind spot is shared, not doubled: a module using only unrecognised access forms
|
||||
would evade both.
|
||||
- **Layer 4 can't catch unknown keys**, because `manifest.schema.json` sets `additionalProperties: true` and
|
||||
the reference validator deliberately "treats unknown keys/files as forward-compatible". Fixing this
|
||||
properly belongs in the spec (tighten the schema, or give the validator a `--strict` mode). Until then,
|
||||
layer 1 is the only thing standing between us and the next `original_audio`.
|
||||
@@ -0,0 +1,354 @@
|
||||
# Detachable panes (`window.feedBack.panes`)
|
||||
|
||||
Pop a panel out of the app into its own OS window, and leave it there: while you
|
||||
play, across song switches, on a second monitor, minimized to the system tray.
|
||||
|
||||
Panes exist because the player's rail popovers are **exclusive** — opening one
|
||||
closes the last. You cannot watch the mixer while riding the camera, and both
|
||||
vanish the moment you want to look at the highway.
|
||||
|
||||
---
|
||||
|
||||
## The whole idea, in one sentence
|
||||
|
||||
**We move the real element.**
|
||||
|
||||
Not a copy of your panel. Not a re-implementation of it in the pop-out window.
|
||||
The actual DOM node. Same-origin windows can adopt each other's nodes, and an
|
||||
adopted node keeps its event listeners and its closures — so your panel goes on
|
||||
running *your* code, against *your* state, in *your* realm. The app's stylesheets
|
||||
are copied into the pane window, so it looks identical too.
|
||||
|
||||
What you popped out is what you get. That is the promise, and it is the reason
|
||||
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
|
||||
your UI to keep in step with the first. Those are all solutions to a problem we
|
||||
simply do not have.
|
||||
|
||||
---
|
||||
|
||||
## Adding a pane to your plugin
|
||||
|
||||
Two lines.
|
||||
|
||||
```js
|
||||
// Guard: the panes API is optional. On a host without it, skip both calls and
|
||||
// your panel behaves exactly as it does today.
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (panes && typeof panes.register === 'function') {
|
||||
panes.register({
|
||||
id: 'camera_director',
|
||||
title: 'Camera Director',
|
||||
icon: '🎥',
|
||||
element: () => panelEl, // your existing panel, as it is
|
||||
});
|
||||
panes.attachChip(panelEl, 'camera_director');
|
||||
}
|
||||
```
|
||||
|
||||
`attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
|
||||
place, same behaviour in every plugin. Clicking it moves your panel to whichever
|
||||
**host** the router picks — usually a pop-out window, but the dock when a window
|
||||
can't be had (a blocked pop-up, or `defaultHost: 'dock'`) — and leaves a
|
||||
"⇲ … is popped out" stub in its place. Clicking the stub brings the panel back, to
|
||||
exactly the spot it left. Core owns the chip, the hiding and the stub, so you write
|
||||
no show/hide logic.
|
||||
|
||||
That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
|
||||
your state — all of it comes along, because none of it moved anywhere except into
|
||||
a different window's document.
|
||||
|
||||
### `element` is a function for a reason
|
||||
|
||||
It is resolved at open time, not at registration. Plugins commonly build their
|
||||
panel lazily on first use, or rebuild it wholesale when something changes (Camera
|
||||
Director rebuilds its panel on every mode change). Asking for it when we need it
|
||||
means we always move the live one.
|
||||
|
||||
**If you rebuild your panel, re-attach the chip.** Rebuilding takes the chip with
|
||||
it. `attachChip()` returns a `detach()`; call it before re-attaching, and again in
|
||||
your teardown — otherwise you leave a stub pointing at DOM that no longer exists.
|
||||
|
||||
```js
|
||||
if (chipDetach) chipDetach();
|
||||
chipDetach = panes.attachChip(panel, PANE_ID, { header: toolsEl });
|
||||
```
|
||||
|
||||
Re-attaching is safe while the pane is popped out: the chip reconciles against the
|
||||
pane's real state, so a panel rebuilt mid-pop-out stays correctly stubbed.
|
||||
|
||||
### The two things core changes about your element
|
||||
|
||||
**1. Placement.** `.fb-paned` is added while the pane is out:
|
||||
|
||||
```css
|
||||
position: static; inset: auto; margin: 0; width: 100%;
|
||||
max-width: none; max-height: none; z-index: auto; box-shadow: none;
|
||||
```
|
||||
|
||||
Your panel was almost certainly a fixed overlay pinned to a corner of the app
|
||||
(`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
|
||||
every one of those is wrong — it would float 72px down from the top of a 380px
|
||||
window, still 288px wide, still casting a shadow over nothing.
|
||||
|
||||
Note there is deliberately **no `display` override**: a panel that is
|
||||
`display:flex` or `grid` stays that way. Colours, borders, radius, padding, fonts
|
||||
and your panel's own internal layout are untouched.
|
||||
|
||||
**2. Visibility.** A panel is usually hidden until its launcher is clicked, and a
|
||||
pane can be opened from the tray or the rail without that ever happening — so core
|
||||
un-hides it, in the two ways a panel is actually hidden:
|
||||
|
||||
```js
|
||||
el.hidden = false;
|
||||
if (el.style.display === 'none') el.style.display = '';
|
||||
```
|
||||
|
||||
**Both are restored exactly as they were when the pane docks**, along with the
|
||||
`.fb-paned` class. A panel that was closed when you opened its pane from the tray
|
||||
goes back to being closed; one that was open stays open.
|
||||
|
||||
---
|
||||
|
||||
## Spec
|
||||
|
||||
```js
|
||||
feedBack.panes.register({
|
||||
id, // required, unique
|
||||
element, // required — an Element, or a function returning one
|
||||
title, // shown in the pane window's title bar, the dock card, the tray
|
||||
icon, // one glyph, for the dock/tray/launcher lists
|
||||
width, height, // the pane window's initial size (it remembers yours after that)
|
||||
defaultHost, // 'window' (default) or 'dock'
|
||||
onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
|
||||
});
|
||||
```
|
||||
|
||||
```js
|
||||
feedBack.panes.attachChip(el, paneId, { header }) // → detach()
|
||||
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
|
||||
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
|
||||
```
|
||||
|
||||
`attachChip` puts the chip in the `header` element you pass, else in
|
||||
`el.querySelector('[data-pane-header]')` if it finds one, else at the top of `el`.
|
||||
An explicit `header` always wins.
|
||||
|
||||
---
|
||||
|
||||
## Hosts
|
||||
|
||||
`detach(id)` puts a pane in the best host available:
|
||||
|
||||
| host | | |
|
||||
|---|---|---|
|
||||
| `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
|
||||
| `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
|
||||
|
||||
You don't pick; you declare `defaultHost` and the router does the rest.
|
||||
|
||||
In the **desktop app** a pane you left popped out comes back popped out on next
|
||||
launch. In a **browser** it comes back **docked** — a browser blocks
|
||||
`window.open()` without a user gesture, so restoring it would only ever produce a
|
||||
"pop-up blocked" toast. The chip pops it out again on your next click.
|
||||
|
||||
---
|
||||
|
||||
## Best practices
|
||||
|
||||
Every item below is something that has already gone wrong, in this codebase, on
|
||||
this feature. They are cheap to get right up front and confusing to diagnose later
|
||||
— a broken pane usually *looks* perfect.
|
||||
|
||||
### 1. Your code still runs in the main window
|
||||
|
||||
The element is *displayed* in the pane window, but its closures, its timers and its
|
||||
`document` references all still belong to the main realm. **That is precisely why
|
||||
everything keeps working** — and it has one sharp consequence:
|
||||
|
||||
```js
|
||||
// WRONG — lands in the MAIN window, not the pane the user is looking at.
|
||||
document.body.appendChild(myTooltip);
|
||||
|
||||
// RIGHT — anchored to the panel, so it travels with it.
|
||||
panelEl.appendChild(myTooltip);
|
||||
```
|
||||
|
||||
**And every lookup for something inside your panel.** Once the panel has moved,
|
||||
`document.getElementById('my-panel-thing')` returns `null` — so every update it
|
||||
guards silently stops happening, precisely while the user is looking at the panel.
|
||||
No error. Just a UI that quietly goes dead.
|
||||
|
||||
```js
|
||||
// WRONG — null once the panel is popped out.
|
||||
document.getElementById('my-panel-hint').textContent = msg;
|
||||
|
||||
// RIGHT — search FROM the panel; works in either document.
|
||||
panelEl.querySelector('#my-panel-hint').textContent = msg;
|
||||
```
|
||||
|
||||
Elements that live outside your panel (your plugin's *screen*, host chrome) never
|
||||
move, and should keep using `document.getElementById`. Audit which is which — in
|
||||
the stem mixer, four ids were inside the panel and a dozen were not.
|
||||
|
||||
Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a
|
||||
dismiss listener on `window` watches a window the user isn't clicking in. Use
|
||||
`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your
|
||||
panel is actually in.
|
||||
|
||||
### 2. Don't hide your panel yourself
|
||||
|
||||
Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it,
|
||||
you are hiding the node that just moved — and the pane window renders nothing.
|
||||
(This is not hypothetical: core's own chip did exactly this, and the first
|
||||
pop-out shipped blank because of it.)
|
||||
|
||||
### 3. Prefer `hidden` or a class for show/hide
|
||||
|
||||
Core makes your panel visible while it's hosted — it clears `hidden`, and clears an
|
||||
inline `display: none` if that's how you hide — and **restores both on dock**. So
|
||||
either style works.
|
||||
|
||||
`hidden` is still the better choice: it composes with everything, and it leaves
|
||||
your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core
|
||||
deliberately does not override `display` for exactly that reason.
|
||||
|
||||
```js
|
||||
panel.hidden = true; // best
|
||||
panel.style.display = 'none'; // works — core saves and restores it
|
||||
```
|
||||
|
||||
### 4. `element` is a function — return the *live* node
|
||||
|
||||
It is resolved when the pane opens, not when you register. Plugins build panels
|
||||
lazily, and rebuild them wholesale (Camera Director rebuilds on every mode
|
||||
change). If you rebuild yours, **re-attach the chip**:
|
||||
|
||||
```js
|
||||
if (chipDetach) chipDetach(); // attachChip returns a detach()
|
||||
chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl });
|
||||
```
|
||||
|
||||
Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that
|
||||
no longer exists.
|
||||
|
||||
### 5. `isConnected` lies about a panel that is a pane
|
||||
|
||||
This one has cost more debugging than everything else on this page combined, and
|
||||
it lies in **both directions**.
|
||||
|
||||
**It says `true` when your panel is not here.** A panel sitting in a pane window is
|
||||
`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets
|
||||
`true` and then acts on a panel that is somewhere else entirely.
|
||||
|
||||
**It says `false` when your panel is perfectly fine.** The host *detaches* the
|
||||
element the moment a pop-out starts, before the new window has even loaded. In that
|
||||
gap `isConnected` is `false` — and any code that rebuilds on that basis builds a
|
||||
**second panel**, while the host is still holding the first.
|
||||
|
||||
That second panel is the one your module variables now point at. The one the user
|
||||
can *see* is the original, owned by nobody. So:
|
||||
|
||||
- its close button closes the *other*, invisible panel — "the X doesn't work"
|
||||
- your chip gets re-attached to the impostor — "the pop-out icon vanished"
|
||||
|
||||
Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it.
|
||||
|
||||
**Ask the pane system, not the DOM.** It knows where your element is:
|
||||
|
||||
```js
|
||||
function paneOwnsPanel() {
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID));
|
||||
}
|
||||
|
||||
// "Is my panel gone?" — not "is it in this document?"
|
||||
if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere
|
||||
```
|
||||
|
||||
Every `isConnected` check on a panel that can be a pane needs this. In the stem
|
||||
mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's
|
||||
fast path (which decided the UI was unmounted and swept on every mutation).
|
||||
|
||||
For "which document is it in right now", use `el.ownerDocument === document`, or
|
||||
take the optional `onHost(hostId, el)` callback, which fires on both moves.
|
||||
|
||||
### 6. If your plugin can be re-injected, it must be able to remove itself
|
||||
|
||||
The host may run your script more than once — a screen re-entry, a version change.
|
||||
Without a teardown, the second run builds a second panel while the first one is
|
||||
still on screen, and every module variable in the new instance points at the new,
|
||||
invisible one. The user clicks the panel they can see; nothing happens.
|
||||
|
||||
Everything stateful duplicates: observers, timers, listeners. And one thing is
|
||||
worse than duplicated — **your pane registration**:
|
||||
|
||||
```js
|
||||
panes.register({ id, element: () => panel }); // resolved LAZILY, at open time
|
||||
```
|
||||
|
||||
First registration wins, so a stale one hands the host `panel` from a **dead
|
||||
instance**. Popping out then moves a panel nobody owns.
|
||||
|
||||
So publish a teardown handle and call it at the top of your script:
|
||||
|
||||
```js
|
||||
if (window.__myPluginInstance?.destroy) {
|
||||
try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ }
|
||||
}
|
||||
|
||||
window.__myPluginInstance = {
|
||||
destroy() {
|
||||
observer?.disconnect();
|
||||
clearTimeout(myTimer);
|
||||
chipDetach?.(); // attachChip() returned this
|
||||
panes?.unregister?.(MY_PANE_ID); // ← the one people forget
|
||||
document.querySelectorAll('#my-panel').forEach((n) => n.remove());
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Belt and braces: when you build your panel, remove any node carrying its id that
|
||||
isn't yours. A zombie panel is worse than no panel — it looks alive and does
|
||||
nothing.
|
||||
|
||||
### 7. Expect rAF to be throttled while your pane has focus
|
||||
|
||||
Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the
|
||||
main window is exactly what's backgrounded while the user is looking at your pane.
|
||||
Your rAF lives in the main window.
|
||||
|
||||
Event-driven panels (sliders, buttons, presets) don't care. A panel that
|
||||
*animates continuously* may run slowly precisely when it's the only thing on
|
||||
screen. Drive such animation from data you already have, or accept the stutter.
|
||||
|
||||
### 8. Don't synchronise anything
|
||||
|
||||
No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored
|
||||
UI. There is **one** realm and **one** panel. If you find yourself writing sync
|
||||
code, you have misunderstood the model — the whole point is that there is nothing
|
||||
to sync.
|
||||
|
||||
### 9. Nothing here is required
|
||||
|
||||
On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls
|
||||
and your panel behaves exactly as it does today. Guard, don't depend:
|
||||
|
||||
```js
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.register !== 'function') return;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Things core guarantees
|
||||
|
||||
- **The element goes home exactly where it came from** — same parent, same position
|
||||
among its siblings. Don't move it yourself while it's popped out.
|
||||
- **It comes home alive.** Core evacuates the element *before* the pane window's
|
||||
document is destroyed. (Get this wrong — dock after the window dies — and the
|
||||
node returns looking perfect with every listener in its subtree silently gone.
|
||||
That bug is why this section exists.)
|
||||
- **A pane window the user closes, or that crashes, is reaped** and the element
|
||||
docked back. Your panel is never stranded in a dead document.
|
||||
- **The app's stylesheets are copied into the pane window**, so your panel looks
|
||||
identical — including your plugin's own `styles` sheet.
|
||||
@@ -189,3 +189,23 @@ out of the capability graph.
|
||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||
rail 30, popovers 40).
|
||||
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
||||
|
||||
## Injecting into core shells (profile, dashboard)
|
||||
|
||||
Core screens that accept plugin sections render **mount points** — usually
|
||||
empty, sometimes holding core's own **fallback content** (the Dashboard's
|
||||
career slot ships the plugin-count stat) — and announce each (re)build with a
|
||||
DOM event, because their `innerHTML` swap wipes anything previously injected.
|
||||
A plugin listens for the event and **replaces the mount's content** (never
|
||||
append — a fallback may be present) by id — the same seam every time:
|
||||
|
||||
| Shell | Event | Mounts |
|
||||
| --- | --- | --- |
|
||||
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
|
||||
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
|
||||
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
|
||||
|
||||
Rules: inject on every event (the mount is fresh), keep the section
|
||||
**absent-not-empty** (no state → leave the mount alone / empty), and guard
|
||||
re-wired listeners with a `dataset` flag when your own refresh path can run
|
||||
against an unwiped mount.
|
||||
|
||||
+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', 'static/highway.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# CLOSED grandfather list — manifest keys core reads or writes that predate the
|
||||
# spec-conformance gate and that the feedpak spec does not define.
|
||||
#
|
||||
# Please don't add entries here — CI will flag any PR that grows this list, so
|
||||
# it can only shrink over time. That's by design, not distrust: the moment the
|
||||
# app touches a key the spec doesn't define, every teammate's PR starts failing
|
||||
# the conformance gate too, and whoever added the key is the only person who
|
||||
# can fix it. The FEP process below avoids putting anyone in that spot. The
|
||||
# feedpak spec's own governance is explicit:
|
||||
#
|
||||
# "This repository defines the format only. Applications that read or write
|
||||
# feedpak ... track this spec as a dependency; they do not drive it.
|
||||
# A change is not part of the format until it lands here."
|
||||
# — got-feedback/feedpak-spec, GOVERNANCE.md
|
||||
#
|
||||
# So a new manifest key goes through the feedpak Enhancement Proposal (FEP)
|
||||
# process — see feedpak-spec/CONTRIBUTING.md:
|
||||
#
|
||||
# 1. Open a FEP issue on got-feedback/feedpak-spec describing the problem, the
|
||||
# on-disk shape, backward compatibility, and the version bump implied.
|
||||
# 2. Land one PR there updating the normative spec, the JSON Schemas, an
|
||||
# example that exercises it, and the changelog — together.
|
||||
# 3. Back here, re-run this PR's checks. The gate verifies against the spec's
|
||||
# HEAD, so once your key is in the spec, the gate goes green.
|
||||
#
|
||||
# That's the supported route — and usually a quick one for additive keys. If
|
||||
# your PR is blocked by this gate, a FEP will get you unblocked properly; an
|
||||
# entry here won't (CI rejects it).
|
||||
#
|
||||
# Entries below exist ONLY because they predate the gate. Each is debt with a
|
||||
# tracking issue, and each disappears when its issue is fixed. The gate also
|
||||
# fails if an entry goes stale — the spec caught up, or core no longer reads or
|
||||
# writes the key — so this file cannot quietly become a place drift hides.
|
||||
|
||||
exceptions:
|
||||
- key: original_audio
|
||||
issue: https://github.com/got-feedback/feedback/issues/945
|
||||
reason: >-
|
||||
Added by #583 (the full mix played while every stem fader sits at unity,
|
||||
since demucs recombination is lossy). It never went through a FEP and the
|
||||
spec does not define it — the drift this gate exists to prevent.
|
||||
|
||||
#933 fixed the drift: feedpak 1.15.0 RESERVES the stem id `full` for the
|
||||
complete mixdown (feedpak-spec#53), and core now reads the full mix from
|
||||
that stem. Nothing depends on this key any more — not the loader, not
|
||||
lib/enrichment.py, not the stems plugin, and the packer no longer writes it.
|
||||
|
||||
What remains is a READ-ONLY deprecated fallback in lib/sloppak.py
|
||||
(_legacy_full_mix), kept for one release because every pack produced before
|
||||
the spec caught up carries `original_audio: original/full.ogg` and would
|
||||
otherwise silently lose its pristine mix. tools/migrate_full_mix_stem.py
|
||||
rewrites those packs into the spec shape.
|
||||
|
||||
This entry disappears with that fallback — tracked by #945, which cannot be
|
||||
forgotten: the gate fails if the entry goes stale, and deleting the read is
|
||||
what makes it stale.
|
||||
@@ -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,380 @@
|
||||
"""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, at most one at a time. server.py's startup hook.
|
||||
|
||||
━━━ THE GUARD ASKS "IS A HEALTHY JANITOR RUNNING?", AND NOTHING ELSE ━━━
|
||||
|
||||
Three ways to get this wrong, and #902 plus two Codex passes found all three:
|
||||
|
||||
1. NO GUARD (the original #902 bug). The re-entry check lived at the call site as
|
||||
`A or (B and C)`, so it never ran, and a second startup started a SECOND thread,
|
||||
overwrote the handle, and left the first to fire hooks forever, unjoinable.
|
||||
|
||||
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). stop_janitor() deliberately
|
||||
leaves that flag True when a hook outruns its join timeout — so once that hook
|
||||
finishes and the thread exits, the flag is stale and a later startup would refuse to
|
||||
start a replacement. Demo cleanup silently dead for the rest of the process.
|
||||
|
||||
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). A timed-out stop leaves the
|
||||
old thread ALIVE BUT DOOMED — its stop event is set, and it exits the moment its
|
||||
current hook returns. Treating it as a running janitor means the replacement is never
|
||||
started, and we are back at (2) a second later.
|
||||
|
||||
So a janitor counts as running only if its thread is alive AND it has not been told to
|
||||
stop.
|
||||
|
||||
━━━ AND WHY EACH JANITOR OWNS ITS STOP EVENT ━━━
|
||||
|
||||
This used to `_DEMO_JANITOR_STOP.clear()` a single shared Event. If a replacement were
|
||||
started while a doomed thread was still finishing a hook, clearing the shared event would
|
||||
RESURRECT it — it loops back to `stop.wait()`, sees the flag cleared, and carries on.
|
||||
Two janitors, which is the exact bug we started from.
|
||||
|
||||
A fresh Event per janitor makes that impossible: the old thread waits on its OWN event,
|
||||
which stays set forever, so it can only exit.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _DEMO_JANITOR_STOP
|
||||
|
||||
thread = _DEMO_JANITOR_THREAD
|
||||
if thread is not None and thread.is_alive() and not _DEMO_JANITOR_STOP.is_set():
|
||||
return # a healthy janitor is already running
|
||||
|
||||
# Either there is no janitor, or the previous one is dead / dying. Give the new one its
|
||||
# OWN stop event so the old one stays stopped no matter what we do to ours.
|
||||
stop = threading.Event()
|
||||
_DEMO_JANITOR_STOP = stop
|
||||
_DEMO_JANITOR_STARTED = True
|
||||
|
||||
def _janitor():
|
||||
# Closes over `stop`, NOT the module global — a later start_janitor() rebinds
|
||||
# _DEMO_JANITOR_STOP, and this thread must keep watching the event it was born with.
|
||||
while not 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
|
||||
+20
-5
@@ -368,10 +368,12 @@ def _acoustid_gate() -> "JSONResponse | None":
|
||||
|
||||
def _song_audio_file(filename: str) -> "str | None":
|
||||
"""Resolve a LIBRARY song (by filename/id) to a local master-audio file for
|
||||
fingerprinting: the full-mix `original_audio` extracted from a sloppak, or a
|
||||
loose folder's audio. None when the song can't be found or ships no full-mix
|
||||
audio (some packs carry only stems). Mirrors serve_sloppak_file's containment
|
||||
guards so a crafted filename can't read outside DLC_DIR / the pack."""
|
||||
fingerprinting: a sloppak's complete mixdown, or a loose folder's audio. None
|
||||
when the song can't be found or carries no mixdown (a pack that kept only its
|
||||
separated stems — an acoustic fingerprint of one re-summed from them would not
|
||||
match the recording, so we decline rather than submit a lossy reconstruction).
|
||||
Mirrors serve_sloppak_file's containment guards so a crafted filename can't
|
||||
read outside DLC_DIR / the pack."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return None
|
||||
@@ -383,7 +385,20 @@ def _song_audio_file(filename: str) -> "str | None":
|
||||
canon = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
rel = (sloppak_mod.load_manifest(resolved) or {}).get("original_audio")
|
||||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||||
# The mixdown is the RESERVED `full` stem (spec §5.3). Unlike playback,
|
||||
# fingerprinting wants it even when it is the pack's ONLY stem — a
|
||||
# single-mix pack is exactly the master audio we want to fingerprint —
|
||||
# so this asks find_full_mix() rather than partition_stems().
|
||||
stems = manifest.get("stems") or []
|
||||
full = sloppak_mod.find_full_mix(
|
||||
[s for s in stems if isinstance(s, dict)]
|
||||
)
|
||||
rel = full.get("file") if full else None
|
||||
# DEPRECATED fallback: packs written before the spec reserved `full` put
|
||||
# the mixdown behind a top-level `original_audio:` key instead (#933).
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
rel = manifest.get("original_audio")
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
return None
|
||||
src = sloppak_mod.get_cached_source_dir(canon)
|
||||
|
||||
+94
-19
@@ -614,6 +614,14 @@ class MetadataDB:
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
|
||||
# Cumulative wall-clock play time (career "hours in genre" odometer).
|
||||
# Fed by the same POST /api/stats the recorder already sends; additive
|
||||
# + idempotent like every other song_stats change.
|
||||
try:
|
||||
self.conn.execute(
|
||||
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Playlists + the reserved "Saved for Later" system playlist. Additive.
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
@@ -901,6 +909,9 @@ class MetadataDB:
|
||||
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
|
||||
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
|
||||
"last_position": newer["last_position"],
|
||||
# Play time is additive: both encodings' hours belong to
|
||||
# the one canonical song.
|
||||
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
|
||||
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
|
||||
}
|
||||
# Atomic swap: clear and reinsert the canonicalized set in one txn.
|
||||
@@ -1074,26 +1085,57 @@ class MetadataDB:
|
||||
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
|
||||
return vals
|
||||
|
||||
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
|
||||
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
|
||||
# so a corrected genre is browsable — the correlated subquery is used ONLY
|
||||
# when genre overrides actually exist; the common case stays on the plain
|
||||
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
|
||||
# write-to-file field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
# Effective genre precedence: per-song OVERRIDE (Fix-metadata popup) →
|
||||
# scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows
|
||||
# only — a 'review'/'failed' candidate's genres could belong to the wrong
|
||||
# recording). Applied at FILTER/FACET time (like the P4 artist alias) so a
|
||||
# corrected or enriched genre is browsable. The vast majority of converted
|
||||
# packs carry no `genres` manifest key, so without the enrichment leg the
|
||||
# genre facet (and career passports) starve on real libraries. The
|
||||
# correlated subqueries are used ONLY when overrides/enrichment genres
|
||||
# actually exist; the common case stays on the plain indexed `genre`
|
||||
# column. Genre stays a library-only overlay (it isn't a write-to-file
|
||||
# field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_OVERRIDE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), genre)"
|
||||
)
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), "
|
||||
"NULLIF(genre, ''), "
|
||||
"(SELECT json_extract(e.genres, '$[0]') FROM song_enrichment e "
|
||||
"WHERE e.filename = songs.filename AND e.match_state IN ('matched', 'manual') "
|
||||
"AND e.genres IS NOT NULL AND e.genres NOT IN ('', '[]')), "
|
||||
"'')"
|
||||
)
|
||||
|
||||
def _has_genre_overrides(self) -> bool:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
|
||||
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
|
||||
|
||||
def _has_enrichment_genres(self) -> bool:
|
||||
try:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_enrichment WHERE match_state IN ('matched', 'manual') "
|
||||
"AND genres IS NOT NULL AND genres NOT IN ('', '[]') "
|
||||
"LIMIT 1").fetchone() is not None
|
||||
except sqlite3.OperationalError:
|
||||
return False # stand-ins / DBs without the enrichment table
|
||||
|
||||
def _effective_genre_expr(self) -> str:
|
||||
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
|
||||
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
|
||||
"""`genre` normally; the enrichment-aware COALESCE only when trusted
|
||||
enrichment genres exist (which also proves the table exists — a
|
||||
stand-in DB without song_enrichment must never receive SQL that
|
||||
references it); the override-only form when just overrides exist."""
|
||||
if self._has_enrichment_genres():
|
||||
return self._EFFECTIVE_GENRE_SQL
|
||||
if self._has_genre_overrides():
|
||||
return self._EFFECTIVE_GENRE_OVERRIDE_SQL
|
||||
return "genre"
|
||||
|
||||
def set_song_tags(self, filename: str, tags) -> list:
|
||||
"""Replace ALL of a song's tags with the given set (each normalized;
|
||||
@@ -1693,7 +1735,8 @@ class MetadataDB:
|
||||
# ── Per-song practice stats ───────────────────────────────────────────---
|
||||
_STATS_COLS = (
|
||||
"filename", "arrangement", "plays", "best_score", "best_accuracy",
|
||||
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
|
||||
"last_score", "last_accuracy", "last_position", "seconds_total",
|
||||
"last_played_at", "updated_at",
|
||||
)
|
||||
|
||||
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
|
||||
@@ -2060,8 +2103,9 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
|
||||
def record_session(self, filename: str, arrangement: int, *, score: int,
|
||||
accuracy: float, last_position=None) -> dict:
|
||||
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
|
||||
accuracy: float, last_position=None, seconds: float = 0) -> dict:
|
||||
"""Record a scored play: plays += 1, best_* = max, last_* = new.
|
||||
`seconds` (wall-clock play time from the recorder) accrues."""
|
||||
from song_score import merge_stats
|
||||
with self._lock:
|
||||
existing = self._stats_row(filename, int(arrangement))
|
||||
@@ -2071,8 +2115,9 @@ class MetadataDB:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats
|
||||
(filename, arrangement, plays, best_score, best_accuracy,
|
||||
last_score, last_accuracy, last_position, last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
||||
last_score, last_accuracy, last_position, seconds_total,
|
||||
last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
plays = excluded.plays,
|
||||
@@ -2081,32 +2126,62 @@ class MetadataDB:
|
||||
last_score = excluded.last_score,
|
||||
last_accuracy = excluded.last_accuracy,
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_at,
|
||||
updated_at = excluded.updated_at""",
|
||||
(filename, int(arrangement), merged["plays"], merged["best_score"],
|
||||
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
|
||||
merged["last_position"]),
|
||||
merged["last_position"], float(seconds or 0)),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self._stats_row(filename, int(arrangement))
|
||||
|
||||
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
|
||||
def touch_position(self, filename: str, arrangement: int, last_position: float,
|
||||
seconds: float = 0) -> dict:
|
||||
"""Persist just the resume position (no plays/score change), so
|
||||
Continue-Playing works for non-scored plays. Also stamps
|
||||
last_played_at — both /api/stats/recent and /api/session/continue
|
||||
filter/order on it, so a position-only touch must set it or the song
|
||||
never surfaces as 'recent' / 'continue playing'."""
|
||||
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
|
||||
wall-clock play time (career hours odometer)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats (filename, arrangement, last_position,
|
||||
seconds_total, last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_at,
|
||||
updated_at = excluded.updated_at""",
|
||||
(filename, int(arrangement), float(last_position), float(seconds or 0)),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self._stats_row(filename, int(arrangement))
|
||||
|
||||
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
|
||||
"""Accrue wall-clock play time (no plays/score/position change) —
|
||||
the recorder's seconds-only flush for unscored plays that ran to the
|
||||
song's natural end (no resume position to touch there: `song:ended`
|
||||
must not overwrite Continue with the end-of-song offset). Stamps
|
||||
last_played_at like touch_position does: the song WAS played, so
|
||||
/api/stats/recent and Continue ordering must see it. Accepted skew:
|
||||
the recorder retries FAILED flushes later, which stamps recency at
|
||||
retry time — rare (offline corner), self-healing on the next play,
|
||||
and preferable to the alternative (keep-existing would leave repeat
|
||||
plays looking stale, the common case)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats (filename, arrangement, seconds_total,
|
||||
last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_at,
|
||||
updated_at = excluded.updated_at""",
|
||||
(filename, int(arrangement), float(last_position)),
|
||||
(filename, int(arrangement), float(seconds)),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self._stats_row(filename, int(arrangement))
|
||||
|
||||
+34
-2
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
|
||||
last_pos = data.get("lastPlayPosition", data.get("last_position"))
|
||||
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
# Optional wall-clock play time (career hours odometer). Bounded per POST:
|
||||
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
|
||||
# clock artifact (suspend/sleep), not practice.
|
||||
seconds = data.get("seconds")
|
||||
if seconds is not None:
|
||||
if isinstance(seconds, bool):
|
||||
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
|
||||
try:
|
||||
seconds = float(seconds)
|
||||
if not math.isfinite(seconds):
|
||||
raise ValueError("non-finite")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
|
||||
if not (0 < seconds <= 6 * 3600):
|
||||
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
|
||||
seconds = seconds or 0.0
|
||||
|
||||
# A scored session needs BOTH score and accuracy. Exactly one provided is
|
||||
# ambiguous — don't silently fall through to the position-only branch.
|
||||
@@ -115,7 +131,8 @@ def api_record_stats(data: dict):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
row = appstate.meta_db.record_session(filename, arrangement, score=score,
|
||||
accuracy=accuracy, last_position=last_pos)
|
||||
accuracy=accuracy, last_position=last_pos,
|
||||
seconds=seconds)
|
||||
# Unified XP + streak side-effects — never let these drop the stat write.
|
||||
progress = None
|
||||
try:
|
||||
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
|
||||
log.warning("stats side-effects (progression) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress, "progression": progression_summary}
|
||||
|
||||
# Seconds-only accrual: an unscored play that ran to the song's natural
|
||||
# end has play time to bank but no resume position to touch (song:ended
|
||||
# must not overwrite Continue with the end-of-song offset). Still counts
|
||||
# as playing today for the streak below.
|
||||
if last_pos is None and seconds:
|
||||
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
|
||||
progress = None
|
||||
try:
|
||||
from datetime import date
|
||||
appstate.meta_db.record_active_day(date.today().isoformat())
|
||||
progress = appstate.meta_db.get_progress()
|
||||
except Exception:
|
||||
log.warning("stats side-effects (streak) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress}
|
||||
|
||||
# Position-only touch.
|
||||
if last_pos is None:
|
||||
return JSONResponse(
|
||||
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
|
||||
pos = float(last_pos)
|
||||
if not math.isfinite(pos):
|
||||
raise ValueError("non-finite")
|
||||
row = appstate.meta_db.touch_position(filename, arrangement, pos)
|
||||
row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
# A resume session still counts as playing today: advance the streak (no XP —
|
||||
|
||||
+43
-19
@@ -321,11 +321,16 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
audio_url = None
|
||||
audio_error: str | None = None # Surfaced in song_info when audio_url is None
|
||||
stems_payload: list[dict] = []
|
||||
# URL of the single full-mix audio (sloppak `original_audio:`), when the
|
||||
# pack ships one. The stems plugin uses this to play the untouched mix
|
||||
# while every stem slider is at unity; None otherwise (separate stems
|
||||
# only, loose folder, or archive).
|
||||
original_audio_url: str | None = None
|
||||
# URL of the pack's complete mixdown — the RESERVED `full` stem (spec
|
||||
# §5.3), which sloppak.load_song() lifts out of `stems` because it is a
|
||||
# mixdown, not a layer. The stems plugin plays it while every stem slider
|
||||
# is at unity (separation is lossy, so it beats re-summing the stems) and
|
||||
# crosses to the separated stems as soon as one is attenuated.
|
||||
#
|
||||
# None when the pack has no mixdown to offer separately from its stems:
|
||||
# a single-mix pack (its one stem IS the mixdown), a loose folder, or an
|
||||
# archive.
|
||||
full_mix_url: str | None = None
|
||||
if is_loose:
|
||||
# Loose folder filenames are relative paths (artist/album/song).
|
||||
# Hash the *canonical* dlc-relative path (so two URL spellings
|
||||
@@ -365,21 +370,25 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
|
||||
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
|
||||
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
|
||||
if loaded_slop is not None and loaded_slop.original_audio:
|
||||
original_audio_url = (
|
||||
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}"
|
||||
if loaded_slop is not None and loaded_slop.full_mix:
|
||||
full_mix_url = (
|
||||
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.full_mix)}"
|
||||
)
|
||||
if stems_payload:
|
||||
# Stems present: keep the core <audio> pointed at stem[0]. This
|
||||
# URL is only ever heard in the degraded path (stems plugin
|
||||
# refuses takeover / decode fails); the full-mix↔stems switch is
|
||||
# driven client-side by `original_audio_url`, not `audio_url`.
|
||||
# driven client-side by `full_mix_url`, not `audio_url`.
|
||||
audio_url = stems_payload[0]["url"]
|
||||
elif original_audio_url:
|
||||
elif full_mix_url:
|
||||
# Stem-less full-mix pack: nothing to separate, so play the full
|
||||
# mix natively through the core <audio>. The stems plugin's
|
||||
# onSongReady returns early on an empty stems list (no graph).
|
||||
audio_url = original_audio_url
|
||||
# Reachable only via the deprecated `original_audio:` key, whose
|
||||
# packs put the mixdown outside `stems` — a pack that carries its
|
||||
# mixdown as the `full` stem has it IN `stems`, so it lands in the
|
||||
# branch above with stems_payload == [full].
|
||||
audio_url = full_mix_url
|
||||
else:
|
||||
audio_error = "This sloppak has no playable stems."
|
||||
else:
|
||||
@@ -521,16 +530,31 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
# for the credits overlay, so minigames / synthetic highway uses
|
||||
# (no manifest) never trigger it.
|
||||
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
|
||||
# Instrument stems ONLY. The pack's complete mixdown (the RESERVED
|
||||
# `full` stem, spec §5.3) is deliberately NOT in this list: consumers
|
||||
# sum `stems` into one mix and render one fader per entry, and the
|
||||
# mixdown is neither a layer nor an instrument — summing it would
|
||||
# double the whole song. It is surfaced separately, below.
|
||||
"stems": stems_payload,
|
||||
# Full-mix audio (sloppak `original_audio:`) served alongside the
|
||||
# separate `stems`. The stems plugin plays this single file while
|
||||
# every stem slider is at unity and switches to the separate stems
|
||||
# the moment one drops below 100%. None when the pack ships stems
|
||||
# only. `has_*` flags mirror the has_drum_tab/has_keys convention so
|
||||
# a client can branch without re-deriving from the URLs.
|
||||
"original_audio_url": original_audio_url,
|
||||
"has_original_audio": bool(original_audio_url),
|
||||
# The complete mixdown, served by the same /api/sloppak/.../file/
|
||||
# endpoint as the stems. The stems plugin plays this single file
|
||||
# while every stem slider is at unity and crosses to the separated
|
||||
# stems the moment one drops below 100% — separation is lossy, so the
|
||||
# mixdown is strictly better audio when nothing is muted. None when
|
||||
# the pack has no mixdown apart from its stems. The `has_*` flags
|
||||
# mirror the has_drum_tab/has_keys convention so a client can branch
|
||||
# without re-deriving from the URLs.
|
||||
"full_mix_url": full_mix_url,
|
||||
"has_full_mix": bool(full_mix_url),
|
||||
"has_stems": bool(stems_payload),
|
||||
# DEPRECATED aliases of the two keys above, kept so a client built
|
||||
# against the old frame keeps working across one release. They were
|
||||
# named after `original_audio:` — a manifest key this repo invented
|
||||
# and the feedpak spec never had (#933). The key is gone; the mixdown
|
||||
# is a stem. Remove these once the shipped stems plugin reads
|
||||
# `full_mix_url` (#945).
|
||||
"original_audio_url": full_mix_url,
|
||||
"has_original_audio": bool(full_mix_url),
|
||||
# Surface a drum_tab presence flag so the visualization picker
|
||||
# can auto-activate the drums plugin even when the chosen
|
||||
# arrangement isn't named "Drums" (drum_tab.json lives next
|
||||
|
||||
+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
|
||||
+409
-61
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
@@ -34,6 +35,21 @@ FEEDPAK_EXT = ".feedpak"
|
||||
SLOPPAK_EXT = ".sloppak"
|
||||
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
|
||||
|
||||
# ── The full mix ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Spec §5.3 RESERVES the stem id `full` for the song's complete mixdown: the
|
||||
# whole song in one file, as heard before source separation. It is a stem — it
|
||||
# lives in `stems` like every other audio file in a pack — but it is a *mixdown,
|
||||
# not a layer*. A reader that sums stems must never include it in the sum: it
|
||||
# already contains every instrument, so summing it doubles the whole song and
|
||||
# muting `guitar` still leaves guitar audible inside it.
|
||||
#
|
||||
# Keeping it matters because separation is lossy: re-summing guitar+bass+drums+
|
||||
# vocals does NOT reproduce the file they came from. The mixdown is the only
|
||||
# faithful rendering of the song a pack can carry, so we play it whenever every
|
||||
# stem sits at unity and nothing is muted.
|
||||
FULL_MIX_STEM_ID = "full"
|
||||
|
||||
import yaml
|
||||
|
||||
from jsonc import load_json
|
||||
@@ -51,6 +67,97 @@ import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
|
||||
|
||||
def find_full_mix(stems: list[dict]) -> dict | None:
|
||||
"""The RESERVED `full` stem (spec §5.3) — the pack's complete mixdown — or None.
|
||||
|
||||
Answers "what is this pack's master audio", which is what fingerprinting
|
||||
wants. For playback use partition_stems() instead: a pack whose *only* stem
|
||||
is `full` has no mixdown to play *separately from* its stems, and this
|
||||
function still returns it.
|
||||
"""
|
||||
return next(
|
||||
(s for s in stems if str(s.get("id", "")) == FULL_MIX_STEM_ID), None
|
||||
)
|
||||
|
||||
|
||||
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
||||
|
||||
The mixdown is lifted OUT of the stem list because every consumer of `stems`
|
||||
treats that list as layers to sum or to show as mixer channels, and `full` is
|
||||
neither (spec §5.3). Leaving it in is precisely the bug that made the packer
|
||||
invent `original_audio` in the first place: a listed full mix plays on top of
|
||||
the stems.
|
||||
|
||||
A pack whose only stem is `full` is a single-mix pack, not a separated one:
|
||||
there are no instruments to be pristine *against*, so `full` stays the sole
|
||||
playable stem and no mixdown is surfaced. That keeps the freshly-converted
|
||||
single-stem pack — much the most common shape — behaving exactly as before.
|
||||
|
||||
EVERY entry with the reserved id is removed, not just the one we surface. A
|
||||
malformed pack that lists `full` twice would otherwise leave a copy of the
|
||||
whole song behind in the stem list, to be summed with the instruments — the
|
||||
precise failure this function exists to prevent, reintroduced by a duplicate.
|
||||
"""
|
||||
if len(stems) < 2:
|
||||
return None, stems
|
||||
full = find_full_mix(stems)
|
||||
if full is None:
|
||||
return None, stems
|
||||
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
|
||||
|
||||
|
||||
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
|
||||
|
||||
Before feedpak 1.15.0 reserved `full`, §5.3 said the mixdown was "commonly
|
||||
replaced" by the per-instrument stems on splitting — so it had nowhere to
|
||||
live, and this repo invented a top-level key pointing at a parallel
|
||||
`original/` directory (#583) to hold it. That key was never in the spec, and
|
||||
#933 removed our dependence on it: the mixdown is a stem.
|
||||
|
||||
We still READ it, because every pack written before the spec caught up
|
||||
carries `original_audio: original/full.ogg` and would otherwise lose its full
|
||||
mix. We never write it. Delete this once those packs are migrated (#945);
|
||||
`tools/migrate_full_mix_stem.py` is the migration.
|
||||
|
||||
NOTE the string literal below. tools/check_spec_conformance.py AST-scans for
|
||||
`manifest.get("<literal>")` to prove every manifest key core reads is one the
|
||||
spec declares. Hoisting "original_audio" into a named constant would hide
|
||||
this read from that scan — the gate would conclude core no longer touches the
|
||||
key, and the grandfather entry that documents this debt would go stale. The
|
||||
literal is what keeps the deprecation honest and visible to CI. Leave it.
|
||||
|
||||
Same permissive, path-traversal-guarded posture as the optional side-files: a
|
||||
missing / escaping / unreadable file leaves the pack without a full mix (the
|
||||
player falls back to the separated stems) rather than aborting the load.
|
||||
Returns the manifest-relative string, so callers build its URL exactly as
|
||||
they build a stem's.
|
||||
"""
|
||||
rel_raw = manifest.get("original_audio")
|
||||
if not isinstance(rel_raw, str) or not rel_raw.strip():
|
||||
return None
|
||||
rel = rel_raw.strip()
|
||||
try:
|
||||
target = (source_dir / rel).resolve()
|
||||
target.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not target.is_file():
|
||||
return None
|
||||
log.info(
|
||||
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
|
||||
"is a stem (id `full`, feedpak spec §5.3). Re-pack with "
|
||||
"tools/migrate_full_mix_stem.py; support for this key will be removed.",
|
||||
rel,
|
||||
)
|
||||
return rel
|
||||
|
||||
|
||||
# ── Format detection ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_sloppak(path: Path) -> bool:
|
||||
@@ -81,6 +188,116 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
|
||||
_unpack_locks: dict[str, threading.Lock] = {}
|
||||
_unpack_locks_guard = threading.Lock()
|
||||
|
||||
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
|
||||
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
|
||||
# half-written directory and leave that resolver caching an incomplete song.
|
||||
_unpacking: set[Path] = set()
|
||||
_unpacking_guard = threading.Lock()
|
||||
|
||||
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
|
||||
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
|
||||
# every song it holds, and it used to grow without any bound at all. A tester
|
||||
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
|
||||
# one caller looped the library calling load_song(). Nothing ever deleted any of
|
||||
# it — not even when the song itself was deleted.
|
||||
#
|
||||
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
|
||||
# song I'm playing, and the last few I played" that this cache actually exists
|
||||
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
|
||||
def _unpack_cache_cap_bytes() -> int:
|
||||
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
|
||||
try:
|
||||
mb = int(raw) if raw else 4096
|
||||
except ValueError:
|
||||
mb = 4096
|
||||
return max(0, mb) * 1024 * 1024
|
||||
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
total = 0
|
||||
for f in path.rglob("*"):
|
||||
try:
|
||||
if f.is_file():
|
||||
total += f.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _touch(path: Path) -> None:
|
||||
"""Bump mtime so the LRU sweep below treats this song as recently used.
|
||||
|
||||
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
|
||||
so without this the song you are actively playing looks as stale as one you
|
||||
unpacked days ago — and a burst of unpacks could evict it mid-song.
|
||||
"""
|
||||
try:
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
|
||||
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
|
||||
|
||||
`keep` is never evicted — it's the song the caller just resolved, i.e. almost
|
||||
certainly the one about to be played.
|
||||
|
||||
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
|
||||
get_cached_source_dir() keeps handing out a path that no longer exists and
|
||||
the media route 404s on every stem instead of re-unpacking (it only falls
|
||||
back to resolve_source_dir when the cache returns None).
|
||||
"""
|
||||
cap = _unpack_cache_cap_bytes()
|
||||
if cap <= 0:
|
||||
return
|
||||
try:
|
||||
entries = []
|
||||
total = 0
|
||||
for d in root.iterdir():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
try:
|
||||
size = _dir_size(d)
|
||||
mtime = d.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
entries.append((mtime, size, d))
|
||||
total += size
|
||||
if total <= cap:
|
||||
return
|
||||
|
||||
keep_resolved = keep.resolve() if keep else None
|
||||
entries.sort(key=lambda e: e[0]) # oldest first
|
||||
for _mtime, size, d in entries:
|
||||
if total <= cap:
|
||||
break
|
||||
try:
|
||||
if keep_resolved and d.resolve() == keep_resolved:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Check-and-delete under ONE hold of the guard. Releasing between the
|
||||
# two would let a resolver mark this dest in-flight and start writing
|
||||
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
|
||||
# that blocks here simply proceeds afterwards — _unpack_zip recreates
|
||||
# the directory anyway.
|
||||
with _unpacking_guard:
|
||||
if d in _unpacking:
|
||||
continue # another thread is writing this
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
if d.exists():
|
||||
continue # couldn't remove — don't claim the bytes back
|
||||
total -= size
|
||||
with _source_lock:
|
||||
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
|
||||
if cached_dir == d:
|
||||
_source_cache.pop(fn, None)
|
||||
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
|
||||
d.name, size / 1e6)
|
||||
except OSError:
|
||||
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
|
||||
|
||||
|
||||
def _unpack_lock_for(filename: str) -> threading.Lock:
|
||||
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
||||
@@ -145,10 +362,17 @@ def resolve_source_dir(
|
||||
re-unpacks if mtime/size changed, then returns that dir.
|
||||
|
||||
Caches the resolution so subsequent calls are ~free.
|
||||
|
||||
NOTE: this writes the WHOLE pack — every stem — to disk. Only call it for a
|
||||
song you are about to play. To read a *part* of a song (an arrangement, the
|
||||
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
|
||||
KB of JSON is ~45x write amplification, and doing it in a loop over the
|
||||
library fills the disk with a decompressed copy of every song.
|
||||
"""
|
||||
path = dlc_root / filename
|
||||
stat = path.stat()
|
||||
mtime, size = stat.st_mtime, stat.st_size
|
||||
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
|
||||
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
@@ -159,42 +383,76 @@ def resolve_source_dir(
|
||||
and cached_size == size
|
||||
and cached_dir.exists()
|
||||
):
|
||||
# Mark it recently-used before returning — see _touch().
|
||||
if cached_dir != path:
|
||||
_touch(cached_dir)
|
||||
return cached_dir
|
||||
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
try:
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
# Shield `dest` from eviction from the moment we start writing
|
||||
# until it is safely in _source_cache. `keep` only shields it
|
||||
# from OUR OWN sweep — a concurrent resolver sweeping with a
|
||||
# different `keep` would delete it, and we would then cache and
|
||||
# return a path that no longer exists. The `finally` below
|
||||
# releases it on EVERY exit, including a failed unpack: leaving
|
||||
# a dest marked in-flight would make it un-evictable forever.
|
||||
with _unpacking_guard:
|
||||
_unpacking.add(dest)
|
||||
guarded = dest
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
# The only moment this cache grows. Sweep here rather than on a
|
||||
# timer so it can never drift far past the cap.
|
||||
_evict_unpack_cache(unpack_cache_root, keep=dest)
|
||||
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
finally:
|
||||
if guarded is not None:
|
||||
with _unpacking_guard:
|
||||
_unpacking.discard(guarded)
|
||||
|
||||
|
||||
def get_cached_source_dir(filename: str) -> Path | None:
|
||||
"""Return the cached source dir for a sloppak if one is known."""
|
||||
"""Return the cached source dir for a sloppak if one is known AND still there.
|
||||
|
||||
The existence check is load-bearing: callers (media.py) only fall back to
|
||||
resolve_source_dir() when this returns None, so handing back a path that has
|
||||
been evicted — or that the user deleted by hand to reclaim disk — would 404
|
||||
every stem for the rest of the process instead of re-unpacking.
|
||||
"""
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
return cached[0] if cached else None
|
||||
if not cached:
|
||||
return None
|
||||
src = cached[0]
|
||||
if not src.is_dir():
|
||||
_source_cache.pop(filename, None)
|
||||
return None
|
||||
_touch(src)
|
||||
return src
|
||||
|
||||
|
||||
# ── Manifest + song loading ───────────────────────────────────────────────────
|
||||
@@ -233,6 +491,82 @@ def load_manifest(path: Path) -> dict:
|
||||
return _read_manifest_from_zip(path)
|
||||
|
||||
|
||||
_ZIP_ROOT = Path("/_root").resolve()
|
||||
|
||||
|
||||
def _zip_member_key(name: str) -> str | None:
|
||||
"""Canonical lookup key for a zip member name, or None if it escapes the root.
|
||||
|
||||
Collapses './', 'a/../b' and backslash separators — the same normalization
|
||||
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
|
||||
for AND the names the archive actually stores must go through this, or a pack
|
||||
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
|
||||
"""
|
||||
safe = safe_join(_ZIP_ROOT, name or "")
|
||||
# None → escapes the root; == root → a degenerate name like "." or "a/..".
|
||||
if safe is None or safe == _ZIP_ROOT:
|
||||
return None
|
||||
return safe.relative_to(_ZIP_ROOT).as_posix()
|
||||
|
||||
|
||||
def read_member_bytes(path: Path, rel: str) -> bytes | None:
|
||||
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
|
||||
|
||||
For a zipped sloppak this opens that single member instead of unpacking the
|
||||
archive — the same trick read_cover_bytes() uses to keep the library grid
|
||||
from exploding every pack just to show a cover.
|
||||
|
||||
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
|
||||
the lyrics, a tone blob) rather than a song you're about to play. The
|
||||
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
|
||||
pack — every stem — into the unpack cache. That is a ~45x write amplification
|
||||
when all you wanted was a few KB of JSON, and looping the library on it
|
||||
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
|
||||
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
|
||||
zip: the cache becomes a second, decompressed copy of everything it touches.
|
||||
"""
|
||||
rel = (rel or "").strip()
|
||||
if not rel:
|
||||
return None
|
||||
|
||||
if path.is_dir():
|
||||
target = safe_join(path.resolve(), rel)
|
||||
if target is None or not target.is_file():
|
||||
return None
|
||||
try:
|
||||
return target.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
|
||||
# open anything, and both sides of the comparison are normalized, so a
|
||||
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
|
||||
# way it did when we unpacked first.
|
||||
member = _zip_member_key(rel)
|
||||
if member is None:
|
||||
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(str(path), "r") as zf:
|
||||
# Match on the NORMALIZED stored name, and take the LAST match — the
|
||||
# archive may store './x' or a backslash path (Windows tooling), and
|
||||
# if it stores two names that normalize to the same file, _unpack_zip
|
||||
# writes them in order so the last one wins. Reading the raw member by
|
||||
# exact name would miss the first case and return the wrong bytes in
|
||||
# the second. A pack has a handful of members; the scan is free.
|
||||
info = None
|
||||
for cand in zf.infolist():
|
||||
if _zip_member_key(cand.filename) == member:
|
||||
info = cand
|
||||
if info is None or info.is_dir():
|
||||
return None
|
||||
with zf.open(info) as f:
|
||||
return f.read()
|
||||
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
||||
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
|
||||
return None
|
||||
|
||||
|
||||
_COVER_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
@@ -367,14 +701,21 @@ class LoadedSloppak:
|
||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||
# absent so indexing by song.arrangements index is safe.
|
||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||
# Manifest-relative path to the single full-mix audio file, taken from the
|
||||
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
|
||||
# pre-separation mixdown that exists alongside the per-instrument `stems`.
|
||||
# None when the key is absent, points outside source_dir, or the file is
|
||||
# missing on disk. Served to the front-end via the highway WS as
|
||||
# `original_audio_url`; the stems plugin uses it to play the untouched mix
|
||||
# when every stem slider is at unity (and the separate stems otherwise).
|
||||
original_audio: str | None = None
|
||||
# Manifest-relative path to the pack's complete mixdown — the whole song in
|
||||
# one file, as heard before source separation. This is the RESERVED `full`
|
||||
# stem (spec §5.3), lifted out of `stems` above precisely because it is NOT
|
||||
# an instrument layer: summing it with the per-instrument stems it was split
|
||||
# into would double the entire song. See partition_stems().
|
||||
#
|
||||
# None when the pack has no mixdown to offer *separately* from its stems —
|
||||
# which includes the common single-mix pack, whose only stem IS the mixdown
|
||||
# (there is nothing to be pristine against, so it stays in `stems`).
|
||||
#
|
||||
# Served to the front-end via the highway WS as `full_mix_url`; the stems
|
||||
# plugin plays it while every stem slider sits at unity and crosses to the
|
||||
# separated stems the moment one drops below 100% — demucs recombination is
|
||||
# lossy, so the mixdown is strictly the better audio when nothing is muted.
|
||||
full_mix: str | None = None
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -766,6 +1107,13 @@ def load_song(
|
||||
default_on = bool(default_val)
|
||||
stems.append({"id": sid, "file": sfile, "default": default_on})
|
||||
|
||||
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
|
||||
# it out so that no consumer of `stems` — the mixer, the library's stem
|
||||
# chips, the WS payload — sums it with, or lists it beside, the instruments
|
||||
# it was separated into. `full_mix_stem` is None for a single-mix pack,
|
||||
# whose only stem IS the mixdown and stays in the list.
|
||||
full_mix_stem, stems = partition_stems(stems)
|
||||
|
||||
# Optional keys.json — song-level, instrument-independent key/scale track
|
||||
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
|
||||
# missing / unreadable / malformed -> None, never fatal. Stored as a
|
||||
@@ -828,28 +1176,22 @@ def load_song(
|
||||
}
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# Optional full-mix audio — manifest `original_audio:` key. The single
|
||||
# pre-separation mixdown that ships alongside the per-instrument stems.
|
||||
# Same permissive, path-traversal-guarded posture as drum_tab above: a
|
||||
# missing/escaping/absent file simply leaves the full mix unavailable (the
|
||||
# player falls back to the separate stems) rather than aborting the load.
|
||||
# We store the manifest-relative string so server.py can build its URL the
|
||||
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
|
||||
original_audio_data: str | None = None
|
||||
original_audio_rel = manifest.get("original_audio")
|
||||
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
|
||||
rel = original_audio_rel.strip()
|
||||
try:
|
||||
oa_path = (source_dir / rel).resolve()
|
||||
oa_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
oa_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
oa_path = None
|
||||
if oa_path is not None and oa_path.is_file():
|
||||
original_audio_data = rel
|
||||
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
||||
# above (spec §5.3) — no path work needed, it was validated with the other
|
||||
# stems and its URL is built the same way. Only when the pack has no `full`
|
||||
# stem do we fall back to the DEPRECATED `original_audio:` key, which is the
|
||||
# shape every pack written before feedpak 1.15.0 uses.
|
||||
if full_mix_stem is not None:
|
||||
full_mix_data: str | None = full_mix_stem["file"]
|
||||
elif find_full_mix(stems) is not None:
|
||||
# Single-mix pack: its ONE stem is the mixdown, so there is no mixdown to
|
||||
# offer *apart from* the stems. Never fall through to the legacy key here
|
||||
# — a pack that both carries a `full` stem and names the old key would
|
||||
# otherwise surface the mixdown twice (once as the stem the player is
|
||||
# already playing, once as a "pristine" track to cross to).
|
||||
full_mix_data = None
|
||||
else:
|
||||
full_mix_data = _legacy_full_mix(manifest, source_dir)
|
||||
|
||||
return LoadedSloppak(
|
||||
song=song,
|
||||
@@ -864,7 +1206,7 @@ def load_song(
|
||||
keys=keys_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
original_audio=original_audio_data,
|
||||
full_mix=full_mix_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -909,7 +1251,7 @@ def extract_meta(path: Path) -> dict:
|
||||
tuning_offsets = _tuning_for_meta(arr_list)
|
||||
|
||||
stems_list = manifest.get("stems", []) or []
|
||||
stem_ids: list[str] = []
|
||||
valid_stems: list[dict] = []
|
||||
for s in stems_list:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
@@ -923,7 +1265,13 @@ def extract_meta(path: Path) -> dict:
|
||||
isinstance(sid, str) and sid
|
||||
and isinstance(sfile, str) and sfile
|
||||
):
|
||||
stem_ids.append(sid)
|
||||
valid_stems.append({"id": sid, "file": sfile})
|
||||
# Partition exactly as load_song() does, for the same reason the library
|
||||
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
|
||||
# A separated pack that retains it would otherwise offer the user a "full"
|
||||
# stem chip alongside guitar/bass/drums and count it as a seventh stem.
|
||||
_full, instrument_stems = partition_stems(valid_stems)
|
||||
stem_ids = [s["id"] for s in instrument_stems]
|
||||
stem_count = len(stem_ids)
|
||||
|
||||
return {
|
||||
|
||||
+89
-3
@@ -1,4 +1,4 @@
|
||||
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
|
||||
"""Regenerate the runtime stylesheet over the full installed-plugin set.
|
||||
|
||||
Core's committed (and image-baked) stylesheet is built scanning only the
|
||||
in-tree plugins. A plugin installed at runtime — into ``FEEDBACK_PLUGINS_DIR``
|
||||
@@ -15,6 +15,7 @@ on a missing optional engine.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -40,12 +41,84 @@ _lock = threading.Lock()
|
||||
# in-flight build re-runs once more to pick up the newer plugin set instead of
|
||||
# every concurrent trigger stacking its own redundant build.
|
||||
_rerun = threading.Event()
|
||||
_fingerprint_cache: dict = {}
|
||||
|
||||
# lib/ lives at ``<app>/lib``; the app root (static/, tailwind.config.js) is its
|
||||
# grandparent.
|
||||
APP_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _committed_css_fingerprint() -> str:
|
||||
"""Content hash of the SHIPPED stylesheet, cached on (mtime, size).
|
||||
|
||||
This is the marker that says WHICH CORE the runtime sheet was built against. Any change to
|
||||
core's CSS regenerates static/tailwind.min.css, which changes this hash.
|
||||
"""
|
||||
committed = APP_DIR / "static" / "tailwind.min.css"
|
||||
try:
|
||||
st = committed.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
key = (st.st_mtime_ns, st.st_size)
|
||||
cached = _fingerprint_cache.get("k")
|
||||
if cached == key:
|
||||
return _fingerprint_cache["v"]
|
||||
h = hashlib.sha256(committed.read_bytes()).hexdigest()
|
||||
_fingerprint_cache["k"] = key
|
||||
_fingerprint_cache["v"] = h
|
||||
return h
|
||||
|
||||
|
||||
def runtime_meta_path() -> Path:
|
||||
"""Sidecar recording which core the runtime sheet was built against."""
|
||||
return runtime_css_path().with_suffix(".meta.json")
|
||||
|
||||
|
||||
def runtime_css_is_current() -> bool:
|
||||
"""True when the runtime sheet was built against the core we are running NOW.
|
||||
|
||||
WHY NOT mtime. Codex [P2] on the second cut of #911, and it was right: filesystem
|
||||
timestamps are not a freshness signal across install methods. Archives and container images
|
||||
routinely PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER mtime than
|
||||
a runtime sheet a user built days ago. The mtime comparison then reports the stale sheet as
|
||||
fresh and it masks the new core CSS indefinitely — permanently, if no Tailwind toolchain is
|
||||
present to trigger a rebuild.
|
||||
|
||||
Content answers the question timestamps only gesture at: the sidecar records the hash of the
|
||||
committed sheet this runtime build was made from. Core ships new CSS -> that file changes ->
|
||||
the hash changes -> the runtime sheet is correctly judged stale.
|
||||
"""
|
||||
try:
|
||||
meta = json.loads(runtime_meta_path().read_text())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return bool(meta.get("committed_sha256")) and meta["committed_sha256"] == _committed_css_fingerprint()
|
||||
|
||||
|
||||
def runtime_css_path() -> Path:
|
||||
"""Where the RUNTIME-augmented stylesheet is written.
|
||||
|
||||
NOT ``static/tailwind.min.css``. That file is a BUILD ARTEFACT: committed, image-baked,
|
||||
and generated by scanning only the in-tree plugins. This one is PER-INSTALL STATE — it
|
||||
additionally scans whatever the user has installed into FEEDBACK_PLUGINS_DIR, so it differs
|
||||
from machine to machine. They are different things and must not share a path.
|
||||
|
||||
Writing the runtime sheet over the committed one had two costs:
|
||||
|
||||
* IN A GIT CHECKOUT it silently modifies a TRACKED file. `git add -A` then sweeps a
|
||||
100KB reshuffle of minified CSS into the commit and `ci/tailwind-fresh` goes red with a
|
||||
diff that explains nothing. That is issue #911, and it cost a red run on a PR whose
|
||||
real diff touched no Tailwind classes at all.
|
||||
* IN A DEPLOY the app directory may be read-only. Writing app state into it is wrong on
|
||||
principle and fatal in practice.
|
||||
|
||||
CONFIG_DIR is where per-install state already lives.
|
||||
"""
|
||||
cfg = (getenv_compat("CONFIG_DIR", "") or "").strip()
|
||||
base = Path(cfg) if cfg else (Path.home() / ".local" / "share" / "feedback")
|
||||
return base / "tailwind.min.css"
|
||||
|
||||
|
||||
def _user_plugins_dir() -> Path | None:
|
||||
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
|
||||
if not raw:
|
||||
@@ -136,6 +209,14 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
||||
cwd=str(APP_DIR), timeout=120,
|
||||
)
|
||||
os.replace(staged, out)
|
||||
# Stamp WHICH CORE this was built against. Without it, an upgraded app cannot tell a
|
||||
# current runtime sheet from one that predates its new CSS.
|
||||
try:
|
||||
runtime_meta_path().write_text(json.dumps({
|
||||
"committed_sha256": _committed_css_fingerprint(),
|
||||
}))
|
||||
except OSError:
|
||||
log.warning("tailwind: could not write the runtime sheet's meta sidecar")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
stderr = (getattr(e, "stderr", "") or "")[-500:]
|
||||
@@ -153,7 +234,7 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
||||
|
||||
|
||||
def rebuild(reason: str = "") -> bool:
|
||||
"""Regenerate ``static/tailwind.min.css`` over baked-in + user plugins.
|
||||
"""Regenerate the RUNTIME stylesheet (see runtime_css_path) over baked-in + user plugins.
|
||||
|
||||
Returns ``True`` on a successful rebuild, ``False`` on any skip/failure.
|
||||
Never raises — callers treat CSS freshness as best-effort. Concurrent
|
||||
@@ -166,8 +247,13 @@ def rebuild(reason: str = "") -> bool:
|
||||
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
|
||||
return False
|
||||
|
||||
out = APP_DIR / "static" / "tailwind.min.css"
|
||||
out = runtime_css_path()
|
||||
src = APP_DIR / "static" / "_tailwind.src.css"
|
||||
try:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
log.warning("tailwind rebuild skipped — cannot create %s%s", out.parent, tag)
|
||||
return False
|
||||
|
||||
# If a rebuild is already running, flag a rerun and return instead of
|
||||
# queueing a redundant build behind it.
|
||||
|
||||
+4
-2
@@ -101,14 +101,16 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
||||
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
||||
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
||||
non-numeric or non-positive (a provider could hand us anything)."""
|
||||
non-numeric, non-finite, or non-positive (a provider could hand us
|
||||
anything; NaN/Infinity would otherwise raise inside int(round(...)) and
|
||||
500 the /api/tunings endpoint)."""
|
||||
out: list[int] = []
|
||||
for f in freqs:
|
||||
try:
|
||||
f = float(f)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f <= 0:
|
||||
if not math.isfinite(f) or f <= 0:
|
||||
return None
|
||||
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||
return out
|
||||
|
||||
@@ -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,716 @@
|
||||
/* 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; }
|
||||
|
||||
/* ── Passports (badge journey) ─────────────────────────────────────────── */
|
||||
|
||||
.career-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
|
||||
}
|
||||
.career-tab {
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.career-tab:hover { color: #e5e7eb; }
|
||||
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
|
||||
|
||||
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.pp-inst {
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: #d1d5db;
|
||||
background-color: rgba(31, 41, 55, 0.7);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||
.pp-inst.active { border-color: #06b6d4; color: #fff; }
|
||||
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
|
||||
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
|
||||
.pp-inst-plus { color: #6b7280; }
|
||||
|
||||
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||
subtle grain gradient (no image assets). Keep the hex pairs in sync with
|
||||
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
|
||||
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
|
||||
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
|
||||
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
|
||||
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
|
||||
|
||||
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
|
||||
.pp-cover, .pp-commit-cover {
|
||||
position: relative;
|
||||
width: 9.5rem;
|
||||
height: 13rem;
|
||||
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
|
||||
0 6px 16px rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
||||
.pp-cover:not(.pp-tilt):hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
|
||||
.pp-cover-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(240, 226, 195, 0.92);
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pp-cover-inst {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 226, 195, 0.55);
|
||||
}
|
||||
.pp-cover-sub {
|
||||
position: absolute;
|
||||
bottom: 0.6rem;
|
||||
font-size: 0.6rem;
|
||||
color: rgba(240, 226, 195, 0.5);
|
||||
}
|
||||
|
||||
.pp-commit-card {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(55, 65, 81, 0.6);
|
||||
background-color: rgba(31, 41, 55, 0.35);
|
||||
}
|
||||
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
|
||||
|
||||
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
|
||||
.pp-brochure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: left;
|
||||
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
|
||||
.pp-brochure-art { font-size: 1.4rem; }
|
||||
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
|
||||
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
|
||||
|
||||
/* The open book */
|
||||
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
|
||||
.pp-book-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(3, 7, 18, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.pp-book {
|
||||
position: relative;
|
||||
width: min(92vw, 720px);
|
||||
height: min(72vh, 470px);
|
||||
perspective: 1800px;
|
||||
}
|
||||
.pp-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
background:
|
||||
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
|
||||
#efe6d0;
|
||||
color: #3f3428;
|
||||
padding: 1.1rem 1.2rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
|
||||
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
|
||||
.pp-book.open .pp-page-left { opacity: 1; }
|
||||
.pp-book-cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
border-radius: 0 0.6rem 0.6rem 0;
|
||||
transform-origin: left center;
|
||||
transform: rotateY(0deg);
|
||||
backface-visibility: hidden;
|
||||
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
|
||||
.pp-book-close {
|
||||
position: absolute;
|
||||
top: -0.75rem;
|
||||
right: -0.75rem;
|
||||
z-index: 8;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
color: #d1d5db;
|
||||
border: 1px solid rgba(107, 114, 128, 0.5);
|
||||
}
|
||||
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
|
||||
.pp-page-head {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
|
||||
padding-bottom: 0.4rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The rubber stamp */
|
||||
.pp-stamp {
|
||||
--pp-rot: 0deg;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: 9rem;
|
||||
height: 9rem;
|
||||
border-radius: 999px;
|
||||
border: 3px solid #9a5b16;
|
||||
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
|
||||
color: #9a5b16;
|
||||
transform: rotate(var(--pp-rot));
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
|
||||
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
|
||||
.pp-stamp-ghost {
|
||||
border-style: dashed;
|
||||
box-shadow: none;
|
||||
border-color: #b3a68b;
|
||||
color: #b3a68b;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.pp-stamp-hidden { opacity: 0; }
|
||||
.pp-stamp-mini {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-width: 2px;
|
||||
box-shadow: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2em;
|
||||
color: #d9a253;
|
||||
border-color: #d9a253;
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
transform: rotate(var(--pp-rot));
|
||||
opacity: 0.95;
|
||||
}
|
||||
.pp-stamp-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
|
||||
filter: blur(5px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
|
||||
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
|
||||
@keyframes pp-slam {
|
||||
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
|
||||
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
|
||||
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
|
||||
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
|
||||
}
|
||||
@keyframes pp-ink {
|
||||
from { opacity: 0; transform: scale(0.6); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
|
||||
@keyframes pp-shake {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0); }
|
||||
25% { transform: translate(2px, 1px) rotate(0.3deg); }
|
||||
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
|
||||
75% { transform: translate(1px, -1px) rotate(0.15deg); }
|
||||
}
|
||||
|
||||
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
|
||||
.pp-invite { color: #6d5d40; }
|
||||
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
|
||||
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
|
||||
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
|
||||
.pp-drill.cleared { color: #4d7c0f; }
|
||||
|
||||
/* Ticket stubs */
|
||||
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
|
||||
.pp-stub {
|
||||
background: #f7f1e3;
|
||||
border: 1px solid #d8cbaa;
|
||||
border-left: 2px dashed #b6a98c;
|
||||
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
|
||||
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
|
||||
}
|
||||
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
|
||||
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
|
||||
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
|
||||
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
|
||||
|
||||
/* Wax-seal commitment ceremony */
|
||||
.pp-ceremony { width: 11rem; height: 15rem; }
|
||||
.pp-wax {
|
||||
position: absolute;
|
||||
bottom: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.25),
|
||||
0 3px 8px rgba(0, 0, 0, 0.55);
|
||||
color: rgba(255, 235, 235, 0.9);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
|
||||
}
|
||||
@keyframes pp-seal-drop {
|
||||
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
|
||||
60% { transform: translateY(0) scale(0.9); opacity: 1; }
|
||||
80% { transform: translateY(0) scale(1.05); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Small screens: the spread stacks; the flip cover would straddle both
|
||||
pages, so the book simply opens. */
|
||||
@media (max-width: 640px) {
|
||||
.pp-book { height: min(80vh, 620px); }
|
||||
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
|
||||
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
|
||||
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
|
||||
.pp-book-cover { display: none; }
|
||||
.pp-book { display: flex; flex-direction: column; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
|
||||
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
|
||||
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
||||
.pp-stamp-hidden { opacity: 0.92; }
|
||||
}
|
||||
|
||||
/* Badge ceremony (body-level overlay — shows over the player) */
|
||||
.pp-ceremony-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 220;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(3, 7, 18, 0.55);
|
||||
backdrop-filter: blur(1.5px);
|
||||
animation: pp-ceremony-in 0.3s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
|
||||
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.pp-ceremony-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-ceremony-stamp {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(239, 230, 208, 0.97);
|
||||
transform: rotate(var(--pp-rot)) scale(1.25);
|
||||
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
|
||||
margin-top: 0;
|
||||
}
|
||||
.pp-ceremony-stamp::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -40%;
|
||||
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
|
||||
transform: translateX(-120%);
|
||||
animation: pp-shine 1.1s ease-out 0.75s forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes pp-shine {
|
||||
to { transform: translateX(120%); }
|
||||
}
|
||||
@keyframes pp-ceremony-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.pp-ceremony-title {
|
||||
margin-top: 1rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #f0e2c3;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
|
||||
|
||||
/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */
|
||||
.pp-hours {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8a7a5e;
|
||||
margin-top: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
|
||||
|
||||
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
|
||||
pointers only). */
|
||||
.pp-tilt {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
.pp-cover.pp-tilt {
|
||||
transform: perspective(700px)
|
||||
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||
rotate(var(--pp-cover-rot, 0deg));
|
||||
transition: transform 0.12s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
|
||||
.pp-stamp-page.pp-tilt {
|
||||
overflow: visible;
|
||||
transform: perspective(600px)
|
||||
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||
rotate(var(--pp-rot));
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.pp-tilt::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(105deg,
|
||||
transparent calc(var(--pp-glint-x, 50%) - 14%),
|
||||
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
|
||||
transparent calc(var(--pp-glint-x, 50%) + 14%));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-tilt:hover::after { opacity: 1; }
|
||||
|
||||
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
|
||||
.pp-stamp-ghost::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 7%;
|
||||
border-radius: 999px;
|
||||
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gold foil preview — honest "coming", never earnable-looking. */
|
||||
.pp-gold-foil {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.28rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 2px dashed #c8b273;
|
||||
color: #a8946d;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
}
|
||||
.pp-gold-foil::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
|
||||
transform: translateX(-120%);
|
||||
animation: pp-foil 3.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pp-foil {
|
||||
0%, 55% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(120%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-gold-foil::after { animation: none; }
|
||||
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
|
||||
/* The hover glint is motion theatrics too — not just the JS tilt. */
|
||||
.pp-tilt::after { display: none; }
|
||||
}
|
||||
|
||||
/* Practice invitations — closest stamps + bring-these-up */
|
||||
.pp-closest {
|
||||
border: 1px solid rgba(75, 85, 99, 0.45);
|
||||
border-radius: 0.6rem;
|
||||
background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.pp-closest-head {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.pp-closest-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
|
||||
.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
|
||||
.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
|
||||
.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
|
||||
|
||||
.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
|
||||
.pp-nearest-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.pp-nearest-row em { color: #3f3428; }
|
||||
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
|
||||
|
||||
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.pp-wall-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
|
||||
.pp-wall-shelf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
|
||||
}
|
||||
.pp-wall-inst {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
min-width: 3.6rem;
|
||||
}
|
||||
.pp-wall-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: 4.2rem;
|
||||
height: 5.6rem;
|
||||
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
|
||||
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
|
||||
0 3px 8px rgba(0, 0, 0, 0.4);
|
||||
padding: 0.3rem;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pp-wall-cover:hover { transform: translateY(-3px); }
|
||||
.pp-wall-cover span {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(240, 226, 195, 0.9);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-wall-cover em {
|
||||
font-size: 0.42rem;
|
||||
letter-spacing: 0.22em;
|
||||
font-style: normal;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
|
||||
.pp-wall-link {
|
||||
align-self: flex-end;
|
||||
font-size: 0.72rem;
|
||||
color: #22d3ee;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
.pp-wall-link:hover { text-decoration: underline; }
|
||||
|
||||
/* The home-page career card — a trading card among stat tiles. */
|
||||
.pp-dash-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(217, 162, 83, 0.35);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
|
||||
linear-gradient(160deg, #2b1414, #17111c);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
|
||||
.pp-dash-shine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
|
||||
transform: translateX(-130%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
|
||||
.pp-dash-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
|
||||
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
|
||||
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
|
||||
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
|
||||
.pp-dash-ask em { color: #cbd5e1; }
|
||||
|
||||
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: none; }
|
||||
.pp-wall-cover, .pp-dash-card { transition: none; }
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"badge_requirement": {
|
||||
"songs": 5,
|
||||
"min_stars": 2
|
||||
},
|
||||
"gig": {
|
||||
"min_songs": 3,
|
||||
"max_songs": 5,
|
||||
"stakes_songs": 2,
|
||||
"encore_accuracy": 0.75
|
||||
},
|
||||
"families": [
|
||||
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
|
||||
{ "key": "blues", "match": ["blues"] },
|
||||
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
|
||||
{ "key": "funk", "match": ["funk", "disco"] },
|
||||
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
|
||||
],
|
||||
"genres": {
|
||||
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
|
||||
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
|
||||
"metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } },
|
||||
"funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } },
|
||||
"jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } }
|
||||
},
|
||||
"drill_labels": {
|
||||
"blues_shuffle": "Blues Shuffle",
|
||||
"rock_power_backbeat": "Power Chords & Backbeat",
|
||||
"melodic_metal_gallop": "Gallop Picking",
|
||||
"sixteenth_pocket": "16th Pocket",
|
||||
"vl_shells": "Shell Voicings"
|
||||
},
|
||||
"graded_instruments": [
|
||||
"guitar",
|
||||
"keys"
|
||||
],
|
||||
"instruments": [
|
||||
"guitar",
|
||||
"bass",
|
||||
"keys",
|
||||
"drums"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "career",
|
||||
"name": "Career",
|
||||
"version": "0.2.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/career.css",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"server_files": [
|
||||
"career/"
|
||||
]
|
||||
},
|
||||
"routes": "routes.py"
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
"""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) may be bundled with
|
||||
the plugin under ``venue-packs/<id>/`` or downloaded on demand into
|
||||
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
|
||||
bundled packs so release assets can replace a built-in starter venue.
|
||||
|
||||
Passports (badge journey per instrument × genre — the identity layer on top
|
||||
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
|
||||
library's effective genre, never stored. The only persisted career state is
|
||||
what cannot be derived — instrument commitment, opened passports, and the
|
||||
relayed virtuoso drill snapshot — as JSON under ``CONFIG_DIR/career/``
|
||||
(exported via ``settings.server_files``).
|
||||
|
||||
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)
|
||||
GET /passports passport walls: badges, stubs, genres, drill status
|
||||
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
||||
POST /passports/open open a genre passport for an instrument
|
||||
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
||||
POST /gigs/propose build a playable setlist for a genre gig
|
||||
POST /gigs log a COMPLETED gig (abandoned sets never log)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from progression import instrument_for_arrangement
|
||||
|
||||
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
|
||||
"plugin_dir": None, # plugin root; bundled packs live below it
|
||||
"venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues
|
||||
"meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL)
|
||||
"log": logging.getLogger("feedBack.plugin.career"),
|
||||
"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 _bundled_venue_dir(venue_id) -> Path:
|
||||
return _state["plugin_dir"] / "venue-packs" / venue_id
|
||||
|
||||
|
||||
def _pack_dir(venue_id):
|
||||
"""Runtime pack location: downloaded override first, bundled fallback."""
|
||||
local = _venue_dir(venue_id)
|
||||
if (local / "manifest.json").is_file():
|
||||
return local
|
||||
bundled = _bundled_venue_dir(venue_id)
|
||||
if (bundled / "manifest.json").is_file():
|
||||
return bundled
|
||||
return local
|
||||
|
||||
|
||||
def _installed(venue_id):
|
||||
return (_pack_dir(venue_id) / "manifest.json").is_file()
|
||||
|
||||
|
||||
def _bundled(venue_id):
|
||||
return (_bundled_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, next_at = _star_progress(acc, thresholds)
|
||||
if stars:
|
||||
per_song[filename] = stars
|
||||
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
|
||||
|
||||
|
||||
# ── Passports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
GENRE_MAX_LEN = 64
|
||||
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def _now_iso():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _genre_display(genre):
|
||||
return " ".join(str(genre or "").strip().split())
|
||||
|
||||
|
||||
def _genre_key(genre):
|
||||
return _genre_display(genre).lower()
|
||||
|
||||
|
||||
def _state_file() -> Path:
|
||||
return _state["state_dir"] / "passports-state.json"
|
||||
|
||||
|
||||
def _drill_file() -> Path:
|
||||
return _state["state_dir"] / "drill-state.json"
|
||||
|
||||
|
||||
def _load_json(path: Path, default):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _save_json(path: Path, obj):
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _career_state():
|
||||
st = _load_json(_state_file(), {})
|
||||
if not isinstance(st, dict):
|
||||
st = {}
|
||||
if not isinstance(st.get("instruments"), dict):
|
||||
st["instruments"] = {}
|
||||
if not isinstance(st.get("passports"), dict):
|
||||
st["passports"] = {}
|
||||
return st
|
||||
|
||||
|
||||
def _genre_expr(db):
|
||||
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
|
||||
# overrides); plain `genre` on stand-ins that don't implement it.
|
||||
fn = getattr(db, "_effective_genre_expr", None)
|
||||
return fn() if callable(fn) else "genre"
|
||||
|
||||
|
||||
def _instrument_of(arrangements, arrangement):
|
||||
"""Progression's arrangement→instrument mapping, via the song_stats
|
||||
arrangement index into the song's arrangements JSON."""
|
||||
entry = None
|
||||
try:
|
||||
idx = int(arrangement)
|
||||
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
|
||||
entry = arrangements[idx]
|
||||
except (TypeError, ValueError):
|
||||
entry = None
|
||||
return instrument_for_arrangement(entry)
|
||||
|
||||
|
||||
def _played_by_instrument_genre():
|
||||
"""((instrument, genre_key) → {filename: stub dict},
|
||||
(instrument, genre_key) → total played seconds).
|
||||
Best accuracy per (instrument, song); seconds sum across every
|
||||
arrangement row; the JOIN keeps the same dead-song filter as _stars()."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return {}, {}
|
||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||
rows = db.conn.execute(
|
||||
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
|
||||
" s.seconds_total, songs.title, songs.artist, songs.arrangements, "
|
||||
f" {_genre_expr(db)} "
|
||||
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
|
||||
).fetchall()
|
||||
arrs_cache = {}
|
||||
out = {}
|
||||
seconds = {}
|
||||
for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows:
|
||||
gkey = _genre_key(genre)
|
||||
if not gkey:
|
||||
continue
|
||||
if filename not in arrs_cache:
|
||||
try:
|
||||
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
|
||||
except (TypeError, ValueError):
|
||||
arrs_cache[filename] = None
|
||||
instrument = _instrument_of(arrs_cache[filename], arrangement)
|
||||
key = (instrument, gkey)
|
||||
seconds[key] = seconds.get(key, 0.0) + (secs or 0.0)
|
||||
acc = acc or 0.0
|
||||
stub = out.setdefault(key, {}).get(filename)
|
||||
if stub is None:
|
||||
out[key][filename] = {
|
||||
"filename": filename,
|
||||
"title": title or filename,
|
||||
"artist": artist or "",
|
||||
"best_accuracy": acc,
|
||||
"last_played_at": played_at,
|
||||
}
|
||||
else:
|
||||
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
|
||||
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
|
||||
for stubs in out.values():
|
||||
for stub in stubs.values():
|
||||
acc = stub["best_accuracy"]
|
||||
stub["best_accuracy"] = round(acc, 4)
|
||||
stub["stars"], stub["next_star_at"] = _star_progress(acc, thresholds)
|
||||
return out, seconds
|
||||
|
||||
|
||||
def _star_progress(acc, thresholds):
|
||||
"""(stars, next_star_at) — the one place the ascending-thresholds
|
||||
assumption lives; _stars() and the passport stubs both use it."""
|
||||
stars = sum(1 for t in thresholds if acc >= t)
|
||||
next_at = next((t for t in thresholds if acc < t), None)
|
||||
return stars, next_at
|
||||
|
||||
|
||||
def _library_genres():
|
||||
"""Distinct effective genres across the live library (the brochure rack)."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.conn.execute(
|
||||
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
|
||||
by_key = {}
|
||||
for genre, count in rows:
|
||||
display = _genre_display(genre)
|
||||
key = display.lower()
|
||||
if not key:
|
||||
continue
|
||||
cur = by_key.get(key)
|
||||
if cur: # case-variant duplicates collapse onto the first-seen casing
|
||||
cur["songs_in_library"] += count
|
||||
else:
|
||||
by_key[key] = {"genre_key": key, "genre": display,
|
||||
"songs_in_library": count}
|
||||
return sorted(by_key.values(),
|
||||
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
|
||||
|
||||
|
||||
def _genre_family(gkey):
|
||||
"""First family whose keyword appears in the genre key (substring — MB's
|
||||
vocabulary is open: 'metalcore' must hit the 'metal' family without an
|
||||
exact alias). List order decides ambiguity: families are checked top to
|
||||
bottom, so 'blues rock' lands on whichever of blues/rock is listed first."""
|
||||
for fam in _state["passports_content"].get("families") or []:
|
||||
if not isinstance(fam, dict):
|
||||
continue
|
||||
for kw in fam.get("match") or []:
|
||||
if isinstance(kw, str) and kw and kw in gkey:
|
||||
return fam.get("key")
|
||||
return None
|
||||
|
||||
|
||||
def _badge_requirement(gkey, instrument="guitar"):
|
||||
cfg = _state["passports_content"]
|
||||
req = dict(cfg.get("badge_requirement") or {})
|
||||
req.setdefault("songs", 5)
|
||||
req.setdefault("min_stars", 2)
|
||||
# Exact per-genre override wins; otherwise the genre inherits its FAMILY's
|
||||
# requirement — so 'death metal' / 'metalcore' passports carry the metal
|
||||
# drill without curating every MB sub-genre by hand.
|
||||
genres_cfg = cfg.get("genres") or {}
|
||||
override = genres_cfg.get(gkey)
|
||||
if not isinstance(override, dict):
|
||||
family = _genre_family(gkey)
|
||||
override = genres_cfg.get(family) if family else None
|
||||
if isinstance(override, dict):
|
||||
req.update(override)
|
||||
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
|
||||
# own instrument's drills. A flat list keeps meaning guitar (back-compat;
|
||||
# virtuoso's drill content is guitar-first).
|
||||
nodes = req.get("virtuoso_nodes") or []
|
||||
if isinstance(nodes, dict):
|
||||
nodes = nodes.get(instrument) or []
|
||||
elif instrument != "guitar":
|
||||
nodes = []
|
||||
req["virtuoso_nodes"] = [n for n in nodes if isinstance(n, str)]
|
||||
return req
|
||||
|
||||
|
||||
def _drill_by_node():
|
||||
doc = _load_json(_drill_file(), {})
|
||||
if not isinstance(doc, dict):
|
||||
return None, {}
|
||||
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||
return doc.get("received_at"), by_node
|
||||
|
||||
|
||||
def _merge_drill_nodes(old, new):
|
||||
"""Gained-only merge of virtuoso byNode snapshots: a completion artifact
|
||||
once relayed never un-earns via a stale snapshot (multi-browser races,
|
||||
settings import, the once-per-session boot relay). Incoming wins the
|
||||
descriptive fields; masteredAt / depth flips / keysCleared only grow."""
|
||||
out = dict(old)
|
||||
for node_id, incoming in new.items():
|
||||
if not isinstance(incoming, dict):
|
||||
continue
|
||||
cur = out.get(node_id)
|
||||
if not isinstance(cur, dict):
|
||||
out[node_id] = incoming
|
||||
continue
|
||||
merged = dict(cur)
|
||||
merged.update(incoming)
|
||||
merged["masteredAt"] = cur.get("masteredAt") or incoming.get("masteredAt")
|
||||
d_old = cur.get("depth") if isinstance(cur.get("depth"), dict) else {}
|
||||
d_new = incoming.get("depth") if isinstance(incoming.get("depth"), dict) else {}
|
||||
depth = dict(d_new)
|
||||
for axis, val in d_old.items():
|
||||
if val and not depth.get(axis):
|
||||
depth[axis] = val
|
||||
if depth:
|
||||
merged["depth"] = depth
|
||||
keys_old = cur.get("keysCleared") if isinstance(cur.get("keysCleared"), list) else []
|
||||
keys_new = incoming.get("keysCleared") if isinstance(incoming.get("keysCleared"), list) else []
|
||||
merged["keysCleared"] = keys_old + [k for k in keys_new if k not in keys_old]
|
||||
out[node_id] = merged
|
||||
return out
|
||||
|
||||
|
||||
def _node_cleared(by_node, node_id):
|
||||
"""A drill counts as cleared on real completion evidence: mastered, any
|
||||
depth rung flipped true, or a key cleared (a top-tier clean pass in one
|
||||
key — virtuoso's first gained-only artifact, and an achievable Bronze
|
||||
bar; the depth rungs additionally require a maxed speed tier)."""
|
||||
entry = by_node.get(node_id)
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
|
||||
keys = entry.get("keysCleared")
|
||||
return (bool(entry.get("masteredAt"))
|
||||
or any(bool(v) for v in depth.values())
|
||||
or bool(isinstance(keys, list) and keys))
|
||||
|
||||
|
||||
def _passports_view():
|
||||
cfg = _state["passports_content"]
|
||||
graded = set(cfg.get("graded_instruments") or [])
|
||||
st = _career_state()
|
||||
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
|
||||
played, played_seconds = _played_by_instrument_genre()
|
||||
received_at, by_node = _drill_by_node()
|
||||
instruments = {}
|
||||
for inst in cfg.get("instruments") or []:
|
||||
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
||||
opened = st["passports"].get(inst)
|
||||
opened = opened if isinstance(opened, dict) else {}
|
||||
passports = []
|
||||
for gkey, meta in sorted(opened.items(),
|
||||
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
req = _badge_requirement(gkey, inst)
|
||||
songs = list(played.get((inst, gkey), {}).values())
|
||||
for s in songs:
|
||||
s["qualifies"] = s["stars"] >= req["min_stars"]
|
||||
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
|
||||
s["title"].lower()))
|
||||
qualifying = sum(1 for s in songs if s["qualifies"])
|
||||
required = req["virtuoso_nodes"]
|
||||
cleared = [n for n in required if _node_cleared(by_node, n)]
|
||||
is_graded = inst in graded
|
||||
if not is_graded:
|
||||
# Where the engine can't fairly grade the instrument's job
|
||||
# (bass pocket, feel) the passport shows repertoire, never a
|
||||
# false badge denial — the doc's shown-not-judged rule.
|
||||
badge = "shown_not_judged"
|
||||
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
||||
badge = "earned"
|
||||
else:
|
||||
badge = "in_progress"
|
||||
# Practice invitation: the non-qualifying songs closest to the
|
||||
# QUALIFYING bar (the badge ask), nearest first — invitation
|
||||
# data, the UI voices it without meters.
|
||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||
bar = (thresholds[req["min_stars"] - 1]
|
||||
if 0 < req["min_stars"] <= len(thresholds) else None)
|
||||
nearest = [] if bar is None else sorted(
|
||||
(s for s in songs if not s["qualifies"]),
|
||||
key=lambda s: bar - s["best_accuracy"])[:3]
|
||||
for s in nearest:
|
||||
s["bar_at"] = bar
|
||||
passports.append({
|
||||
"genre_key": gkey,
|
||||
"genre": meta.get("genre") or gkey,
|
||||
"opened_at": meta.get("opened_at"),
|
||||
"requirement": req,
|
||||
"graded": is_graded,
|
||||
"songs": songs,
|
||||
"qualifying_count": qualifying,
|
||||
"nearest": nearest,
|
||||
# Honest hours odometer (Stage 5 post-cap): a true fact that
|
||||
# only grows — never a target, never a meter.
|
||||
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
|
||||
"drills": {"required": required, "cleared": cleared},
|
||||
"badge": badge,
|
||||
})
|
||||
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
|
||||
for p in passports:
|
||||
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports,
|
||||
"gig_count": len(inst_gigs)}
|
||||
return {
|
||||
"config": {
|
||||
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||
"graded_instruments": sorted(graded),
|
||||
"instruments": list(cfg.get("instruments") or []),
|
||||
# Career-side display names for virtuoso drill node ids.
|
||||
"drill_labels": dict(cfg.get("drill_labels") or {}),
|
||||
},
|
||||
"instruments": instruments,
|
||||
"genres": _library_genres(),
|
||||
"drill_state": {"received_at": received_at},
|
||||
}
|
||||
|
||||
|
||||
def _gig_config():
|
||||
cfg = _state["passports_content"].get("gig")
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
|
||||
def _num(key, default, cast):
|
||||
# Tuning data, not code: junk falls back instead of 500ing both gig
|
||||
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
|
||||
val = cfg.get(key)
|
||||
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
||||
return default
|
||||
return cast(val)
|
||||
|
||||
return {
|
||||
"min_songs": max(1, _num("min_songs", 3, int)),
|
||||
"max_songs": max(1, _num("max_songs", 5, int)),
|
||||
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
|
||||
"encore_accuracy": _num("encore_accuracy", 0.75, float),
|
||||
}
|
||||
|
||||
|
||||
def _current_venue():
|
||||
"""Highest unlocked venue (the room you can book today)."""
|
||||
stars_total, _, _ = _stars()
|
||||
best = None
|
||||
for v in _state["content"]["venues"]:
|
||||
if stars_total >= v["star_threshold"]:
|
||||
if best is None or v["star_threshold"] >= best["star_threshold"]:
|
||||
best = v
|
||||
return best
|
||||
|
||||
|
||||
def _unplayed_genre_songs(gkey, exclude, limit):
|
||||
"""Library songs of a genre with no stats yet — a young passport's gig
|
||||
still gets a full set (playing them is how stubs start).
|
||||
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
|
||||
songs, single-user); push the match into SQL if propose ever feels slow."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.conn.execute(
|
||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
|
||||
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
|
||||
).fetchall()
|
||||
out = []
|
||||
for filename, title, artist, genre in rows:
|
||||
if _genre_key(genre) != gkey or filename in exclude:
|
||||
continue
|
||||
out.append({"filename": filename, "title": title or filename,
|
||||
"artist": artist or ""})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
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 name in (manifest.get("intro") or {}).values():
|
||||
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
|
||||
raise ValueError(f"intro 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["plugin_dir"] = plugin_dir
|
||||
_state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8"))
|
||||
_state["venues_dir"] = (
|
||||
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
||||
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
||||
_state["passports_content"] = json.loads(
|
||||
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
|
||||
# Persisted career state (commitment / opened passports / drill snapshot)
|
||||
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
|
||||
# rides the settings export/import bundle. Packs stay out (they're media).
|
||||
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
|
||||
_state["state_dir"].mkdir(parents=True, exist_ok=True)
|
||||
_state["meta_db"] = context.get("meta_db")
|
||||
_state["log"] = context.get("log") or _state["log"]
|
||||
for v in _state["content"]["venues"]:
|
||||
if _bundled(v["id"]):
|
||||
_validate_pack_dir(_bundled_venue_dir(v["id"]))
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
|
||||
def get_state():
|
||||
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"]),
|
||||
"bundled": _bundled(v["id"]),
|
||||
"has_pack": _bundled(v["id"]) or 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.get(f"/api/plugins/{PLUGIN_ID}/passports")
|
||||
def get_passports():
|
||||
with _lock:
|
||||
return _passports_view()
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
|
||||
def commit_instrument(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
entry = st["instruments"].setdefault(inst, {})
|
||||
# Idempotent: the wax seal is pressed once; re-commits keep the
|
||||
# original date (only-gained-never-lost).
|
||||
if not entry.get("committed_at"):
|
||||
entry["committed_at"] = _now_iso()
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "instrument": inst,
|
||||
"committed_at": entry["committed_at"]}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
|
||||
def open_passport(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
# Opening a passport implies the instrument commitment (permissive
|
||||
# server, ceremony ordering is the UI's job).
|
||||
st["instruments"].setdefault(inst, {}).setdefault(
|
||||
"committed_at", _now_iso())
|
||||
genres = st["passports"].setdefault(inst, {})
|
||||
if gkey not in genres:
|
||||
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
|
||||
def post_drill_state(body: dict = Body(...)):
|
||||
# The relayed virtuoso.progress snapshot (career's screen.js listens to
|
||||
# the virtuoso:progress bus event and forwards the localStorage doc).
|
||||
# Only the fields the badge check reads are kept.
|
||||
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
|
||||
raise HTTPException(400, "Expected a progress snapshot with byNode.")
|
||||
# Bound the INCOMING snapshot before the merge — the gained-only merge
|
||||
# drops junk entries, which must not become a size-guard bypass.
|
||||
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
with _lock:
|
||||
_, existing = _drill_by_node()
|
||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||
"byNode": _merge_drill_nodes(existing, body["byNode"])}
|
||||
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
_save_json(_drill_file(), {"received_at": _now_iso(),
|
||||
"snapshot": snapshot})
|
||||
return {"ok": True}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
|
||||
def propose_gig(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
cfg = _gig_config()
|
||||
try:
|
||||
size = int((body or {}).get("size") or 4)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "size must be a number.")
|
||||
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
|
||||
played, _seconds = _played_by_instrument_genre()
|
||||
stubs = list(played.get((inst, gkey), {}).values())
|
||||
req = _badge_requirement(gkey, inst)
|
||||
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
|
||||
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
|
||||
# The set: mostly songs you own, plus a couple of stakes songs near
|
||||
# the bar; a young passport fills from unplayed genre songs so the
|
||||
# first gig is how stubs start. random per call = free re-roll.
|
||||
random.shuffle(qualifying)
|
||||
rest.sort(key=lambda s: -s["best_accuracy"])
|
||||
qtaken = max(1, size - cfg["stakes_songs"])
|
||||
picks = qualifying[:qtaken]
|
||||
for s in rest:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
# Surplus qualifying songs backfill a short set — a mature passport
|
||||
# with no near-bar songs left must still fill the bill. Offset by how
|
||||
# many QUALIFYING songs were taken, not len(picks): rest's stakes
|
||||
# additions would otherwise skip eligible qualifying songs entirely.
|
||||
for s in qualifying[qtaken:]:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
if len(picks) < size:
|
||||
exclude = {s["filename"] for s in picks}
|
||||
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
|
||||
if not picks:
|
||||
raise HTTPException(404, "No songs of this genre in the library.")
|
||||
venue = _current_venue()
|
||||
return {
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"venue_id": venue["id"] if venue else None,
|
||||
"venue_name": venue["name"] if venue else "",
|
||||
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
|
||||
"artist": s.get("artist") or ""} for s in picks[:size]],
|
||||
}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
|
||||
def log_gig(body: dict = Body(...)):
|
||||
# Called by the runner ONLY when the set completed — an abandoned set
|
||||
# never logs (no fail state; the gig you finished is the gig you
|
||||
# played). Accuracies come from song_stats, freshly written by the
|
||||
# set's own plays.
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
venue_id = str((body or {}).get("venue_id") or "")
|
||||
songs = (body or {}).get("songs")
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
|
||||
raise HTTPException(400, "Unknown venue.")
|
||||
if (not isinstance(songs, list) or not songs or len(songs) > 8
|
||||
or not all(isinstance(f, str) and f.strip() for f in songs)):
|
||||
raise HTTPException(400, "songs must be 1-8 filenames.")
|
||||
db = _state["meta_db"]
|
||||
entries = []
|
||||
accuracies = []
|
||||
for filename in songs:
|
||||
title = filename
|
||||
accuracy = None
|
||||
if db is not None:
|
||||
# The NEWEST row is the set's own just-recorded play — a
|
||||
# MAX(last_accuracy) across arrangements would happily log a
|
||||
# stale higher score from another instrument's old session.
|
||||
row = db.conn.execute(
|
||||
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
|
||||
"ORDER BY last_played_at DESC LIMIT 1",
|
||||
(filename,)).fetchone()
|
||||
if row and row[0] is not None:
|
||||
accuracy = round(float(row[0]), 4)
|
||||
accuracies.append(accuracy)
|
||||
trow = db.conn.execute(
|
||||
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
|
||||
if trow and trow[0]:
|
||||
title = trow[0]
|
||||
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
|
||||
# Encore needs the WHOLE set scored at the bar — one scored song must
|
||||
# not earn an encore for a set that was 4/5 unheard.
|
||||
encore = (len(accuracies) == len(songs) and
|
||||
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
|
||||
gig = {
|
||||
"at": _now_iso(),
|
||||
"venue_id": venue_id or None,
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"songs": entries,
|
||||
"encore": encore,
|
||||
}
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
if not isinstance(st.get("gigs"), list):
|
||||
st["gigs"] = []
|
||||
st["gigs"].append(gig)
|
||||
# ponytail: hard cap — nothing reads past the last 20 per
|
||||
# passport; the state file must not grow (and export) forever.
|
||||
st["gigs"] = st["gigs"][-500:]
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "gig": gig}
|
||||
|
||||
@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.")
|
||||
pack_dir = _pack_dir(venue_id)
|
||||
path = pack_dir / filename
|
||||
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
|
||||
# the resolved path must stay inside the selected pack dir.
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(pack_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,43 @@
|
||||
<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>
|
||||
<div class="career-tabs" role="tablist">
|
||||
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
|
||||
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
|
||||
</div>
|
||||
|
||||
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
|
||||
<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>
|
||||
|
||||
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
|
||||
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
|
||||
<div id="pp-instruments" class="pp-instruments"></div>
|
||||
<div id="pp-closest" class="mt-4"></div>
|
||||
<div id="pp-shelf-wrap" class="mt-5">
|
||||
<div id="pp-shelf" class="pp-shelf"></div>
|
||||
</div>
|
||||
<div id="pp-rack-wrap" class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
|
||||
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
|
||||
<div id="pp-rack" class="pp-rack"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pp-overlay" class="pp-overlay hidden"></div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
|
||||
settings.server_files has a visible home in Settings; nothing to configure. -->
|
||||
<div class="text-sm text-gray-300 space-y-2">
|
||||
<p><strong>Career</strong> computes stars and genre badges from your play
|
||||
stats — they are never stored, so there is nothing to back up or reset.</p>
|
||||
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
|
||||
commitments, opened genre passports, and the practice-drill snapshot the
|
||||
Virtuoso plugin reports. These ride along in
|
||||
<em>Settings → Export</em> automatically.</p>
|
||||
</div>
|
||||
<hr class="border-gray-800 my-3">
|
||||
<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,179 @@
|
||||
// Passport UI pure-logic tests: load screen.js in a bare vm window and
|
||||
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
|
||||
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');
|
||||
|
||||
function load(seed) {
|
||||
const store = Object.assign({}, seed);
|
||||
const window = {
|
||||
console,
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
},
|
||||
notifications: [],
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
window.fbNotify = { show: (n) => window.notifications.push(n) };
|
||||
const context = vm.createContext(window);
|
||||
// `document` and `localStorage` resolve as bare names inside the IIFE.
|
||||
context.document = window.document;
|
||||
context.localStorage = window.localStorage;
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'career/screen.js' });
|
||||
return window;
|
||||
}
|
||||
|
||||
test('module loads (and boots) in a bare vm window', () => {
|
||||
const w = load();
|
||||
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
|
||||
});
|
||||
|
||||
test('ppKey normalizes case and whitespace', () => {
|
||||
const { ppKey } = load().__careerPassportTest;
|
||||
assert.equal(ppKey(' Blues Rock '), 'blues rock');
|
||||
assert.equal(ppKey('FUNK'), 'funk');
|
||||
assert.equal(ppKey(''), '');
|
||||
assert.equal(ppKey(null), '');
|
||||
});
|
||||
|
||||
test('ppJitter is deterministic and bounded', () => {
|
||||
const { ppJitter } = load().__careerPassportTest;
|
||||
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
|
||||
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
|
||||
const j = ppJitter(seed, 8);
|
||||
assert.ok(j >= -8 && j <= 8, `${seed} → ${j}`);
|
||||
}
|
||||
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
|
||||
});
|
||||
|
||||
test('detectNewBadges notifies once per badge, never after it is seen', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
const view = {
|
||||
instruments: {
|
||||
guitar: {
|
||||
passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
|
||||
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
assert.match(w.notifications[0].message, /Blues/);
|
||||
// Same view again in the same session: no duplicate notification.
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
// Seen (slam played) → a fresh session stays quiet too.
|
||||
t.markBadgeSeen('guitar', 'blues');
|
||||
// JSON-compare: vm objects carry a foreign Object prototype.
|
||||
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
|
||||
|
||||
// Fresh session (new vm, empty notify cache) with the badge already seen:
|
||||
// detection must stay silent.
|
||||
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('a new badge triggers the crowd celebrate() exactly once', () => {
|
||||
const w = load();
|
||||
let calls = 0;
|
||||
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||
w.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(calls, 1);
|
||||
// Same session, same view: no re-celebration.
|
||||
w.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('ceremony degrades when the crowd layer is absent or throws', () => {
|
||||
const w = load();
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
|
||||
w.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
// celebrate() throwing must not break detection.
|
||||
const w2 = load();
|
||||
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 1);
|
||||
});
|
||||
|
||||
test('seenBadges tolerates corrupt stored values', () => {
|
||||
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
||||
const w = load({ 'feedBack-career-badges-seen': bad });
|
||||
const t = w.__careerPassportTest;
|
||||
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
|
||||
// And detection still works on top of the recovered empty state.
|
||||
t.detectNewBadges({ instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
|
||||
assert.equal(w.notifications.length, 1, `stored ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
|
||||
const { fmtHours } = load().__careerPassportTest;
|
||||
assert.equal(fmtHours(0), '');
|
||||
assert.equal(fmtHours(59), '');
|
||||
assert.equal(fmtHours(60), '1 min');
|
||||
assert.equal(fmtHours(1800), '30 min');
|
||||
assert.equal(fmtHours(3600), '1 h');
|
||||
assert.equal(fmtHours(51120), '14.2 h');
|
||||
assert.equal(fmtHours(null), '');
|
||||
assert.equal(fmtHours('junk'), '');
|
||||
});
|
||||
|
||||
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
||||
const { ppFillFraction } = load().__careerPassportTest;
|
||||
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
|
||||
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
|
||||
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
|
||||
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
|
||||
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
|
||||
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
|
||||
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
||||
assert.equal(ppFillFraction(null), 0);
|
||||
});
|
||||
|
||||
test('careerTotals / wall + dash card stay absent without commitment', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
// No _pp at all → null; committed-less view → null (absent-not-empty).
|
||||
assert.equal(t.careerTotals(), null);
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: null, passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed but zero passports opened: still absent (no zero-wall).
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: 'x', passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed with an earned badge + hours → totals aggregate.
|
||||
t.setView({ config: { instruments: ['guitar', 'bass'] },
|
||||
instruments: {
|
||||
guitar: { committed_at: 'x', passports: [
|
||||
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
|
||||
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
|
||||
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
|
||||
bass: { committed_at: null, passports: [] },
|
||||
} });
|
||||
const totals = t.careerTotals();
|
||||
assert.equal(totals.badges, 1);
|
||||
assert.equal(totals.seconds, 3720);
|
||||
assert.equal(totals.walls.length, 1);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"venue": "bar",
|
||||
"version": 1,
|
||||
"loops": {
|
||||
"bored": "bored.mp4",
|
||||
"neutral": "neutral.mp4",
|
||||
"engaged": "engaged.mp4",
|
||||
"ecstatic": "ecstatic.mp4"
|
||||
},
|
||||
"stingers": {
|
||||
"clap": "clap.mp4",
|
||||
"cheer": "cheer.mp4"
|
||||
},
|
||||
"intro": {
|
||||
"video": "intro.mp4",
|
||||
"audio": "bar-ambience.mp3"
|
||||
},
|
||||
"sfx": {
|
||||
"up": "sfx-up.mp3",
|
||||
"down": "sfx-down.mp3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2418,6 +2418,13 @@
|
||||
let _venueSceneAssetsLoaded = false;
|
||||
let _venueSceneLoadFailed = false;
|
||||
const _venueTextureCache = new Map();
|
||||
// Crowd video layers (career mode). venue-crowd.js owns the <video>
|
||||
// elements and the crossfade timing; the renderer only maps them onto
|
||||
// two planes in front of the static plate. _venueCrowdRev bumps on any
|
||||
// element (re)assignment so update() knows to rebind textures.
|
||||
const _venueCrowdVideos = [null, null];
|
||||
let _venueCrowdMix = 0;
|
||||
let _venueCrowdRev = 0;
|
||||
|
||||
function _bgVenueMoodCoeffs(state) {
|
||||
const s = String(state || 'idle').toLowerCase();
|
||||
@@ -2909,6 +2916,20 @@
|
||||
window.h3dVenueSceneSetMood = (state) => {
|
||||
_venueMoodState = String(state || 'idle').toLowerCase();
|
||||
};
|
||||
// Crowd video layers (career mode) — see venue-crowd.js. Layer 0/1 are
|
||||
// two coplanar backdrop planes; mix selects between them (0 → layer 0,
|
||||
// 1 → layer 1) so the caller can crossfade loop videos.
|
||||
window.h3dVenueBackdropSetVideo = (layer, videoEl) => {
|
||||
const i = layer ? 1 : 0;
|
||||
const el = videoEl || null;
|
||||
if (_venueCrowdVideos[i] === el) return;
|
||||
_venueCrowdVideos[i] = el;
|
||||
_venueCrowdRev++;
|
||||
};
|
||||
window.h3dVenueBackdropSetMix = (mix) => {
|
||||
const v = Number(mix);
|
||||
_venueCrowdMix = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
|
||||
};
|
||||
window.h3dVenueSceneSetInstrumentPov = (input) => {
|
||||
const next = _venueResolvePovFromInput(input);
|
||||
if (_venueInstrumentPov === next) return;
|
||||
@@ -3371,6 +3392,40 @@
|
||||
() => _venueMarkFailed('failed to load small-club bg plate'),
|
||||
);
|
||||
|
||||
// Crowd video planes (career mode): two crossfading layers
|
||||
// just in front of the static plate (which stays mounted as
|
||||
// the no-pack / load-failure fallback). Textures bind lazily
|
||||
// in update() when venue-crowd.js assigns video elements.
|
||||
state.crowd = { layers: [], rev: -1 };
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const geo = new T.PlaneGeometry(1, 1);
|
||||
const mat = new T.MeshBasicMaterial({
|
||||
color: 0xffffff, transparent: true, opacity: 0,
|
||||
depthWrite: false, fog: false,
|
||||
});
|
||||
const mesh = new T.Mesh(geo, mat);
|
||||
mesh.visible = false;
|
||||
// Layer 1 sits nearest so three.js's back-to-front
|
||||
// transparent sort draws it after layer 0.
|
||||
const layer = {
|
||||
mesh, geo, mat, tex: null, videoEl: null,
|
||||
cam: settings.cam,
|
||||
distance: BG_BACKDROP_DISTANCE * (i === 0 ? 1.04 : 1.03),
|
||||
lastAspect: 0, lastVisibleHeight: 0,
|
||||
};
|
||||
layer.applyCoverCrop = function () {
|
||||
if (!layer.videoEl || !layer.tex) return;
|
||||
_bgCoverCrop(
|
||||
layer.tex,
|
||||
layer.videoEl.videoWidth || 0,
|
||||
layer.videoEl.videoHeight || 0,
|
||||
layer.cam.aspect,
|
||||
);
|
||||
};
|
||||
scene.add(mesh);
|
||||
state.crowd.layers.push(layer);
|
||||
}
|
||||
|
||||
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
|
||||
const hazeMat = new T.MeshBasicMaterial({
|
||||
color: 0x101820, transparent: true, opacity: coeffs.haze,
|
||||
@@ -3402,6 +3457,64 @@
|
||||
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
|
||||
* (coeffs.haze / VENUE_HAZE_STEADY);
|
||||
}
|
||||
if (s.crowd) {
|
||||
// Rebind VideoTextures when venue-crowd.js (re)assigns
|
||||
// elements. VideoTexture samples the element every frame,
|
||||
// so a src change on the same element needs no rebind.
|
||||
if (s.crowd.rev !== _venueCrowdRev) {
|
||||
s.crowd.rev = _venueCrowdRev;
|
||||
s.crowd.layers.forEach((layer, i) => {
|
||||
const el = _venueCrowdVideos[i];
|
||||
if (layer.videoEl === el) return;
|
||||
if (layer.tex) { layer.mat.map = null; layer.tex.dispose(); layer.tex = null; }
|
||||
layer.videoEl = el;
|
||||
layer.lastAspect = 0; // force refit + recrop
|
||||
if (el) {
|
||||
const tex = new T.VideoTexture(el);
|
||||
tex.colorSpace = T.SRGBColorSpace;
|
||||
tex.wrapS = T.ClampToEdgeWrapping;
|
||||
tex.wrapT = T.ClampToEdgeWrapping;
|
||||
tex.minFilter = T.LinearFilter;
|
||||
tex.magFilter = T.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
layer.tex = tex;
|
||||
layer.mat.map = tex;
|
||||
}
|
||||
layer.mat.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
const warm = coeffs.warmth;
|
||||
s.crowd.layers.forEach((layer, i) => {
|
||||
const el = layer.videoEl;
|
||||
// videoWidth === 0 until metadata lands — showing the
|
||||
// plane before that paints a black flash over the plate.
|
||||
const ready = !!el && el.videoWidth > 0;
|
||||
// venue-crowd.js swaps src on the same element (loop ↔
|
||||
// stinger); a new intrinsic size needs a fresh
|
||||
// cover-crop, which _bgFitBackdropPlane only reapplies
|
||||
// on camera aspect changes.
|
||||
if (ready && (layer.lastVidW !== el.videoWidth ||
|
||||
layer.lastVidH !== el.videoHeight)) {
|
||||
layer.lastVidW = el.videoWidth;
|
||||
layer.lastVidH = el.videoHeight;
|
||||
layer.applyCoverCrop();
|
||||
}
|
||||
// Layer 0 (rear) stays fully opaque whenever any of the
|
||||
// fade involves it: two half-transparent layers would
|
||||
// let the static plate behind bleed through (~25% at
|
||||
// mid-fade). The crossfade is therefore layer 1 (front)
|
||||
// fading over an opaque layer 0 — in both directions.
|
||||
const opacity = i === 0
|
||||
? (_venueCrowdMix < 0.999 ? 1 : 0)
|
||||
: _venueCrowdMix;
|
||||
layer.mat.opacity = opacity;
|
||||
layer.mesh.visible = ready && opacity > 0.01;
|
||||
if (layer.mesh.visible) {
|
||||
layer.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
|
||||
_bgFitBackdropPlane(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
teardown(s) {
|
||||
if (!s) return;
|
||||
@@ -3416,6 +3529,19 @@
|
||||
p.mat.dispose?.();
|
||||
}
|
||||
}
|
||||
// Crowd planes: this style owns the VideoTextures; the
|
||||
// <video> elements belong to venue-crowd.js and survive.
|
||||
if (s.crowd) {
|
||||
for (const layer of s.crowd.layers) {
|
||||
layer.mesh?.parent?.remove(layer.mesh);
|
||||
layer.geo?.dispose?.();
|
||||
if (layer.mat) {
|
||||
layer.mat.map = null;
|
||||
layer.mat.dispose?.();
|
||||
}
|
||||
layer.tex?.dispose?.();
|
||||
}
|
||||
}
|
||||
// Dispose the cached plate textures too — the module-level cache
|
||||
// otherwise keeps every loaded POV plate GPU-resident for the
|
||||
// page lifetime (steady VRAM growth across POV/arrangement swaps).
|
||||
|
||||
+404
-10294
File diff suppressed because it is too large
Load Diff
+161
-1655
File diff suppressed because it is too large
Load Diff
@@ -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,28 @@
|
||||
// Blob export helpers — the download idiom that used to be duplicated in
|
||||
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
|
||||
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
|
||||
// plugins reach it via dynamic import('/static/js/blob-io.js').
|
||||
|
||||
export function downloadBlob(blob, filename) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Copy an image blob to the system clipboard. Returns true on success, false
|
||||
// when the Clipboard API is unavailable or refuses (insecure context, no user
|
||||
// gesture, permission denied) — callers fall back to downloadBlob and say so.
|
||||
export async function copyImageBlob(blob) {
|
||||
try {
|
||||
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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 window.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 window.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;
|
||||
window.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;
|
||||
window.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;
|
||||
window.highway.setTime(r.to);
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
beginCount();
|
||||
});
|
||||
}
|
||||
}
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
|
||||
function beginCount() {
|
||||
const bpm = window.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 = window.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,275 @@
|
||||
// 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.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
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();
|
||||
downloadBlob(blob, filename);
|
||||
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,258 @@
|
||||
// The library's edit-song modal: open, validate, save, delete.
|
||||
//
|
||||
// Interface width ZERO — nothing in app.js calls into this cluster; app.js only needs the four
|
||||
// names on the window contract so the markup's onclick= handlers resolve. That is what makes it
|
||||
// the cleanest slice left, and it only became clean because the LIBRARY came out first (#896):
|
||||
// every dependency this modal has is now a module.
|
||||
//
|
||||
// It reads six bindings out of ./library.js (loadLibrary, loadFavorites, loadTreeView,
|
||||
// _removeLibCardsForFilename, libView, _lastLibSelected) and never writes one — checked, which
|
||||
// matters: an imported binding is READ-ONLY, so a single write would have forced a setter or a
|
||||
// container. Every use is a read, so plain imports suffice.
|
||||
//
|
||||
// Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back.
|
||||
import { _confirmDialog, _escAttr, _trapFocusInModal } from './dom.js';
|
||||
import { L } from './library-state.js';
|
||||
import {
|
||||
_lastLibSelected, _removeLibCardsForFilename, libView, loadFavorites, loadLibrary, loadTreeView,
|
||||
} from './library.js';
|
||||
|
||||
// ── Edit metadata modal ─────────────────────────────────────────────────
|
||||
export function openEditModal(songData, openerEl) {
|
||||
const artUrl = `/api/song/${encodeURIComponent(songData.f)}/art?t=${Date.now()}`;
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'edit-modal';
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
// role=dialog: assistive tech announces it as a modal; also lets
|
||||
// the global keyboard listener's `_isInsideInteractiveControl`
|
||||
// bail when typing inside the modal so Library shortcuts don't
|
||||
// hijack keys from the edit form.
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', 'Edit song metadata');
|
||||
// Record the element that triggered the modal so Esc / Cancel can
|
||||
// return focus to the exact entry the user was on, even if
|
||||
// _lastLibSelected changes before the modal closes.
|
||||
// Prefer the explicitly-passed openerEl (from the edit-btn click
|
||||
// handler, which has the exact [data-play] parent) over
|
||||
// _lastLibSelected, which may not have been updated when the
|
||||
// click's stopPropagation() prevented the card-click handler.
|
||||
const _emActive = document.querySelector('.screen.active');
|
||||
const _emLast = (_lastLibSelected && document.body.contains(_lastLibSelected)
|
||||
&& _emActive && _emActive.contains(_lastLibSelected)) ? _lastLibSelected : null;
|
||||
modal._opener = (openerEl && document.body.contains(openerEl)) ? openerEl : _emLast;
|
||||
modal.innerHTML = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-md mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Edit Song</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="relative group cursor-pointer" id="edit-art-wrapper">
|
||||
<img src="${artUrl}" alt="" class="w-20 h-20 rounded-lg object-cover bg-dark-600" id="edit-art-preview">
|
||||
<div class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
|
||||
<span class="text-white text-xs">Change</span>
|
||||
</div>
|
||||
<input type="file" accept="image/*" id="edit-art-file" class="hidden" onchange="previewEditArt(this)">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 flex-1">Click image to change album art</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Title</label>
|
||||
<input type="text" id="edit-title" value="${_escAttr(songData.t)}"
|
||||
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>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Artist</label>
|
||||
<input type="text" id="edit-artist" value="${_escAttr(songData.a)}"
|
||||
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>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Album</label>
|
||||
<input type="text" id="edit-album" value="${_escAttr(songData.al)}"
|
||||
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>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Year</label>
|
||||
<input type="text" inputmode="numeric" id="edit-year" value="${_escAttr(songData.y)}" placeholder="e.g. 2024"
|
||||
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>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button data-edit-save
|
||||
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
<button data-edit-close
|
||||
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-800">
|
||||
<button data-delete-filename="${_escAttr(songData.f)}"
|
||||
class="w-full px-4 py-2 bg-red-900/30 hover:bg-red-900/60 border border-red-900/50 hover:border-red-700 rounded-xl text-sm text-red-300 hover:text-red-100 transition">Remove from library</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Move focus into the dialog's first text input so background
|
||||
// shortcuts (and arrow nav) can't fire on the underlying library
|
||||
// entry while the edit form is open. Title is the natural primary
|
||||
// field — most edits are correcting spelling there. Caret-end
|
||||
// selection so the user can keep typing rather than overtype the
|
||||
// current value.
|
||||
const titleInput = document.getElementById('edit-title');
|
||||
if (titleInput) {
|
||||
titleInput.focus({ preventScroll: true });
|
||||
try {
|
||||
const len = titleInput.value.length;
|
||||
titleInput.setSelectionRange(len, len);
|
||||
} catch { /* some browsers reject selection on certain input types */ }
|
||||
}
|
||||
|
||||
// Trap Tab / Shift+Tab inside the modal so focus can't escape to
|
||||
// the library content underneath while the edit form is open.
|
||||
_trapFocusInModal(modal);
|
||||
|
||||
// Click on art triggers file input
|
||||
document.getElementById('edit-art-wrapper').addEventListener('click', () => {
|
||||
document.getElementById('edit-art-file').click();
|
||||
});
|
||||
|
||||
// Save — wired in JS (not an inline onclick) so the filename never has to
|
||||
// survive embedding in a single-quoted attribute string. encodeURIComponent
|
||||
// does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break
|
||||
// the inline `saveEditModal('…')` handler and silently fail the save. The
|
||||
// raw filename lives in the closure; encode it here for saveEditModal.
|
||||
const saveBtn = modal.querySelector('[data-edit-save]');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f)));
|
||||
}
|
||||
|
||||
const deleteBtn = modal.querySelector('[data-delete-filename]');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
deleteSongFromModal(deleteBtn.dataset.deleteFilename);
|
||||
});
|
||||
}
|
||||
|
||||
// Close on backdrop click or Cancel button; restore focus to opener.
|
||||
// Backdrop dismissal requires the gesture's mousedown to have STARTED on
|
||||
// the backdrop — not just the click/mouseup to land there. Otherwise a
|
||||
// click-drag that begins inside a field (e.g. selecting text) and is
|
||||
// released past the modal edge resolves its `click` target to the backdrop
|
||||
// and silently discards the edit. Cancel / ✕ (data-edit-close) always close.
|
||||
let _downOnBackdrop = false;
|
||||
modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); });
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return;
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕
|
||||
// control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH
|
||||
// the click target to be the backdrop element itself AND the gesture to have
|
||||
// started there (downOnBackdrop) — so a click-drag begun inside a field and
|
||||
// released on the backdrop does not discard the form. Pure + top-level so it's
|
||||
// unit-testable in isolation.
|
||||
export function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
|
||||
if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
|
||||
return clickTarget === modalEl && downOnBackdrop === true;
|
||||
}
|
||||
|
||||
export async function saveEditModal(encodedFilename) {
|
||||
const filename = decodeURIComponent(encodedFilename);
|
||||
|
||||
// Save metadata
|
||||
await fetch(`/api/song/${encodeURIComponent(filename)}/meta`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: document.getElementById('edit-title').value.trim(),
|
||||
artist: document.getElementById('edit-artist').value.trim(),
|
||||
album: document.getElementById('edit-album').value.trim(),
|
||||
// Year is normalised server-side (non-numeric/empty → ""), so a
|
||||
// blank or cleared field round-trips safely.
|
||||
year: document.getElementById('edit-year').value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Upload art if changed
|
||||
const fileInput = document.getElementById('edit-art-file');
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
await fetch(`/api/song/${encodeURIComponent(filename)}/art/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: e.target.result }),
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(fileInput.files[0]);
|
||||
}
|
||||
|
||||
const modal = document.getElementById('edit-modal');
|
||||
const opener = modal ? modal._opener : null;
|
||||
if (modal) modal.remove();
|
||||
// Restore focus to the entry the modal was opened from so subsequent
|
||||
// keyboard navigation resumes correctly (same as Esc / Cancel paths).
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
// Refresh current view
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
if (activeScreen?.id === 'favorites') loadFavorites();
|
||||
else loadLibrary();
|
||||
}
|
||||
|
||||
export async function deleteSongFromModal(filename) {
|
||||
const title = (document.getElementById('edit-title')?.value || filename).trim();
|
||||
const ok = await _confirmDialog({
|
||||
title: 'Remove from library?',
|
||||
body: `<p class="text-sm text-gray-300">Remove <span class="font-semibold text-white">${_escAttr(title)}</span> from your library?</p>
|
||||
<p class="text-xs text-red-400/90 mt-2">This permanently deletes the file from disk. This cannot be undone.</p>`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`/api/song/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
alert(`Delete failed: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { msg = (await resp.json()).error || msg; } catch (_) {}
|
||||
alert(`Delete failed: ${msg}`);
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('edit-modal');
|
||||
if (modal) modal.remove();
|
||||
L.treeStats = null;
|
||||
L.favTreeStats = null;
|
||||
L.tuningNames = null;
|
||||
|
||||
// Remove the deleted song's card from any currently-rendered grid/tree
|
||||
// so the user sees it disappear without waiting for a refetch. A full
|
||||
// loadLibrary() here would re-call loadGridPage(currentPage), which
|
||||
// uses 'append' mode when currentPage > 0 and re-appends the same
|
||||
// (now-shortened) page on top of what's already rendered — leaving
|
||||
// the deleted card visible. Direct DOM removal also preserves scroll
|
||||
// position, which a refetch from page 0 would lose.
|
||||
_removeLibCardsForFilename(filename);
|
||||
|
||||
// Tree views group by artist with song counts; a single card removal
|
||||
// leaves stale counts, so refresh the tree for whichever screen we're
|
||||
// looking at (each tree-view renderer replaces innerHTML cleanly).
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
if (activeScreen?.id === 'favorites') {
|
||||
// loadFavorites() routes to either loadFavGridPage (always
|
||||
// 'replace') or loadFavTreeView — both safe for a single delete.
|
||||
loadFavorites();
|
||||
} else if (libView === 'tree') {
|
||||
loadTreeView();
|
||||
}
|
||||
// Main library grid view: DOM removal above is sufficient.
|
||||
}
|
||||
@@ -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 window.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,190 @@
|
||||
// highway.js's immutable constants: geometry, colour tables, timing budgets, and the
|
||||
// load-adaptive render-scale thresholds.
|
||||
//
|
||||
// WHY THESE — AND ONLY THESE — MAY LIVE AT MODULE SCOPE
|
||||
//
|
||||
// createHighway() is a FACTORY, not a singleton. The constitution publishes
|
||||
// window.createHighway precisely so a plugin can build a SECOND highway for its own panel,
|
||||
// and highway.js says so at the top of the closure:
|
||||
//
|
||||
// // R3c: per-instance mutable state in one object, so extracted renderer/ws
|
||||
// // modules can close over it as a factory arg without cross-panel sharing.
|
||||
//
|
||||
// So MUTABLE state (hwState) must never become a module-level singleton — two highways would
|
||||
// silently share it. That is the opposite of the app.js carve, where a single state container
|
||||
// was right because there is exactly one app.
|
||||
//
|
||||
// These 29 are pure literals: frozen numbers, strings and colour tables, never reassigned and
|
||||
// never mutated. Sharing them across instances is not just safe, it is what you want — one
|
||||
// copy of the shimmer LUT bounds and the string palettes rather than one per panel.
|
||||
//
|
||||
// Anything with a runtime dependency (document, window, performance, localStorage) stays in
|
||||
// the factory. Checked: none of these has one.
|
||||
|
||||
// Cap the interpolation so a stalled main thread (long task, GC,
|
||||
// dropped tick) can't make getTime drift far past reality. Also the
|
||||
// threshold for "audio looks paused" — if setTime hasn't advanced t
|
||||
// in this long, treat as paused.
|
||||
export const _CHART_MAX_INTERP_MS = 100;
|
||||
|
||||
// Throttled DOM visibility sampling. Reading canvas.offsetParent
|
||||
// every rAF frame forces a style/layout recalc — profiled at ~0.5 s
|
||||
// main-thread self-time over a 63 s session. The displayed state
|
||||
// changes rarely (navigate / splitscreen panel toggle), so the DOM
|
||||
// is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached
|
||||
// value serves the frames in between (worst-case transition latency
|
||||
// ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal).
|
||||
// Set _domVisSampledFrame to NaN to force a fresh sample on the next
|
||||
// check (done on init, canvas replace, resize, and override-clear so
|
||||
// deliberate transitions don't wait out the throttle window).
|
||||
// NOTE those manual resets are LATENCY optimizations, not correctness
|
||||
// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES
|
||||
// frames regardless, so a visibility-affecting path that forgets to
|
||||
// reset self-heals within ~10 frames — stale visibility can never be
|
||||
// served indefinitely.
|
||||
export const _DOM_VIS_CHECK_FRAMES = 10;
|
||||
|
||||
// Paused-render throttle (feedBack#654). The rAF loop runs
|
||||
// unconditionally and only gates on visibility + ready, never on
|
||||
// playback — so an expensive renderer (3D Highway's Three.js WebGL
|
||||
// scene) does a full render every frame even while paused. That is
|
||||
// pure waste, and the dominant cost on high-refresh / ANGLE setups
|
||||
// (Chromium on Windows paces rAF to the fastest attached monitor,
|
||||
// so the loop can run at 144 Hz even on a 60 Hz panel). While the
|
||||
// audio clock is stalled, cap draws to one per
|
||||
// _PAUSED_FRAME_INTERVAL_MS. Note position is clock-derived
|
||||
// (n.t - currentTime), so this changes smoothness only — never
|
||||
// audio/visual sync. A low non-zero rate (not a hard skip) keeps
|
||||
// resize / seek-scrub / renderer-swap repaints correct without
|
||||
// having to hook each of those paths.
|
||||
export const _PAUSED_FRAME_INTERVAL_MS = 100;
|
||||
|
||||
export const _DRAW_BUDGET_HI_MS = 12;
|
||||
|
||||
export const _DRAW_BUDGET_LO_MS = 7;
|
||||
|
||||
export const _AUTO_SCALE_MIN = 0.25;
|
||||
|
||||
export const _AUTO_ADJUST_COOLDOWN_MS = 600;
|
||||
|
||||
// Upscaling is deliberately LAZY (longer cooldown than the downscale path) so
|
||||
// the resolution doesn't visibly hunt up/down on passages that hover near the
|
||||
// budget — testers saw "quality going up and down" as parts got busier (#618
|
||||
// charrette). Downscale stays prompt to protect the frame rate.
|
||||
export const _AUTO_UPSCALE_COOLDOWN_MS = 2500;
|
||||
|
||||
// 64-entry precomputed jitter LUT replacing Math.random() in the
|
||||
// lit-sustain shimmer hot path (drawSustains). Visually
|
||||
// indistinguishable from per-frame Math.random at rAF cadence,
|
||||
// allocation-free, and removes 4 RNG calls per visible lit sustain
|
||||
// per frame on dense charts. Seeded deterministically (xorshift32)
|
||||
// so the LUT itself is identical across `createHighway()` instances
|
||||
// — shimmer is therefore reload-stable and test-reproducible PER
|
||||
// instance for a given (frameIdx, n.s, n.t) seed. The seed includes
|
||||
// closure-scope `_frameIdx` which is per-instance, so two
|
||||
// splitscreen highways with different rAF cadence will shimmer
|
||||
// differently at any given wall-clock moment; what's stable is the
|
||||
// LUT contents.
|
||||
//
|
||||
// _SHIMMER_LUT_SIZE MUST stay a power of two — `_shimmerNoise`
|
||||
// indexes with `& (_SHIMMER_LUT_SIZE - 1)` for the cheap modulo.
|
||||
export const _SHIMMER_LUT_SIZE = 64;
|
||||
|
||||
// Memoize ctx.measureText() for the lyric overlay. Per-syllable
|
||||
// measurement was the dominant cost in dense karaoke charts; text
|
||||
// and fontSize are the only inputs (font face string is constant
|
||||
// `bold ${fontSize}px sans-serif`). Two-level Map (outer: fontSize,
|
||||
// inner: text) so a cache hit avoids the `fontSize + '|' + text`
|
||||
// concat that previously allocated on every lookup.
|
||||
//
|
||||
// Bounded on BOTH levels: window resizes change `fontSize`, so each
|
||||
// resize creates a fresh inner Map; without an outer cap, the cache
|
||||
// would retain every fontSize ever rendered for the page lifetime.
|
||||
// Cap outer at 16 distinct fontSize buckets (more than enough — a
|
||||
// session typically sees one or two), inner at 4096 entries per
|
||||
// bucket. Clear-on-overflow on both — a karaoke cold start re-warms
|
||||
// in one frame.
|
||||
export const _LYRIC_MEASURE_OUTER_MAX = 16;
|
||||
|
||||
export const _LYRIC_MEASURE_INNER_MAX = 4096;
|
||||
|
||||
// Rendering config
|
||||
export const VISIBLE_SECONDS = 3.0;
|
||||
|
||||
export const Z_CAM = 2.2;
|
||||
|
||||
export const Z_MAX = 10.0;
|
||||
|
||||
export const BG = '#080810';
|
||||
|
||||
// String color palettes. Indices 0–5 cover guitar / bass; 6–7
|
||||
// are added for extended-range GP imports (7-string, 8-string).
|
||||
// Lookups still use `|| '#888'` as a safety fallback for any
|
||||
// out-of-range index.
|
||||
//
|
||||
// These are `let`, not `const`: setStringColors() (used by the core
|
||||
// "Highway String Colors" theming UI) overrides per-index entries at
|
||||
// runtime, deriving the dim/bright variants from the chosen base color.
|
||||
// DEFAULT_* keep the originals so a reset restores them byte-for-byte.
|
||||
export const DEFAULT_STRING_COLORS = [
|
||||
'#cc0000', '#cca800', '#0066cc',
|
||||
'#cc6600', '#00cc66', '#9900cc',
|
||||
'#cc00aa', '#00cccc', // 7th = magenta, 8th = teal
|
||||
];
|
||||
|
||||
export const DEFAULT_STRING_DIM = [
|
||||
'#520000', '#524200', '#002952',
|
||||
'#522900', '#005229', '#3d0052',
|
||||
'#520042', '#005252',
|
||||
];
|
||||
|
||||
export const DEFAULT_STRING_BRIGHT = [
|
||||
'#ff3c3c', '#ffe040', '#3c9cff',
|
||||
'#ff9c3c', '#3cff9c', '#cc3cff',
|
||||
'#ff3ce0', '#3ce0e0',
|
||||
];
|
||||
|
||||
export const MAX_RENDERER_DRAW_FAILURES = 3;
|
||||
|
||||
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
|
||||
//
|
||||
// Charts often repeat the same chord shape several times in a
|
||||
// row (e.g. a G strummed 4 times). We call a contiguous run of same-id
|
||||
// chords with gaps < CHAIN_GAP_THRESHOLD a "chain". Chains drive two
|
||||
// visual choices:
|
||||
// • The first chord in a chain renders in full; subsequent chords in
|
||||
// a chain of CHAIN_RENDER_FULL_MAX or longer render as a "repeat
|
||||
// box" — a translucent boxed frame so the eye can see the rhythm
|
||||
// pattern without re-scanning identical fret numbers.
|
||||
// • Each chord anchors a CHORD_FRAME_FRETS-wide frame; muted and
|
||||
// open-only chords inherit the frame from their predecessor so
|
||||
// they don't snap to fret 0.
|
||||
//
|
||||
// We compute chain stats and frame anchors once per `src` array via
|
||||
// _ensureChordRenderCache (lazy, invalidates when the array reference
|
||||
// changes — which happens on chord ingest, mastery rebuild, or song
|
||||
// reset). The render path is then pure read.
|
||||
export const CHAIN_GAP_THRESHOLD = 0.5;
|
||||
|
||||
export const CHAIN_RENDER_FULL_MAX = 4;
|
||||
|
||||
export const CHORD_FRAME_FRETS = 4;
|
||||
|
||||
// Fretline preview: the static fret line at the bottom shows the chord
|
||||
// closest to the strum line (currentTime + FRETLINE_TARGET_OFFSET) within
|
||||
// the [target - FRETLINE_WINDOW_BEFORE, target + FRETLINE_WINDOW_AFTER]
|
||||
// window, as a teaching aid.
|
||||
export const FRETLINE_TARGET_OFFSET = -0.25;
|
||||
|
||||
export const FRETLINE_WINDOW_BEFORE = 0.1;
|
||||
|
||||
export const FRETLINE_WINDOW_AFTER = 0.3;
|
||||
|
||||
// Repeat / mute box colors.
|
||||
export const REPEAT_BOX_FILL = 'rgba(48, 80, 128, 0.06)';
|
||||
|
||||
export const REPEAT_BOX_BAR = '#50a0dc';
|
||||
|
||||
export const MUTE_BOX_STROKE = '#6060809b';
|
||||
|
||||
export const MUTE_BOX_BAR = '#606080d1';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
// highway.js's PURE geometry + label primitives.
|
||||
//
|
||||
// Every function here is a pure function of its arguments. None of them touches hwState, and
|
||||
// none closes over the canvas context — roundRect() already took `ctx` explicitly, and the
|
||||
// rest need nothing but numbers. project() reads only the module-level constants from
|
||||
// ./highway-constants.js.
|
||||
//
|
||||
// THAT PURITY IS WHY THIS SLICE IS SAFE, and why it is the one to do first. createHighway() is
|
||||
// a FACTORY — a plugin can build a second highway for its own panel — so anything holding
|
||||
// per-instance state (hwState) must be passed it as an argument rather than importing it, or
|
||||
// two panels silently share one clock and palette. These six hold no state at all, so they
|
||||
// move VERBATIM: not one call site changes.
|
||||
//
|
||||
// The primitives that DO need hwState (fretX, fillTextReadable, _noteState, _paintGemGlow)
|
||||
// are deliberately left behind. They need an explicit hwState parameter threaded through 53
|
||||
// call sites, which is a real change and belongs in its own commit, not smuggled in beside a
|
||||
// provably-identical move.
|
||||
import { VISIBLE_SECONDS, Z_CAM, Z_MAX, _SHIMMER_LUT_SIZE } from './highway-constants.js';
|
||||
|
||||
// ── Projection ───────────────────────────────────────────────────────
|
||||
export function project(tOffset) {
|
||||
if (tOffset > VISIBLE_SECONDS || tOffset < -0.05) return null;
|
||||
if (tOffset < 0) return { y: 0.82 + Math.abs(tOffset) * 0.3, scale: 1.0 };
|
||||
|
||||
const z = tOffset * (Z_MAX / VISIBLE_SECONDS);
|
||||
const denom = z + Z_CAM;
|
||||
if (denom < 0.01) return null;
|
||||
const scale = Z_CAM / denom;
|
||||
const y = 0.82 + (0.08 - 0.82) * (1.0 - scale);
|
||||
return { y, scale };
|
||||
}
|
||||
|
||||
export function bnvNormalizedPoints(bnv, sus) {
|
||||
if (!Array.isArray(bnv) || bnv.length === 0) return [];
|
||||
// Map each point's time over the NOTE's span [0, sus] so it sits at its
|
||||
// real fraction of the note (a bend that completes before the note ends
|
||||
// draws short of the glyph's right edge). Fall back to the curve's own
|
||||
// t-range only when the note has no usable sustain.
|
||||
if (Number.isFinite(sus) && sus > 0) {
|
||||
return bnv.map(p => ({ x: Math.min(Math.max(p.t / sus, 0), 1), v: p.v }));
|
||||
}
|
||||
const t0 = bnv[0].t;
|
||||
const span = bnv[bnv.length - 1].t - t0;
|
||||
return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v }));
|
||||
}
|
||||
|
||||
export function teachingFingerLabel(fg) {
|
||||
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
|
||||
return fg === 0 ? 'T' : String(fg);
|
||||
}
|
||||
|
||||
export function teachingDegreeLabel(sd) {
|
||||
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
|
||||
return String(sd);
|
||||
}
|
||||
|
||||
export function chordHarmonyLabels(fn, voicing, caged, guideTones) {
|
||||
const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : '';
|
||||
const vc = (typeof voicing === 'string') ? voicing.trim() : '';
|
||||
const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim()))
|
||||
? 'CAGED: ' + caged.trim() : '';
|
||||
const gt = Array.isArray(guideTones)
|
||||
? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : [];
|
||||
return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' };
|
||||
}
|
||||
|
||||
export function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
|
||||
// ── The shimmer noise LUT ───────────────────────────────────────────────────────
|
||||
//
|
||||
// A DETERMINISTIC xorshift table: no randomness, no state, byte-for-byte identical for every
|
||||
// highway instance. Unlike the three per-instance caches that came out of the drawing layer (a
|
||||
// warn-once Set, a chord WeakMap, a lyric-width Map — all MUTATED, all lifted onto hwState so
|
||||
// two panels cannot stomp each other), this one is not merely SAFE to share but BETTER shared:
|
||||
// built once for the page instead of once per panel.
|
||||
//
|
||||
// MUTABILITY, NOT LOCATION, IS WHAT DECIDES WHERE A THING BELONGS.
|
||||
const _shimmerLut = new Float32Array(_SHIMMER_LUT_SIZE);
|
||||
for (let i = 0; i < _SHIMMER_LUT_SIZE; i++) {
|
||||
let x = (i + 1) | 0; // +1 dodges the all-zero xorshift trap
|
||||
x ^= x << 13;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5;
|
||||
_shimmerLut[i] = (x >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
export function _shimmerNoise(seed) {
|
||||
// Mask works only because _SHIMMER_LUT_SIZE is a power of two.
|
||||
return _shimmerLut[(seed >>> 0) & (_SHIMMER_LUT_SIZE - 1)];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// highway.js's STATEFUL primitives: the four shared helpers that need per-instance state.
|
||||
//
|
||||
// ━━━ hwState IS A PARAMETER, NOT AN IMPORT. THIS IS THE WHOLE DESIGN. ━━━
|
||||
//
|
||||
// createHighway() is a FACTORY. The constitution publishes window.createHighway so a plugin can
|
||||
// build a SECOND highway for its own panel, and highway.js says so itself:
|
||||
//
|
||||
// // R3c: per-instance mutable state in one object, so extracted renderer/ws
|
||||
// // modules can close over it as a factory arg without cross-panel sharing.
|
||||
//
|
||||
// Import hwState as a module singleton and the two panels silently share one clock, one render
|
||||
// scale, one string palette — each driving the other. Nothing would throw. The picture would
|
||||
// just be wrong, in a way no test would catch.
|
||||
//
|
||||
// So every function here takes hwState as its FIRST ARGUMENT. It reads a little worse at the
|
||||
// call site and it is the only correct shape.
|
||||
//
|
||||
// (This is the exact opposite of the app.js carve, where player-state.js and library-state.js
|
||||
// ARE module singletons — correctly, because there is exactly one app. Same epic, same
|
||||
// language, opposite answer, decided entirely by whether the thing is a factory.)
|
||||
//
|
||||
// The PURE primitives — project, roundRect, and the label helpers — need none of this and live
|
||||
// in ./highway-geometry.js.
|
||||
// No imports. These four need nothing but the hwState they are handed and their arguments.
|
||||
|
||||
export function fretX(hwState, fret, scale, w) {
|
||||
const hw = w * 0.52 * scale;
|
||||
const margin = hw * 0.06;
|
||||
const usable = hw * 2 - 2 * margin;
|
||||
const t = fret / Math.max(1, hwState.displayMaxFret);
|
||||
return w / 2 - hw + margin + t * usable;
|
||||
}
|
||||
|
||||
export function fillTextReadable(hwState, text, x, y) {
|
||||
// ctx may be null when the 2D context was never acquired
|
||||
// (canvas already locked to WebGL). No-op in that case —
|
||||
// alternatives would be throwing, which breaks plugin hooks
|
||||
// that call this after a context-type mismatch.
|
||||
if (!hwState.canvas || !hwState.ctx) return;
|
||||
const W = hwState.canvas.width;
|
||||
if (!hwState._lefty) {
|
||||
hwState.ctx.fillText(text, x, y);
|
||||
return;
|
||||
}
|
||||
hwState.ctx.save();
|
||||
hwState.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
hwState.ctx.fillText(text, W - x, y);
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
|
||||
// ── Per-note judgment state (feedBack#254) ──────────────────────────
|
||||
// Resolves the registered provider for one chart note. Returns null
|
||||
// when no provider is set, the provider throws, it reports nothing,
|
||||
// or the reported alpha is non-positive. Otherwise a normalized
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
// 'hit' and 'active' are both "lit" — renderers may treat them the
|
||||
// same; the distinction (struck note vs currently-held sustain) is
|
||||
// there for renderers that want it. The provider owns all timing /
|
||||
// fade — `alpha` is whatever intensity it wants right now.
|
||||
export function _noteState(hwState, note, chartTime) {
|
||||
if (!hwState._noteStateProvider) return null;
|
||||
let raw;
|
||||
try { raw = hwState._noteStateProvider(note, chartTime); } catch (e) { return null; }
|
||||
if (!raw) return null;
|
||||
const state = typeof raw === 'string' ? raw : raw.state;
|
||||
if (state !== 'hit' && state !== 'active' && state !== 'miss') return null;
|
||||
const alpha = (raw && typeof raw === 'object' && Number.isFinite(raw.alpha))
|
||||
? Math.max(0, Math.min(1, raw.alpha))
|
||||
: 1;
|
||||
if (alpha <= 0) return null;
|
||||
const color = (raw && typeof raw === 'object' && typeof raw.color === 'string') ? raw.color : null;
|
||||
// Pass through the provider's `live` flag: note_detect tags its
|
||||
// ring-tracking 'active' responses with live:true so a renderer can
|
||||
// treat them as authoritative (extinguish on mute, relight on
|
||||
// re-strike) instead of latching them for the whole chart sustain.
|
||||
// Renderers that don't care simply ignore it.
|
||||
const live = (raw && typeof raw === 'object' && raw.live === true);
|
||||
return { state, alpha, color, live };
|
||||
}
|
||||
|
||||
// Paints the judgment effect on top of an already-drawn gem at
|
||||
// (cx,cy) with half-extent `r`. `ns` is the normalized state from
|
||||
// _noteState (or null → no-op). A miss → faint red wash. A correct
|
||||
// hit / held sustain → a "sizzle": throbbing additive halo + a
|
||||
// flickering white-hot core + crackling spark lines re-randomised
|
||||
// each frame + (for a fresh struck note that's fading) an expanding
|
||||
// shockwave ring. Intensity scales with `ns.alpha`, so a struck
|
||||
// note flares and dies while a held sustain crackles continuously.
|
||||
// Caller draws the gem normally first, then calls this BEFORE any
|
||||
// glyph so a readable fret number can land on top.
|
||||
export function _paintGemGlow(hwState, cx, cy, r, stringIdx, ns) {
|
||||
if (!ns || !hwState.ctx) return;
|
||||
hwState.ctx.save();
|
||||
if (ns.state === 'miss') {
|
||||
hwState.ctx.globalAlpha = 0.4 * ns.alpha;
|
||||
hwState.ctx.fillStyle = '#ff2828';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 1.05, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
hwState.ctx.restore();
|
||||
return;
|
||||
}
|
||||
const col = ns.color || hwState.STRING_BRIGHT[stringIdx] || '#ffffff';
|
||||
const a = ns.alpha;
|
||||
const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
||||
hwState.ctx.lineCap = 'round';
|
||||
|
||||
// Expanding shockwave — only on a fresh struck-and-fading hit
|
||||
// (alpha decays 1→0). 'active' (held sustain, alpha pinned 1) skips it.
|
||||
if (ns.state === 'hit' && a < 1) {
|
||||
const prog = 1 - a; // 0 at strike → 1 at fade-out
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * 0.85;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(1.5, r * 0.26 * a);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (1.0 + prog * 2.7), 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Throbbing halo (≈9 Hz wobble).
|
||||
const pulse = 0.8 + 0.2 * Math.sin(nowMs / 18);
|
||||
const haloR = r * 2.0 * pulse;
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
const g = hwState.ctx.createRadialGradient(cx, cy, 0, cx, cy, haloR);
|
||||
g.addColorStop(0, '#ffffff');
|
||||
g.addColorStop(0.30, col);
|
||||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
hwState.ctx.fillStyle = g;
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crackle — short bright spark lines flicking out from the gem,
|
||||
// re-randomised every frame so it shimmers.
|
||||
const sparkCount = 6;
|
||||
for (let i = 0; i < sparkCount; i++) {
|
||||
if (Math.random() > 0.55 * a + 0.2) continue; // intermittent
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const inR = r * 0.45;
|
||||
const len = r * (0.7 + Math.random() * 1.6) * (0.5 + 0.5 * a);
|
||||
hwState.ctx.globalAlpha = a * (0.45 + Math.random() * 0.55);
|
||||
hwState.ctx.strokeStyle = Math.random() < 0.5 ? '#ffffff' : col;
|
||||
hwState.ctx.lineWidth = Math.max(1, r * (0.08 + Math.random() * 0.08));
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.moveTo(cx + Math.cos(ang) * inR, cy + Math.sin(ang) * inR);
|
||||
hwState.ctx.lineTo(cx + Math.cos(ang) * (inR + len), cy + Math.sin(ang) * (inR + len));
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Flickering white-hot core.
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * (0.55 + Math.random() * 0.45);
|
||||
hwState.ctx.fillStyle = '#ffffff';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (0.30 + Math.random() * 0.14), 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crisp bright rim.
|
||||
hwState.ctx.globalCompositeOperation = 'source-over';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(2, r * 0.2);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 0.95, 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
@@ -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().`);
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. window.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;
|
||||
window.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
|
||||
// window.highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
export function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!host.currentFilename()) return;
|
||||
const si = (window.highway && typeof window.highway.getSongInfo === 'function')
|
||||
? (window.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,748 @@
|
||||
//
|
||||
// ━━━ THIS WAS THE UNCUTTABLE HEART, AND IT IS 359 LINES ━━━
|
||||
//
|
||||
// At the start of the app.js carve, seeding a dependency closure from count-in, from loops, from
|
||||
// section-practice or from the JUCE seek shim all returned the SAME 178-function, 3,360-line
|
||||
// set. playSong and showScreen called each other; everything called them; nothing could be cut
|
||||
// anywhere. The conclusion — correct at the time — was that no closure-based carve could touch
|
||||
// it at any seed, and the answer was a HOST SEAM.
|
||||
//
|
||||
// That was true THEN. It is not true now. Every slice taken out since (transport, loops,
|
||||
// count-in, section-practice, the library, the edit modal, settings) removed edges, and the
|
||||
// strongly-connected component DISSOLVED. This closure is 36 declarations with an interface
|
||||
// width of FOUR.
|
||||
//
|
||||
// The lesson is not that the seam was wrong. The seam is what MADE this possible: it let the
|
||||
// carves proceed against a cyclic core instead of stalling on it. The lesson is to RE-MEASURE.
|
||||
// An SCC is a fact about a graph at a moment, not a property of the code.
|
||||
//
|
||||
// ━━━ THE GATE STATEMENTS AT THE BOTTOM, AND WHY NO SCAN FOUND THEM ━━━
|
||||
//
|
||||
// window.feedBack.holdAutoplay / holdAutoExit and their two event handlers are TOP-LEVEL
|
||||
// STATEMENTS, not declarations. They WRITE this module's state (_autoplayHeld, _autoExitTimer,
|
||||
// …), and an imported binding is READ-ONLY — so left behind in app.js, every one of them threw
|
||||
// "Assignment to constant variable" the instant this module existed.
|
||||
//
|
||||
// A dependency scan that walks DECLARATIONS cannot see them. Only the browser A/B did. It is the
|
||||
// same blind spot that nearly shipped a dead library A-Z rail (#896): app.js keeps its public
|
||||
// API in top-level statements, and those are invisible to a call-graph.
|
||||
//
|
||||
// ━━━ ZERO OUTSIDE WRITES, BY MOVING THE BOUNDARY RATHER THAN BUILDING MACHINERY ━━━
|
||||
//
|
||||
// Autoplay scalars and the wake-lock state were written from outside — which would have forced a
|
||||
// setter or a container. But the writers (_releaseAutoplay, _acquireWakeLock) plainly belong
|
||||
// here. Pulling them in left ZERO outside writes, so every export is a plain import. Same move as
|
||||
// settings (#920): measure the writers before you reach for a container.
|
||||
|
||||
import {
|
||||
loadSettings,
|
||||
} from './settings.js';
|
||||
import {
|
||||
clearLoop,
|
||||
loadSavedLoops,
|
||||
} from './loops.js';
|
||||
import {
|
||||
audio,
|
||||
} from './audio-el.js';
|
||||
import {
|
||||
_snapshotResumeSession,
|
||||
} from './resume-session.js';
|
||||
import {
|
||||
_resetJuceAudioShimChain,
|
||||
} from './juce-audio.js';
|
||||
import {
|
||||
_hideSectionPracticeBar,
|
||||
_resetSectionPracticeLog,
|
||||
_scheduleSectionPracticeRetries,
|
||||
} from './section-practice.js';
|
||||
import {
|
||||
_cancelCountIn,
|
||||
armCreditsHideOnPlay,
|
||||
hideSongCreditsOverlay,
|
||||
holdCreditsThen,
|
||||
scheduleCreditsHide,
|
||||
showSongCreditsOverlay,
|
||||
startSongCountIn,
|
||||
} from './count-in.js';
|
||||
import {
|
||||
_autoplayExitEnabled,
|
||||
_countdownBeforeSongEnabled,
|
||||
_resetPlaybackSpeedForNewSong,
|
||||
} from './player-controls.js';
|
||||
import {
|
||||
_audioTime,
|
||||
_resetAudioSeekState,
|
||||
_songEventPayload,
|
||||
jucePlayer,
|
||||
setPlayButtonState,
|
||||
togglePlay,
|
||||
} from './transport.js';
|
||||
import {
|
||||
_activeLibraryProviderId,
|
||||
_bumpLibNavGeneration,
|
||||
_getArrangementNamingMode,
|
||||
_libScrollOnNextRender,
|
||||
_resetLibraryProviderViewState,
|
||||
loadFavorites,
|
||||
loadLibrary,
|
||||
loadLibraryProviders,
|
||||
stopInfiniteScroll,
|
||||
} from './library.js';
|
||||
import {
|
||||
S,
|
||||
} from './player-state.js';
|
||||
import {
|
||||
L,
|
||||
} from './library-state.js';
|
||||
// Tracks which list screen launched the player so Esc-from-player
|
||||
// returns the user to that screen instead of always defaulting to
|
||||
// the Library (feedBack#126). Reset on every `playSong` call so a
|
||||
// song launched from a deep-link / plugin screen still gets a sane
|
||||
// fallback ('home').
|
||||
export let _playerOriginScreen = 'home';
|
||||
|
||||
export let _settingsOriginScreen = 'home';
|
||||
|
||||
// ── Screen Navigation ─────────────────────────────────────────────────────
|
||||
export async function showScreen(id) {
|
||||
// ── 'home' is the LEGACY library screen. Always route it to the v3 Songs list. ──
|
||||
//
|
||||
// The v3 shell replaced #home with #v3-songs. That mapping DID exist — but only inside
|
||||
// wrappers on `window.showScreen`, and only for callers that go through `window`:
|
||||
//
|
||||
// app.js publishes the raw fn -> shell.js wraps it (adding the mapping)
|
||||
// -> the stems plugin wraps it AGAIN, capturing whatever
|
||||
// happened to be there at the time
|
||||
//
|
||||
// Two ways that fails, and testers hit both:
|
||||
//
|
||||
// 1. ORDER. Three independent parties monkey-patch window.showScreen, each capturing the
|
||||
// current value. Plugins load ASYNCHRONOUSLY, so the chain links up in whatever order
|
||||
// the race settles — and any capture taken before shell.js installs, or any
|
||||
// re-assignment after it, silently drops the mapping.
|
||||
//
|
||||
// 2. THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
|
||||
// Esc-from-settings shortcut call the IMPORTED showScreen directly, so no wrapper ever
|
||||
// sees them. Verified in a browser: the unwrapped function with 'home' lands on the dead
|
||||
// legacy screen every single time.
|
||||
//
|
||||
// Hence "randomly, when moving to the library from another menu option" — and "never when a
|
||||
// song ends", because closeCurrentSong resolves its target through _resolvePlayerOrigin(),
|
||||
// which already applies this mapping.
|
||||
//
|
||||
// So it lives HERE now: ONE guard in the function every caller routes through, rather than a
|
||||
// chain of monkey-patches that must each remember.
|
||||
//
|
||||
// ONLY 'home'. NOT 'v3-home'. _resolvePlayerOrigin() maps BOTH — correctly, because it
|
||||
// computes where to RETURN TO after a song, and coming back to the Songs list from the
|
||||
// dashboard is the right behaviour. Copying that condition here was a [P1] (Codex caught it):
|
||||
// #v3-home is the v3 DASHBOARD, a real screen the shell's Home nav, the onboarding tour and
|
||||
// the dashboard re-render listener all target. Redirecting it would make Home unreachable.
|
||||
//
|
||||
// A legacy alias is not the same thing as a return target.
|
||||
if (id === 'home' && document.getElementById('v3-songs')) {
|
||||
id = 'v3-songs';
|
||||
}
|
||||
|
||||
// Capture the previous screen before changing active classes
|
||||
const prevScreenId = document.querySelector('.screen.active')?.id;
|
||||
|
||||
// ── screen:changing — emitted BEFORE any of the work below ──────────────────
|
||||
//
|
||||
// Timing matters here, and Codex caught me getting it wrong. The stems plugin used to
|
||||
// monkey-patch window.showScreen so it could tear down its audio graph BEFORE navigation
|
||||
// began. screen:changed fires at the very END of this function — after awaiting library and
|
||||
// provider loads — so moving that plugin onto it would have delayed teardown behind a slow
|
||||
// fetch, or skipped it entirely if the fetch threw. Stems would keep playing on a non-player
|
||||
// screen.
|
||||
//
|
||||
// So there are two events, and the distinction is the whole point:
|
||||
// screen:changing — before anything happens. "I am leaving `from`." Cancel/teardown here.
|
||||
// screen:changed — after the DOM and data are settled. "I am on `id`."
|
||||
if (window.feedBack) window.feedBack.emit('screen:changing', { id, from: prevScreenId || null });
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
// Mark the next render as a screen-entry so it scrolls the
|
||||
// restored selection into view exactly once. Routine renders
|
||||
// (search / sort / filter typing) won't have this flag set and
|
||||
// so won't yank the viewport. Also bump the nav-items
|
||||
// generation so the next keypress doesn't reuse a cache built
|
||||
// against a now-hidden screen's container.
|
||||
_bumpLibNavGeneration();
|
||||
if (id === 'home') {
|
||||
_libScrollOnNextRender.home = true;
|
||||
const beforeProviderId = _activeLibraryProviderId();
|
||||
await loadLibraryProviders({ restoreSaved: true });
|
||||
if (_activeLibraryProviderId() !== beforeProviderId) {
|
||||
_resetLibraryProviderViewState();
|
||||
} else {
|
||||
L.libEpoch++;
|
||||
L.currentPage = 0;
|
||||
L.treeStats = null;
|
||||
stopInfiniteScroll();
|
||||
}
|
||||
loadLibrary(0);
|
||||
}
|
||||
if (id === 'favorites') { _libScrollOnNextRender.favorites = true; loadFavorites(); }
|
||||
if (id === 'settings') {
|
||||
// Record where we came from so Esc can go back. The player screen
|
||||
// is torn down by the `id !== 'player'` branch below, so
|
||||
// re-entering it via showScreen() would land on a dead screen —
|
||||
// fall back to the player's own origin (or 'home') instead.
|
||||
if (prevScreenId && prevScreenId !== 'settings') {
|
||||
_settingsOriginScreen = prevScreenId === 'player'
|
||||
? (_playerOriginScreen || 'home')
|
||||
: prevScreenId;
|
||||
}
|
||||
loadSettings();
|
||||
}
|
||||
if (id !== 'player') {
|
||||
const audio = document.getElementById('audio');
|
||||
const stopTime = _audioTime();
|
||||
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying;
|
||||
// Snapshot where we were so leaving the player — especially by accident
|
||||
// — is recoverable instead of dumping the user back at bar 1 next time.
|
||||
// Must run BEFORE window.highway.stop()/audio unload, while getSongInfo() and
|
||||
// the position (stopTime) are still live.
|
||||
if (hadPlayableSong) _snapshotResumeSession(stopTime);
|
||||
window.highway.stop();
|
||||
// Cancel any queued seeks, in-flight shim closures, AND active
|
||||
// count-in timers before stopping playback so none of these paths
|
||||
// can mutate the torn-down session (mirrors the same triple reset
|
||||
// in playSong()).
|
||||
_cancelCountIn();
|
||||
_resetJuceAudioShimChain();
|
||||
_resetAudioSeekState();
|
||||
if (window._juceMode) {
|
||||
// HTML5 emits 'pause' via the media-element listener below;
|
||||
// JUCE doesn't, so plugins would stay stuck in "playing".
|
||||
// Snapshot the canonical payload BEFORE stop() resets _pos
|
||||
// to 0, then emit AFTER stop completes. Mirrors the HTML5
|
||||
// pause contract via _songEventPayload (audioT/chartT/perfNow).
|
||||
const payload = _songEventPayload();
|
||||
const wasPlaying = S.isPlaying;
|
||||
await jucePlayer.stop().catch(() => {});
|
||||
if (wasPlaying && window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', payload);
|
||||
}
|
||||
window._juceMode = false;
|
||||
window._juceAudioUrl = null;
|
||||
}
|
||||
if (hadPlayableSong) window.feedBack.emit('song:stop', { time: stopTime || 0, screen: id });
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
window._currentSongAudio = null;
|
||||
// Reloading any song later should get a fresh JUCE routing attempt.
|
||||
window._clearJuceRerouteMemo?.();
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
// `from` is the screen we just LEFT. Without it, "I am leaving the player" is not
|
||||
// expressible from an event, and the only way to express it was to WRAP window.showScreen —
|
||||
// which is what shell.js and the stems plugin both did, and why the library intermittently
|
||||
// showed the legacy screen (#923, #924): three parties patching one global, each capturing
|
||||
// whatever was there at the time, in whatever order the plugin loads settled.
|
||||
//
|
||||
// Additive: every existing listener (app.js, audio-mixer.js, tour-engine.js) reads `id` and
|
||||
// is unaffected.
|
||||
if (window.feedBack) window.feedBack.emit('screen:changed', { id, from: prevScreenId || null });
|
||||
}
|
||||
|
||||
export let currentFilename = '';
|
||||
|
||||
export function _playbackApi() {
|
||||
return window.feedBack && window.feedBack.playback && window.feedBack.playback.version === 1
|
||||
? window.feedBack.playback
|
||||
: null;
|
||||
}
|
||||
|
||||
// Bridge hits are a "this legacy surface is still in use" signal, not a call
|
||||
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
|
||||
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
|
||||
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
|
||||
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
|
||||
// snapshot serialization on the main thread and saturated the inspector's
|
||||
// hitCount. Throttle per surface: the first call records immediately, repeats
|
||||
// within the window are dropped.
|
||||
export const _bridgeRecordLast = new Map();
|
||||
|
||||
export const _BRIDGE_RECORD_MIN_MS = 5000;
|
||||
|
||||
export function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
|
||||
const playback = _playbackApi();
|
||||
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
|
||||
const key = `${bridgeId}|${legacySurface}`;
|
||||
const now = Date.now();
|
||||
const last = _bridgeRecordLast.get(key);
|
||||
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
|
||||
_bridgeRecordLast.set(key, now);
|
||||
playback.recordBridgeHit({
|
||||
bridgeId,
|
||||
legacySurface,
|
||||
source: 'core.app',
|
||||
reason: reason || 'legacy playback surface used',
|
||||
});
|
||||
}
|
||||
|
||||
// Screen Wake Lock — keep the display awake while a song is playing so the
|
||||
// OS screensaver doesn't kick in during windowed-mode playback (only audio +
|
||||
// the highway animation are active, so the input-idle timer otherwise fires).
|
||||
// Engaged only while playing (acquire on play/resume, release on
|
||||
// pause/ended/stop) per issue #686. In a plain browser this uses the W3C
|
||||
// Screen Wake Lock API; inside feedBack-desktop (Electron) navigator.wakeLock
|
||||
// is unreliable, so we also drive the native powerSaveBlocker bridge when it
|
||||
// is exposed — both calls are best-effort and degrade silently elsewhere.
|
||||
export let _screenWakeLock = null;
|
||||
|
||||
export let _wakeLockPending = false;
|
||||
|
||||
// Desired state: true while a song should be keeping the screen awake. This is
|
||||
// the source of truth that survives the async gap of navigator.wakeLock.request
|
||||
// — set synchronously by acquire/release so an in-flight request that resolves
|
||||
// after playback already stopped can release itself instead of leaking a lock.
|
||||
export let _wakeLockWanted = false;
|
||||
|
||||
// Set when an acquire is requested while one is already in flight (e.g. a quick
|
||||
// hide→show during the first request); the in-flight request retries once on
|
||||
// settle so a transient NotAllowedError doesn't leave the song unprotected.
|
||||
export let _wakeLockRetry = false;
|
||||
|
||||
// Last value handed to the desktop bridge. This is the value we *requested*,
|
||||
// not one confirmed by the IPC round trip: the Electron main-process side
|
||||
// effect (powerSaveBlocker start/stop) happens when the message is received,
|
||||
// before its promise resolves, so deduping on the requested value lets opposite
|
||||
// transitions (true↔false) always go through promptly while still suppressing
|
||||
// redundant repeats (e.g. the synchronous song:play + song:resume pair). A
|
||||
// rejected/throwing call invalidates the marker (the side effect never landed)
|
||||
// so the next song:* / visibilitychange retries — without an inline re-sync,
|
||||
// which would tight-loop on a persistently failing bridge.
|
||||
// Last value handed to the bridge: false (off) / true (on) / null (unknown —
|
||||
// a call failed, so the real blocker state can't be assumed). null never equals
|
||||
// a boolean `want`, so the next sync always re-sends and recovers.
|
||||
export let _desktopAwakeReq = false;
|
||||
|
||||
// Monotonic id of the most recent bridge call, so a stale (out-of-order)
|
||||
// rejection from a superseded call can be ignored rather than corrupting the
|
||||
// marker — a boolean alone can't tell "my request failed" from "an older
|
||||
// same-valued request failed after a newer one already succeeded".
|
||||
export let _desktopAwakeGen = 0;
|
||||
|
||||
// Drive the native feedBack-desktop blocker to exactly (wanted && visible),
|
||||
// mirroring the browser wake lock which is only held while the page is visible.
|
||||
// Gating on visibility stops a minimized Electron window from keeping the whole
|
||||
// display awake. No-op in a plain browser; isolated from the wakeLock path so a
|
||||
// flaky bridge can't abort it.
|
||||
export function _syncDesktopBridge() {
|
||||
const want = _wakeLockWanted && document.visibilityState === 'visible';
|
||||
if (want === _desktopAwakeReq) return; // already requested this value
|
||||
const bridge = window.feedBackDesktop?.power?.setScreenAwake;
|
||||
if (typeof bridge !== 'function') return; // plain browser — nothing to sync
|
||||
_desktopAwakeReq = want;
|
||||
const gen = ++_desktopAwakeGen;
|
||||
let r;
|
||||
try {
|
||||
r = bridge(want);
|
||||
} catch (e) {
|
||||
console.debug('desktop wake bridge failed:', e?.name || e);
|
||||
if (gen === _desktopAwakeGen) _desktopAwakeReq = null; // unknown — force a re-send next event
|
||||
return;
|
||||
}
|
||||
if (r && typeof r.then === 'function') {
|
||||
r.catch((e) => {
|
||||
console.debug('desktop wake bridge rejected:', e);
|
||||
// The IPC didn't take effect; we can't assume which state the blocker
|
||||
// is in (a prior call may also have failed), so mark it unknown and
|
||||
// let the next song:* / visibilitychange re-send. Only if this is
|
||||
// still the latest request — a stale rejection from a superseded call
|
||||
// must not clobber a newer request's marker.
|
||||
if (gen === _desktopAwakeGen) _desktopAwakeReq = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function _acquireWakeLock() {
|
||||
_wakeLockWanted = true;
|
||||
_syncDesktopBridge();
|
||||
if (_screenWakeLock) return; // already held — nothing to do
|
||||
// A request is already in flight (song:play and song:resume fire
|
||||
// synchronously from the audio 'play' listener, and visibilitychange can
|
||||
// re-enter): don't issue a duplicate, but remember to retry on settle so a
|
||||
// visibility bounce during the request can't strand us without a lock.
|
||||
if (_wakeLockPending) { _wakeLockRetry = true; return; }
|
||||
if (!navigator.wakeLock?.request) return;
|
||||
_wakeLockPending = true;
|
||||
_wakeLockRetry = false;
|
||||
try {
|
||||
const sentinel = await navigator.wakeLock.request('screen');
|
||||
if (!_wakeLockWanted) {
|
||||
// Playback stopped while the request was in flight — release the
|
||||
// just-granted lock immediately rather than holding it stale.
|
||||
try { await sentinel.release(); } catch (e) { /* already released */ }
|
||||
return;
|
||||
}
|
||||
_screenWakeLock = sentinel;
|
||||
sentinel.addEventListener('release', () => {
|
||||
_screenWakeLock = null;
|
||||
// The UA auto-releases on tab hide, but may also release for its own
|
||||
// reasons (power policy) while the page stays visible. Re-acquire if
|
||||
// a song is still playing and we're visible — the visibilitychange
|
||||
// handler covers the hidden→visible case.
|
||||
if (_wakeLockWanted && document.visibilityState === 'visible') {
|
||||
_acquireWakeLock();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// NotAllowedError (page hidden / no user activation) or unsupported.
|
||||
console.debug('wakeLock request failed:', e?.name || e);
|
||||
} finally {
|
||||
_wakeLockPending = false;
|
||||
// A re-acquire arrived while the request was in flight (typically a
|
||||
// hide→show bounce). If we still want the lock, are visible, and didn't
|
||||
// get one (the request raced a hidden window and rejected), try once
|
||||
// more now that the page state has settled. Bounded: only fires when a
|
||||
// bounce actually occurred, so a permanently-denied request can't loop.
|
||||
if (_wakeLockRetry && _wakeLockWanted && !_screenWakeLock
|
||||
&& document.visibilityState === 'visible') {
|
||||
_wakeLockRetry = false;
|
||||
_acquireWakeLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function _releaseWakeLock() {
|
||||
_wakeLockWanted = false;
|
||||
_syncDesktopBridge();
|
||||
if (!_screenWakeLock) return;
|
||||
try { await _screenWakeLock.release(); } catch (e) { /* already released */ }
|
||||
_screenWakeLock = null;
|
||||
}
|
||||
|
||||
// Resolve where the player should return on Esc / close / auto-exit.
|
||||
// A one-shot setReturnScreen() override wins (consumed here) — used by the
|
||||
// lessons catalog so a lesson returns to the lessons screen rather than the
|
||||
// library, even though the external tutorials plugin owns the playSong call.
|
||||
// Otherwise remember the actual launch screen; the element-exists guard
|
||||
// keeps the classic v2 UI (no #v3-* ids) from being stranded on a missing
|
||||
// screen, and unknown launches fall back to 'home'. The dashboard — classic
|
||||
// 'home' and the v3 shell's 'v3-home' — returns to the Songs list when it
|
||||
// exists (dashboard actions call playSong() directly, so its id is the
|
||||
// active screen at launch).
|
||||
export function _resolvePlayerOrigin() {
|
||||
const override = window.feedBack && window.feedBack._nextReturnScreen;
|
||||
if (window.feedBack) window.feedBack._nextReturnScreen = null;
|
||||
if (override && document.getElementById(override)) return override;
|
||||
const launchFrom = document.querySelector('.screen.active');
|
||||
const launchId = launchFrom && launchFrom.id;
|
||||
if (launchId && launchId !== 'player' && document.getElementById(launchId)) {
|
||||
return ((launchId === 'home' || launchId === 'v3-home') && document.getElementById('v3-songs'))
|
||||
? 'v3-songs' : launchId;
|
||||
}
|
||||
return 'home';
|
||||
}
|
||||
|
||||
// Autoplay: one-shot flag armed by each fresh playSong(), consumed by the
|
||||
// next song:ready. song:ready also fires on arrangement switches / seeks,
|
||||
// which never arm the flag, so those don't auto-restart.
|
||||
export let _pendingAutostart = false;
|
||||
|
||||
// Autoplay gate (window.feedBack.holdAutoplay): a plugin (the tuner) can defer the
|
||||
// auto-start of a freshly-loaded song until it's cleared — "tune before you play".
|
||||
// The hold is claimed synchronously on song:loading (so it beats this song:ready
|
||||
// autostart); release() — or a fail-open backstop — runs the deferred start.
|
||||
// Generation-guarded so a newer song invalidates a stale hold. Manual Play never
|
||||
// flows through here, so Play always wins.
|
||||
export let _autoplayHeld = false;
|
||||
|
||||
export let _autoplayStart = null;
|
||||
|
||||
export let _autoplayGen = 0;
|
||||
|
||||
export let _autoplayBackstop = null;
|
||||
|
||||
export const AUTOPLAY_HOLD_BACKSTOP_MS = 12000;
|
||||
|
||||
export function _clearAutoplayHold() {
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
_autoplayHeld = false;
|
||||
_autoplayStart = null;
|
||||
_autoplayGen++;
|
||||
}
|
||||
|
||||
export function _releaseAutoplay(gen) {
|
||||
if (gen !== _autoplayGen) return; // a newer song superseded this hold
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
_autoplayHeld = false;
|
||||
const start = _autoplayStart;
|
||||
_autoplayStart = null;
|
||||
if (typeof start === 'function') start();
|
||||
}
|
||||
|
||||
export let _autoplayHoldToken = 0;
|
||||
|
||||
window.feedBack.holdAutoplay = function () {
|
||||
const gen = _autoplayGen;
|
||||
const token = ++_autoplayHoldToken; // this hold's identity — a stale release from an earlier hold is a no-op
|
||||
_autoplayHeld = true;
|
||||
if (_autoplayBackstop) clearTimeout(_autoplayBackstop);
|
||||
// Fail-open: a hold that's never released (a plugin that claimed but wedged before
|
||||
// it could decide) must never permanently block the song. Once the holder commits
|
||||
// to an intentional, user-dismissable hold it calls release.settle() to cancel this
|
||||
// — so the backstop can't cut off e.g. a user still tuning past the timeout.
|
||||
_autoplayBackstop = setTimeout(() => _releaseAutoplay(gen), AUTOPLAY_HOLD_BACKSTOP_MS);
|
||||
let released = false;
|
||||
function release() {
|
||||
if (released || gen !== _autoplayGen || token !== _autoplayHoldToken) return;
|
||||
released = true;
|
||||
_releaseAutoplay(gen);
|
||||
}
|
||||
// Cancel the fail-open backstop WITHOUT releasing: the holder has taken explicit
|
||||
// responsibility for releasing (on dismiss), and a song switch clears the hold anyway.
|
||||
release.settle = function () {
|
||||
if (gen !== _autoplayGen || token !== _autoplayHoldToken) return;
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
};
|
||||
return release;
|
||||
};
|
||||
|
||||
window.feedBack.on('song:ready', () => {
|
||||
if (!_pendingAutostart) return;
|
||||
_pendingAutostart = false;
|
||||
if (S.isPlaying) return;
|
||||
// Feedpak contributor credits: only real feedpak plays carry authors
|
||||
// (loose/archive and minigames get []), so a non-empty list is the gate.
|
||||
// Shown over the highway and dismissed the moment real playback begins
|
||||
// (song:play). This fresh-load path is the only place it fires —
|
||||
// arrangement switches / seeks / manual replays never arm _pendingAutostart,
|
||||
// and minigames never get here. Decoupled from autoplay below so credits
|
||||
// show on load even when autoplay-exit is disabled.
|
||||
const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || [];
|
||||
if (authors.length) {
|
||||
showSongCreditsOverlay(authors);
|
||||
armCreditsHideOnPlay();
|
||||
}
|
||||
// Autoplay-exit disabled: don't auto-start. Still let the credits dwell a
|
||||
// couple seconds on the freshly-loaded song, then clear them (they also
|
||||
// clear early if the user manually presses Play, via _creditsHideOnPlay).
|
||||
if (!_autoplayExitEnabled()) {
|
||||
if (authors.length) scheduleCreditsHide();
|
||||
return;
|
||||
}
|
||||
// The actual auto-start: a count-in (which handles HTML5 + _juceMode) or the
|
||||
// Play path directly. Guarded so a manual Play during a gate / credits hold
|
||||
// can't double-toggle, and so a stale (released-after-leaving) start never
|
||||
// begins playback off the player.
|
||||
const start = () => {
|
||||
if (S.isPlaying) return;
|
||||
if (!document.getElementById('player')?.classList.contains('active')) { hideSongCreditsOverlay(); return; }
|
||||
if (_countdownBeforeSongEnabled()) {
|
||||
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
|
||||
} else {
|
||||
Promise.resolve(togglePlay())
|
||||
.then(() => { if (!S.isPlaying) hideSongCreditsOverlay(); })
|
||||
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
|
||||
}
|
||||
};
|
||||
// A plugin (the tuner) may gate playback until it's cleared. The hold was
|
||||
// claimed on song:loading; stash the start and let release()/the backstop run
|
||||
// it. _cancelCountIn()/changeArrangement() clear _creditsTimer below, so a
|
||||
// teardown during the credits dwell still cancels a non-gated play.
|
||||
if (_autoplayHeld) { _autoplayStart = start; return; }
|
||||
// Not gated: a count-in starts now (it owns its on-screen dwell); otherwise
|
||||
// let the credits dwell a couple seconds first, then start.
|
||||
if (_countdownBeforeSongEnabled() || !authors.length) start();
|
||||
else holdCreditsThen(start);
|
||||
});
|
||||
|
||||
// Auto-exit: when the song ends, return to the launching menu. A scoring
|
||||
// plugin that shows an end-of-song results screen calls holdAutoExit() to
|
||||
// defer this; the user closing that screen (its Close button calls
|
||||
// window.closeCurrentSong()) performs the exit. With no results screen the
|
||||
// grace timer returns to the menu on its own.
|
||||
export const AUTO_EXIT_GRACE_MS = 1500;
|
||||
|
||||
export let _autoExitTimer = null;
|
||||
|
||||
export let _autoExitHeld = false;
|
||||
|
||||
// Bumped every time the auto-exit state is reset (new song via playSong, and
|
||||
// each song:ended). A hold's release() captures the generation at hold time
|
||||
// and no-ops once it changes, so a plugin that drops or fires its release
|
||||
// handle after the player has moved on can never navigate a fresh session —
|
||||
// callers don't need to balance the handle.
|
||||
export let _autoExitGen = 0;
|
||||
|
||||
export function _clearAutoExit() {
|
||||
if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; }
|
||||
_autoExitHeld = false;
|
||||
_autoExitGen++;
|
||||
}
|
||||
|
||||
// Heuristic safety net for score-screen plugins that don't (yet) call
|
||||
// holdAutoExit(): if a visible full-screen results/dialog overlay is on top
|
||||
// when the grace timer fires, defer the auto-return and let that screen's
|
||||
// own close button drive the exit (its Close should call closeCurrentSong).
|
||||
// getClientRects() is used for the visibility test because it reports
|
||||
// position:fixed overlays correctly, unlike offsetParent.
|
||||
export function _resultsOverlayVisible() {
|
||||
let nodes;
|
||||
try {
|
||||
nodes = document.querySelectorAll('[role="dialog"][aria-modal="true"], .fixed.inset-0');
|
||||
} catch (_) { return false; }
|
||||
for (const el of nodes) {
|
||||
if (!el || el.id === 'player') continue; // never the player itself
|
||||
if (el.classList && el.classList.contains('hidden')) continue;
|
||||
if (el.getClientRects && el.getClientRects().length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Plugins call this synchronously from their own song:ended handler (core
|
||||
// runs first, so the timer is already pending) to claim the exit.
|
||||
window.feedBack.holdAutoExit = function () {
|
||||
if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; }
|
||||
_autoExitHeld = true;
|
||||
const gen = _autoExitGen;
|
||||
let released = false;
|
||||
return function release() {
|
||||
// No-op once released, or once the session has moved on (a newer
|
||||
// playSong / song:ended bumped the generation) — so a stale handle
|
||||
// never navigates away from a fresh song.
|
||||
if (released || gen !== _autoExitGen) return;
|
||||
released = true;
|
||||
if (typeof window.closeCurrentSong === 'function') window.closeCurrentSong();
|
||||
};
|
||||
};
|
||||
|
||||
window.feedBack.on('song:ended', () => {
|
||||
_clearAutoExit();
|
||||
if (!_autoplayExitEnabled()) return;
|
||||
// Only auto-exit from the player screen (ignore stale/duplicate ends).
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (!active || active.id !== 'player') return;
|
||||
_autoExitTimer = setTimeout(() => {
|
||||
_autoExitTimer = null;
|
||||
if (_autoExitHeld) return; // a plugin explicitly claimed the exit
|
||||
if (_resultsOverlayVisible()) return; // a score/results overlay is up; let it drive the exit
|
||||
const cur = document.querySelector('.screen.active');
|
||||
if (cur && cur.id === 'player' && typeof window.closeCurrentSong === 'function') {
|
||||
window.closeCurrentSong();
|
||||
}
|
||||
}, AUTO_EXIT_GRACE_MS);
|
||||
});
|
||||
|
||||
// Abort controller for cancelling pending requests when entering player
|
||||
export let artAbortController = null;
|
||||
|
||||
export async function playSong(filename, arrangement, options) {
|
||||
console.log('playSong called:', filename);
|
||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
}
|
||||
// Invalidate any prior song's autoplay gate before plugins re-claim it on the
|
||||
// song:loading emit below.
|
||||
_clearAutoplayHold();
|
||||
window.feedBack.emit('song:loading', { filename, arrangement: arrangement ?? null });
|
||||
|
||||
// Cancel any pending art/metadata requests
|
||||
if (artAbortController) artAbortController.abort();
|
||||
artAbortController = null;
|
||||
|
||||
window.highway.stop();
|
||||
// Cancel any active count-in: clear timers/RAF and bump the gen so
|
||||
// delayed callbacks (rewind frames, post-seek then, count-in ticks,
|
||||
// post-count play) bail before mutating the new session.
|
||||
_cancelCountIn();
|
||||
// Reset the JUCE shim BEFORE awaiting jucePlayer.stop() so any in-flight
|
||||
// shim closures see a stale generation after their await and bail out
|
||||
// before mutating isPlaying / button label / song:* events for the
|
||||
// outgoing song.
|
||||
_resetJuceAudioShimChain();
|
||||
// Cancel queued _audioSeek calls from the previous song: bumping the
|
||||
// generation makes their chained callbacks bail out.
|
||||
_resetAudioSeekState();
|
||||
if (window._juceMode) {
|
||||
// Mirror the showScreen teardown: emit song:pause for the JUCE
|
||||
// path so plugins don't see a stale "playing" state on song
|
||||
// change. (HTML5 fires it via the audio element 'pause' event.)
|
||||
// Snapshot payload BEFORE stop() resets _pos so audioT/chartT
|
||||
// capture the actual paused position.
|
||||
const payload = _songEventPayload();
|
||||
const wasPlaying = S.isPlaying;
|
||||
await jucePlayer.stop().catch(() => {});
|
||||
if (wasPlaying && window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', payload);
|
||||
}
|
||||
window._juceMode = false;
|
||||
window._juceAudioUrl = null;
|
||||
}
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
// Stale until the incoming song's WS handler (window.highway.js) sets it again.
|
||||
window._currentSongAudio = null;
|
||||
// Fresh JUCE routing attempt for whatever song loads next.
|
||||
window._clearJuceRerouteMemo?.();
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
_resetPlaybackSpeedForNewSong();
|
||||
clearLoop();
|
||||
_resetSectionPracticeLog();
|
||||
_hideSectionPracticeBar();
|
||||
// Reset so the jump-fix (setInterval, ~line 8979) doesn't mistake the new
|
||||
// song starting at t=0 for an unexpected seek from the previous song's
|
||||
// position. audio.currentTime may not reset synchronously when src is cleared.
|
||||
S.lastAudioTime = 0;
|
||||
|
||||
currentFilename = filename;
|
||||
// A fresh load arms autoplay; a pending auto-exit from the previous
|
||||
// song is no longer relevant. A *resume* load (options.resume) instead
|
||||
// arms _pendingResume — consumed at song:ready to restore speed + seek to
|
||||
// the saved position, then start — so autostart and resume don't both try
|
||||
// to begin playback from different positions.
|
||||
if (options && options.resume && Number(options.resume.position) > 0) {
|
||||
S.pendingResume = options.resume;
|
||||
_pendingAutostart = false;
|
||||
} else {
|
||||
S.pendingResume = null;
|
||||
_pendingAutostart = true;
|
||||
}
|
||||
_clearAutoExit();
|
||||
// Remember which screen the player was launched from so Esc /
|
||||
// navigation back from the player (and auto-exit) returns the user
|
||||
// there (feedBack#126).
|
||||
_playerOriginScreen = _resolvePlayerOrigin();
|
||||
showScreen('player');
|
||||
|
||||
// Wait for previous WebSocket to fully close before opening new one
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
window.highway.init(document.getElementById('highway'));
|
||||
|
||||
const wsParams = new URLSearchParams();
|
||||
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
|
||||
wsParams.set('naming_mode', _getArrangementNamingMode());
|
||||
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/highway/${decodeURIComponent(filename)}?${wsParams.toString()}`;
|
||||
window.highway.connect(wsUrl);
|
||||
_resetSectionPracticeLog();
|
||||
_scheduleSectionPracticeRetries();
|
||||
loadSavedLoops();
|
||||
document.getElementById('quality-select').value = window.highway.getRenderScale();
|
||||
const _minScaleSel = document.getElementById('min-scale-select');
|
||||
if (_minScaleSel && window.highway.getMinRenderScale) _minScaleSel.value = String(window.highway.getMinRenderScale());
|
||||
}
|
||||
|
||||
// Leave the player and return to the screen the song was launched from
|
||||
// (Esc shortcut uses the same origin-aware target). showScreen() owns the
|
||||
// full teardown: song:stop, audio unload, window.highway.stop(), count-in cancel.
|
||||
export function closeCurrentSong() {
|
||||
// A real close (user Escape/✕, or the queue-aware wrapper once the queue is
|
||||
// exhausted) abandons any play-queue so a stale one can't advance later.
|
||||
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.clear();
|
||||
return showScreen(_playerOriginScreen || 'home');
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
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' });
|
||||
downloadBlob(blob, filename);
|
||||
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,539 @@
|
||||
// Settings: load/save, the AV-offset nudge, the default-arrangement pin, the instrument
|
||||
// pathway, and the app-update channel.
|
||||
//
|
||||
// INTERFACE WIDTH 1 — app.js calls loadSettings() and nothing else. It got that clean by
|
||||
// PULLING THE WRITERS IN: _defaultArrangement was the one binding written from outside the
|
||||
// cluster, by saveSettings and pinCurrentArrangementDefault — which are themselves settings
|
||||
// functions. Widening the slice to include them left ZERO outside writes, so every export is a
|
||||
// plain read-only import and no state container is needed.
|
||||
//
|
||||
// (An imported binding is read-only. One write from outside would have forced a setter or a
|
||||
// container, as it did for the player and the library. Here the fix was to draw the boundary in
|
||||
// the right place instead.)
|
||||
//
|
||||
// ─── handleSliderInput STAYS A HOST HOOK, DELIBERATELY ───────────────────────
|
||||
//
|
||||
// It lives here (it is a settings control), but player-controls.js must NOT import it: this
|
||||
// module already imports player-controls (_applyMastery, _autoplayExitEnabled, …), so a direct
|
||||
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
|
||||
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
|
||||
import { hwcInitSettingsUI } from './highway-colors.js';
|
||||
import { _getArrangementNamingMode } from './library.js';
|
||||
import {
|
||||
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
|
||||
} from './player-controls.js';
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
export let _defaultArrangement = '';
|
||||
|
||||
export const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
|
||||
|
||||
export function _normalizeInstrumentPathway(value) {
|
||||
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
|
||||
}
|
||||
|
||||
export function _syncDefaultArrangementSelect(value) {
|
||||
const sel = document.getElementById('default-arrangement');
|
||||
if (!sel) return;
|
||||
const wanted = value || '';
|
||||
const existing = Array.from(sel.options).find(opt => opt.value === wanted);
|
||||
const dynamic = sel.querySelector('option[data-dynamic-default-arrangement]');
|
||||
if (dynamic && dynamic.value !== wanted) dynamic.remove();
|
||||
if (wanted && !existing) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = wanted;
|
||||
opt.textContent = `${wanted} (saved default)`;
|
||||
opt.dataset.dynamicDefaultArrangement = 'true';
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = wanted;
|
||||
}
|
||||
|
||||
export function _currentArrangementName() {
|
||||
const song = window.feedBack?.currentSong;
|
||||
const sel = document.getElementById('arr-select');
|
||||
if (song?.arrangements && sel) {
|
||||
const match = song.arrangements.find(a => String(a.index) === String(sel.value));
|
||||
if (match?.name) return String(match.name);
|
||||
}
|
||||
if (song?.arrangement) return String(song.arrangement);
|
||||
const selectedText = sel?.selectedOptions?.[0]?.textContent || '';
|
||||
return selectedText.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
||||
}
|
||||
|
||||
export function syncDefaultArrangementPin() {
|
||||
const btn = document.getElementById('arr-default-pin');
|
||||
if (!btn) return;
|
||||
const name = _currentArrangementName();
|
||||
const isDefault = !!name && name === _defaultArrangement;
|
||||
const label = name
|
||||
? (isDefault ? `${name} is the default arrangement` : `Make ${name} the default for new songs`)
|
||||
: 'Select an arrangement to make it the default';
|
||||
btn.textContent = isDefault ? '★' : '☆';
|
||||
btn.setAttribute('aria-pressed', isDefault ? 'true' : 'false');
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.disabled = !name;
|
||||
btn.classList.toggle('text-yellow-300', isDefault);
|
||||
btn.classList.toggle('text-gray-400', !isDefault);
|
||||
btn.title = label;
|
||||
}
|
||||
|
||||
export async function pinCurrentArrangementDefault() {
|
||||
const name = _currentArrangementName();
|
||||
if (!name || name === _defaultArrangement) {
|
||||
syncDefaultArrangementPin();
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ default_arrangement: name }),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
_defaultArrangement = name;
|
||||
_syncDefaultArrangementSelect(name);
|
||||
syncDefaultArrangementPin();
|
||||
}
|
||||
|
||||
export async function loadSettings() {
|
||||
// App Updates UI does not depend on /api/settings — run it first so a
|
||||
// failed fetch below still leaves the desktop updater wired up.
|
||||
// setupAppUpdates() is idempotent via _appUpdatesWired.
|
||||
setupAppUpdates();
|
||||
setupWindowOptions();
|
||||
const resp = await fetch('/api/settings');
|
||||
const data = await resp.json();
|
||||
// Null-guard the form fields: on the v3 tabbed settings page the markup is
|
||||
// rendered by settings.js, so a control may be absent if that render hasn't
|
||||
// run yet (or on a follower window). The optional-chaining keeps loadSettings
|
||||
// from throwing and aborting the rest of the hydration.
|
||||
const dlcEl = document.getElementById('dlc-path');
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
|
||||
const leftyEl = document.getElementById('setting-lefty');
|
||||
if (leftyEl) leftyEl.checked = window.highway.getLefty();
|
||||
const autoplayExitEl = document.getElementById('setting-autoplay-exit');
|
||||
if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled();
|
||||
const showUpNextEl = document.getElementById('setting-show-upnext');
|
||||
if (showUpNextEl) showUpNextEl.checked = _showUpNextEnabled();
|
||||
const confirmExitEl = document.getElementById('setting-confirm-exit');
|
||||
if (confirmExitEl) confirmExitEl.checked = _exitConfirmEnabled();
|
||||
// Restore master-difficulty slider from persisted value (defaults
|
||||
// to 100 when the key is absent — no behaviour change for users
|
||||
// who've never touched the slider).
|
||||
const masteryPct = typeof data.master_difficulty === 'number'
|
||||
? Math.max(0, Math.min(100, data.master_difficulty))
|
||||
: 100;
|
||||
// Drives both the player-popover slider (#mastery-slider) and the
|
||||
// Gameplay-tab "Note highway speed" slider (#setting-highway-speed), which
|
||||
// share the master_difficulty key. skipPersist so loading the value doesn't
|
||||
// echo it back to the server.
|
||||
_applyMastery(masteryPct, { skipPersist: true });
|
||||
// Route the loaded value through setAvOffsetMs so the highway's
|
||||
// render clock, the Settings slider, the HUD readout, and the
|
||||
// module variable all pick it up consistently. Pass skipPersist
|
||||
// so we don't echo the loaded value back to the server.
|
||||
setAvOffsetMs(Number(data.av_offset_ms) || 0, /* skipPersist */ true);
|
||||
// Arrangement naming mode is localStorage-only (client preference).
|
||||
const namingModeEl = document.getElementById('arrangement-naming-mode');
|
||||
if (namingModeEl) namingModeEl.value = _getArrangementNamingMode();
|
||||
// Gameplay-tab settings (tabbed settings page). Countdown is mirrored to
|
||||
// localStorage so the song-start path reads it synchronously without an
|
||||
// async /api/settings fetch on the play hot path. Miss penalty / fail
|
||||
// behavior are persist-only stubs (not yet consumed by scoring).
|
||||
const countdownOn = data.countdown_before_song === true;
|
||||
try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ }
|
||||
const countdownEl = document.getElementById('setting-countdown-before-song');
|
||||
if (countdownEl) countdownEl.checked = countdownOn;
|
||||
// Achievements epic: mirror the opt-in flag to localStorage so the
|
||||
// onboarding card + the bundled achievements plugin can read the current
|
||||
// state app-wide (the plugin's own settings panel still owns the toggle).
|
||||
try { localStorage.setItem('achievementsEnabled', data.achievements_enabled === true ? '1' : '0'); } catch (_) { /* private mode */ }
|
||||
const missEl = document.getElementById('setting-miss-penalty');
|
||||
if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none';
|
||||
const failEl = document.getElementById('setting-fail-behavior');
|
||||
if (failEl) failEl.value = typeof data.fail_behavior === 'string' ? data.fail_behavior : 'continue';
|
||||
// Native folder picker — only present when running inside feedBack-desktop.
|
||||
if (window.feedBackDesktop && typeof window.feedBackDesktop.pickDirectory === 'function') {
|
||||
document.getElementById('btn-pick-dlc')?.classList.remove('hidden');
|
||||
}
|
||||
syncDefaultArrangementPin();
|
||||
// Hydrate the highway-color settings UI (theme select + per-string pickers)
|
||||
// — the runtime apply path (initHighwayColors) doesn't render these controls.
|
||||
hwcInitSettingsUI();
|
||||
}
|
||||
|
||||
// ── Window options (desktop-only) ────────────────────────────────────────
|
||||
// Desktop-only window preferences (start-in-fullscreen, …). The whole block
|
||||
// stays hidden in the plain web / Docker app; unhide + wire only when the
|
||||
// feedBack-desktop bridge (window.feedBackDesktop.window) exposes the getter
|
||||
// and setter. Persistence lives desktop-side because only the Electron main
|
||||
// process can read the pref at window-creation time — core just proxies.
|
||||
export let _windowOptionsWired = false;
|
||||
|
||||
export function setupWindowOptions() {
|
||||
const block = document.getElementById('window-options-block');
|
||||
if (!block) return;
|
||||
const winApi = window.feedBackDesktop?.window;
|
||||
// Per-method capability check: a partial/older bridge may expose `window`
|
||||
// without this shape. Leave the block hidden rather than half-wiring it.
|
||||
if (!winApi
|
||||
|| typeof winApi.getStartFullscreen !== 'function'
|
||||
|| typeof winApi.setStartFullscreen !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
block.classList.remove('hidden');
|
||||
|
||||
const cb = document.getElementById('setting-start-fullscreen');
|
||||
if (!cb) return;
|
||||
|
||||
// Hydrate from the desktop-persisted value. The getter may be sync or
|
||||
// async (IPC round-trip); Promise.resolve normalises both.
|
||||
Promise.resolve(winApi.getStartFullscreen()).then(function (on) {
|
||||
cb.checked = !!on;
|
||||
}).catch(function () { /* leave unchecked on error */ });
|
||||
|
||||
// Guard only the listener against double-binding; unhide + re-hydrate
|
||||
// stay idempotent so re-entering Settings refreshes the checkbox.
|
||||
if (!_windowOptionsWired) {
|
||||
_windowOptionsWired = true;
|
||||
cb.addEventListener('change', function () {
|
||||
try { winApi.setStartFullscreen(cb.checked); } catch (_) { /* best-effort */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
|
||||
|
||||
export let _appUpdatesWired = false;
|
||||
|
||||
export function setupAppUpdates() {
|
||||
const block = document.getElementById('app-updates-block');
|
||||
if (!block) return;
|
||||
const updateApi = window.feedBackDesktop?.update;
|
||||
// Per-method capability check: an older or partial feedBack-desktop
|
||||
// bridge may expose `update` without the full shape. Skip wiring (and
|
||||
// leave the block hidden) rather than throwing on first interaction.
|
||||
if (!updateApi
|
||||
|| typeof updateApi.getStatus !== 'function'
|
||||
|| typeof updateApi.setChannel !== 'function'
|
||||
|| typeof updateApi.checkNow !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
block.classList.remove('hidden');
|
||||
|
||||
const channelSelect = document.getElementById('app-update-channel');
|
||||
const checkBtn = document.getElementById('app-update-check-now');
|
||||
const statusEl = document.getElementById('app-update-status');
|
||||
const linuxNote = document.getElementById('app-update-linux-note');
|
||||
if (!channelSelect || !checkBtn || !statusEl) return;
|
||||
|
||||
// localStorage access can throw in storage-restricted contexts (sandbox
|
||||
// iframes, privacy modes, etc.); fall back to the default channel so the
|
||||
// panel still renders rather than aborting wiring entirely.
|
||||
let storedRaw = null;
|
||||
// Read the canonical key, falling back to the pre-rename
|
||||
// 'slopsmith-update-channel' so an existing channel preference survives.
|
||||
try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ }
|
||||
const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable';
|
||||
channelSelect.value = stored;
|
||||
|
||||
const isLinux = window.feedBackDesktop?.platform === 'linux';
|
||||
|
||||
function showLinuxFallback(message) {
|
||||
if (linuxNote) linuxNote.classList.remove('hidden');
|
||||
channelSelect.disabled = true;
|
||||
checkBtn.disabled = true;
|
||||
statusEl.textContent = message || 'Auto-update is not available on this platform.';
|
||||
}
|
||||
|
||||
function fmtTimestamp(ts) {
|
||||
if (!ts) return 'never';
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return Number.isNaN(d.getTime()) ? 'never' : d.toLocaleString();
|
||||
} catch (_) { return 'never'; }
|
||||
}
|
||||
|
||||
function renderStatus(extra) {
|
||||
try {
|
||||
// Wrap in Promise.resolve so a future getStatus() that returns
|
||||
// synchronously won't blow up on .then().
|
||||
void Promise.resolve(updateApi.getStatus()).then((s) => {
|
||||
if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
|
||||
if (s.status === 'unsupported' || s.platform === 'linux') {
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.';
|
||||
statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg;
|
||||
return;
|
||||
}
|
||||
const parts = [
|
||||
`Version ${s.currentVersion || '?'}`,
|
||||
`channel ${s.channel || channelSelect.value}`,
|
||||
`last checked ${fmtTimestamp(s.lastChecked)}`,
|
||||
];
|
||||
statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · ');
|
||||
}).catch((e) => {
|
||||
console.warn('[updater] getStatus failed:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] getStatus threw:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
}
|
||||
}
|
||||
|
||||
if (isLinux) {
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
// Keep main informed of the persisted channel even on Linux so
|
||||
// cross-platform reasoning about the channel stays consistent.
|
||||
// setChannel() may return a Promise — chain .catch() so a rejected
|
||||
// promise doesn't surface as an unhandled rejection.
|
||||
try {
|
||||
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
|
||||
console.warn('[updater] setChannel(linux) failed:', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel(linux) threw:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Inform main of the persisted channel on each load. setChannel() on
|
||||
// main is idempotent when the channel already matches.
|
||||
try {
|
||||
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
|
||||
console.warn('[updater] setChannel(initial) failed:', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel(initial) threw:', e);
|
||||
}
|
||||
|
||||
if (!_appUpdatesWired) {
|
||||
// Wire DOM listeners once. The elements live in static index.html
|
||||
// and are not recreated, so re-wiring on every loadSettings() call
|
||||
// would just stack duplicate handlers.
|
||||
channelSelect.addEventListener('change', async () => {
|
||||
const val = channelSelect.value;
|
||||
if (!APP_UPDATE_CHANNELS.includes(val)) return;
|
||||
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
|
||||
try {
|
||||
// Await setChannel so the status line reflects what actually
|
||||
// happened — rendering "Channel set" unconditionally would
|
||||
// mislead users when the IPC rejects.
|
||||
await Promise.resolve(updateApi.setChannel(val));
|
||||
renderStatus(`Channel set to ${val}.`);
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel failed:', e);
|
||||
renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`);
|
||||
}
|
||||
});
|
||||
|
||||
checkBtn.addEventListener('click', async () => {
|
||||
checkBtn.disabled = true;
|
||||
statusEl.textContent = 'Checking for updates…';
|
||||
let reEnableBtn = true;
|
||||
try {
|
||||
const result = await updateApi.checkNow();
|
||||
const status = result?.status || 'unknown';
|
||||
let msg;
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
msg = "You're on the newest version in this channel.";
|
||||
break;
|
||||
case 'downloading':
|
||||
msg = 'Update available — downloading…';
|
||||
break;
|
||||
case 'downloaded':
|
||||
msg = 'Update downloaded — restart to apply.';
|
||||
break;
|
||||
case 'unsupported':
|
||||
reEnableBtn = false;
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
return;
|
||||
case 'error':
|
||||
msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`;
|
||||
break;
|
||||
default:
|
||||
msg = `Update check returned: ${status}`;
|
||||
}
|
||||
renderStatus(msg);
|
||||
} catch (e) {
|
||||
console.warn('[updater] checkNow failed:', e);
|
||||
statusEl.textContent = `Update check failed: ${e?.message || e}`;
|
||||
} finally {
|
||||
if (reEnableBtn) checkBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
_appUpdatesWired = true;
|
||||
}
|
||||
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
// Updates the fill on slider elements. Expects a CSS variable --range-pct used
|
||||
// in the track fill styling. Declared as a function (not a const) so it is
|
||||
// hoisted onto window — audio-mixer.js calls it as window.handleSliderInput,
|
||||
// matching the window.playSong / window.showScreen cross-script convention.
|
||||
export function handleSliderInput(el) {
|
||||
if (!el) return;
|
||||
const min = el.min || 0;
|
||||
const max = el.max || 100;
|
||||
const pct = (el.value - min) / (max - min) * 100;
|
||||
el.style.setProperty('--range-pct', pct + '%');
|
||||
}
|
||||
|
||||
// A/V sync calibration. Positive = audio runs ahead of visuals; we
|
||||
// add this to audio.currentTime when driving the highway so the
|
||||
// visuals catch up. Persisted via /api/settings as av_offset_ms.
|
||||
// Live-tunable from the player screen via [ / ] keys (Shift for
|
||||
// ±50 ms) and from the Settings slider; both auto-save with the
|
||||
// same debounced POST. loadSettings() seeds the value via
|
||||
// setAvOffsetMs without saving (skipPersist=true) to avoid an
|
||||
// echo-back round-trip.
|
||||
export let _avOffsetMs = 0;
|
||||
|
||||
export let _avSaveDebounce = null;
|
||||
|
||||
export function setAvOffsetMs(ms, skipPersist) {
|
||||
// Clamp to the same bounds the Settings/player-bar sliders enforce
|
||||
// (-1000..1000 ms). Defends against bad values from /api/settings
|
||||
// landing as `value` on <input type=range>.
|
||||
const n = Number(ms);
|
||||
_avOffsetMs = Math.max(-1000, Math.min(1000, Number.isFinite(n) ? n : 0));
|
||||
// Drive the highway's render-time shift. getTime() still returns
|
||||
// the audio-aligned chart time so plugins (note detection, etc.)
|
||||
// keep scoring against the real chart clock regardless of visual
|
||||
// calibration.
|
||||
if (window.highway?.setAvOffset) window.highway.setAvOffset(_avOffsetMs);
|
||||
// Sync any visible Settings slider
|
||||
const avSlider = document.getElementById('setting-av-offset');
|
||||
if (avSlider) {
|
||||
avSlider.value = _avOffsetMs;
|
||||
handleSliderInput(avSlider);
|
||||
}
|
||||
const avVal = document.getElementById('setting-av-offset-val');
|
||||
if (avVal) avVal.textContent = Math.round(_avOffsetMs);
|
||||
// Sync the inline player-bar slider (live-tunable while playing)
|
||||
const playerAvSlider = document.getElementById('player-av-offset-slider');
|
||||
if (playerAvSlider) {
|
||||
playerAvSlider.value = _avOffsetMs;
|
||||
handleSliderInput(playerAvSlider);
|
||||
}
|
||||
const playerAvLabel = document.getElementById('player-av-offset-label');
|
||||
if (playerAvLabel) {
|
||||
const rounded = Math.round(_avOffsetMs);
|
||||
playerAvLabel.textContent = `${rounded >= 0 ? '+' : ''}${rounded}ms`;
|
||||
}
|
||||
// Update the player HUD readout (hidden when offset = 0 to
|
||||
// avoid clutter; the keyboard shortcut is documented in the
|
||||
// Settings help text so it stays discoverable).
|
||||
const hud = document.getElementById('hud-avoffset');
|
||||
if (hud) {
|
||||
hud.textContent = `A/V ${_avOffsetMs >= 0 ? '+' : ''}${Math.round(_avOffsetMs)} ms`;
|
||||
hud.classList.toggle('hidden', _avOffsetMs === 0);
|
||||
}
|
||||
if (!skipPersist) _persistAvOffset();
|
||||
}
|
||||
|
||||
export function _persistAvOffset() {
|
||||
// Debounced persist — POST only the one field; the server merges.
|
||||
if (_avSaveDebounce) clearTimeout(_avSaveDebounce);
|
||||
_avSaveDebounce = setTimeout(async () => {
|
||||
_avSaveDebounce = null;
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ av_offset_ms: _avOffsetMs }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('A/V offset save failed:', e);
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
export function nudgeAvOffsetMs(delta) {
|
||||
setAvOffsetMs(Math.max(-1000, Math.min(1000, _avOffsetMs + delta)));
|
||||
}
|
||||
|
||||
export async function saveSettings() {
|
||||
const defaultArrangement = document.getElementById('default-arrangement').value;
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dlc_dir: document.getElementById('dlc-path').value.trim(),
|
||||
default_arrangement: defaultArrangement,
|
||||
demucs_server_url: document.getElementById('demucs-server-url').value.trim(),
|
||||
av_offset_ms: _avOffsetMs,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
_defaultArrangement = defaultArrangement;
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
syncDefaultArrangementPin();
|
||||
}
|
||||
document.getElementById('settings-status').textContent = data.message || data.error;
|
||||
}
|
||||
|
||||
// Persist a single settings field the instant a control changes (used by
|
||||
// the Settings dropdowns). The /api/settings POST handler merges only the
|
||||
// keys present in the body, so this one-field write won't clobber dlc_dir
|
||||
// or any other setting. No debounce: a <select> change event fires once
|
||||
// per selection, unlike the A/V / mastery sliders' per-pixel oninput.
|
||||
//
|
||||
// The Settings-dropdown autosaves run through one chain so their POSTs are
|
||||
// sent one at a time, in the order the user made the changes — the last
|
||||
// selection is always the last write, for both rapid changes to one
|
||||
// dropdown and back-to-back changes across different dropdowns. The A/V
|
||||
// and mastery slider autosaves POST directly (not through this chain);
|
||||
// the server-side config.json lock is what keeps those from racing the
|
||||
// dropdown writes (see save_settings() in server.py).
|
||||
export let _settingSaveChain = Promise.resolve();
|
||||
|
||||
export function persistSetting(key, value) {
|
||||
const next = _settingSaveChain.then(() => _postSetting(key, value));
|
||||
// Swallow failures so one failed write doesn't poison the chain and
|
||||
// block every later save.
|
||||
_settingSaveChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setInstrumentPathway(value) {
|
||||
const pathway = _normalizeInstrumentPathway(value);
|
||||
const el = document.getElementById('setting-instrument-pathway');
|
||||
if (el) el.value = pathway;
|
||||
persistSetting('pathway', pathway).then(() => {
|
||||
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
|
||||
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function _postSetting(key, value) {
|
||||
const status = document.getElementById('settings-status');
|
||||
try {
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (status) status.textContent = data.message || data.error || '';
|
||||
} catch (e) {
|
||||
if (status) status.textContent = 'Save failed: ' + e.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
// KEYBOARD SHORTCUTS: the panel registry, the global dispatchers, and the plugin-facing API.
|
||||
//
|
||||
// ━━━ MOST OF THIS SUBSYSTEM IS TOP-LEVEL STATEMENTS, NOT DECLARATIONS ━━━
|
||||
//
|
||||
// 10 declarations — and 18 top-level statements. window.registerShortcut,
|
||||
// window.createShortcutPanel, getAllShortcuts, unregisterShortcut, clearWindowShortcuts, the
|
||||
// panel registry, and BOTH global keydown dispatchers are all bare statements at app.js's top
|
||||
// level. A dependency scan that walks declarations sees NONE of them, and would have reported
|
||||
// this cluster as 246 lines. It is more than double that.
|
||||
//
|
||||
// That blind spot has now cost twice: it nearly shipped a dead library A-Z rail (#896), and it
|
||||
// threw "Assignment to constant variable" in the session carve (#921), where the autoplay gate's
|
||||
// top-level statements wrote state that had become a read-only import. The extractor takes them
|
||||
// by construction now — any top-level statement that TOUCHES a moved binding comes along.
|
||||
//
|
||||
// window.registerShortcut and friends are a PLUGIN-FACING API. They keep working because app.js
|
||||
// still publishes them; the definitions simply live here, next to the dispatcher they feed.
|
||||
|
||||
import {
|
||||
_lastLibSelected,
|
||||
_libNavItems,
|
||||
_moveSelectionInItems,
|
||||
_providerSupports,
|
||||
_setLibSelection,
|
||||
_toggleHeader,
|
||||
} from './library.js';
|
||||
import {
|
||||
_sectionPracticeBarContains,
|
||||
_sectionPracticePopoverOpen,
|
||||
} from './section-practice.js';
|
||||
import {
|
||||
_trapFocusInModal,
|
||||
esc,
|
||||
} from './dom.js';
|
||||
import {
|
||||
playSong,
|
||||
} from './session.js';
|
||||
import { host } from './host.js';
|
||||
// ── Global keyboard shortcuts ─────────────────────────────────────────────
|
||||
//
|
||||
// `/` focuses the active screen's search input (Library / Favorites);
|
||||
// `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail
|
||||
// convention. The listener bails when the user is already typing in
|
||||
// any text-accepting element so it can't intercept normal typing —
|
||||
// including inputs inside the filters drawer, plugin settings, or
|
||||
// modal dialogs.
|
||||
export function _isTextInput(el) {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT') {
|
||||
// Some <input> types (button, checkbox, radio, range, ...) don't
|
||||
// accept text; only intercept the ones that do.
|
||||
const t = (el.type || 'text').toLowerCase();
|
||||
return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t);
|
||||
}
|
||||
if (tag === 'TEXTAREA') return true;
|
||||
if (tag === 'SELECT') return true;
|
||||
if (el.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _isShortcutHelpKey(e) {
|
||||
return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/'));
|
||||
}
|
||||
|
||||
export function _isShortcutHelpSuppressedTarget(el) {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT') {
|
||||
const t = (el.type || 'text').toLowerCase();
|
||||
return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t);
|
||||
}
|
||||
if (tag === 'TEXTAREA') return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _activeSearchInput() {
|
||||
// Pick the search field for whichever screen is currently active.
|
||||
// No match (e.g. on the player or settings screen) means `/` does
|
||||
// nothing — the shortcut only fires where a search box exists.
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (!active) return null;
|
||||
if (active.id === 'home') return document.getElementById('lib-filter');
|
||||
if (active.id === 'favorites') return document.getElementById('fav-filter');
|
||||
return null;
|
||||
}
|
||||
|
||||
export function _gridColumns(container) {
|
||||
// Count columns by grouping the first row of children by their
|
||||
// top coordinate. Robust against any grid-template-columns syntax
|
||||
// (`repeat(...)`, `auto-fit`, named lines, etc.) where naively
|
||||
// splitting `getComputedStyle().gridTemplateColumns` on whitespace
|
||||
// would miscount because of spaces inside `repeat(...)` /
|
||||
// `minmax(...)`. Falls back to 1 when the container is empty
|
||||
// so callers' max(1, ...) clamps stay valid.
|
||||
if (!container) return 1;
|
||||
const children = Array.from(container.children).filter(
|
||||
c => c && c.offsetParent !== null
|
||||
);
|
||||
if (!children.length) return 1;
|
||||
const firstTop = children[0].getBoundingClientRect().top;
|
||||
let cols = 0;
|
||||
for (const c of children) {
|
||||
// Allow ~1px slop for sub-pixel rounding so two children that
|
||||
// would visually align still group together.
|
||||
if (Math.abs(c.getBoundingClientRect().top - firstTop) < 1.5) cols++;
|
||||
else break;
|
||||
}
|
||||
return Math.max(1, cols);
|
||||
}
|
||||
|
||||
export function _isInsideInteractiveControl(el) {
|
||||
// Bail when the user is interacting with anything that has its
|
||||
// own keyboard semantics — form controls (checkbox / select /
|
||||
// button) consume arrow keys for their own behavior, and the
|
||||
// filters drawer is a focus trap of those. Without this guard the
|
||||
// library's arrow nav would steal arrow presses from a focused
|
||||
// tuning checkbox or sort dropdown.
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _isSpaceKey(e) {
|
||||
return e.key === ' ' || e.key === 'Spacebar';
|
||||
}
|
||||
|
||||
export function _shortcutDispatchBlocked(e) {
|
||||
if (_isTextInput(e.target)) return true;
|
||||
// Space in Section Practice bar should pause/resume, not toggle checkboxes/buttons.
|
||||
if (_isSpaceKey(e) && _sectionPracticeBarContains(e.target)) return false;
|
||||
// While the Section Practice popover is open, Esc just closes it (handled by
|
||||
// the popover's own keydown listener) — suppress the player-scope
|
||||
// "back to library" Esc so the user doesn't get bounced out of the player.
|
||||
if (e.key === 'Escape' && _sectionPracticePopoverOpen()) return true;
|
||||
// Space on the player screen should always play/pause, even if focus is on a
|
||||
// sidebar nav link, player rail button, popover control, or any other
|
||||
// interactive element — the shortcut dispatcher calls preventDefault so the
|
||||
// focused element won't also activate. Two exceptions keep native Space:
|
||||
// text inputs (already exempted above), and focus inside a true modal
|
||||
// dialog (role="dialog" aria-modal="true", or a .feedBack-modal overlay)
|
||||
// layered over the player — a modal traps interaction, so Space must reach
|
||||
// its focused control (e.g. the Close button) rather than toggle playback
|
||||
// behind it. Non-modal player popovers/toasts (loop A/B, arrangement pin,
|
||||
// role="dialog" aria-modal="false") are not modals and stay covered.
|
||||
if (_isSpaceKey(e) && _getCurrentContext().isPlayer &&
|
||||
!(e.target && e.target.closest &&
|
||||
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
|
||||
return false;
|
||||
}
|
||||
// Escape is the universal "back" action and must fire like Space above even
|
||||
// when a transport/rail control <button> holds keyboard focus after a click
|
||||
// — otherwise a focused control swallows Esc and the user can't leave the
|
||||
// song until they click empty canvas (feedBack — "Escape in song not
|
||||
// consistent"). It applies on the player (exit the song) AND settings
|
||||
// (return to the previous screen), both of which register an Escape=Back
|
||||
// shortcut. The earlier guards still win: text inputs are exempted at the
|
||||
// top (Esc there clears/blurs the field), and the Section Practice popover
|
||||
// already claimed Esc above. A true modal layered over the screen still
|
||||
// traps Esc — the modal-overlay check keeps Esc closing the modal rather
|
||||
// than ejecting past it to the screen behind.
|
||||
if (e.key === 'Escape') {
|
||||
const ctx = _getCurrentContext();
|
||||
if ((ctx.isPlayer || ctx.isSettings) &&
|
||||
!(e.target && e.target.closest &&
|
||||
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return _isInsideInteractiveControl(e.target);
|
||||
}
|
||||
|
||||
export function _handleLibArrowNav(e) {
|
||||
// Space (' ') is the standard activation key for focusable
|
||||
// elements alongside Enter — without it, a screen-reader user
|
||||
// hitting Space on a focused card would just scroll the page
|
||||
// instead of activating it. We treat Space identically to Enter
|
||||
// inside this handler.
|
||||
const isActivate = e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar';
|
||||
if (!isActivate &&
|
||||
!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
|
||||
return false;
|
||||
}
|
||||
if (_isInsideInteractiveControl(document.activeElement)) return false;
|
||||
const { items, container, mode } = _libNavItems();
|
||||
if (!items.length) return false;
|
||||
|
||||
const currentTarget = (document.activeElement && items.includes(document.activeElement))
|
||||
? document.activeElement
|
||||
: (_lastLibSelected && items.includes(_lastLibSelected) ? _lastLibSelected : null);
|
||||
|
||||
if (isActivate) {
|
||||
if (!currentTarget) return false;
|
||||
e.preventDefault();
|
||||
// Sync persistent selection before activating so Tab-then-Enter
|
||||
// (no prior arrow nav or mouse click) still lights up the `.selected`
|
||||
// ring and updates `_lastLibSelected`/localStorage — consistent with
|
||||
// the click delegate at the bottom of this file.
|
||||
_setLibSelection(currentTarget, { focus: false });
|
||||
if (currentTarget.classList.contains('song-row') ||
|
||||
currentTarget.classList.contains('song-card')) {
|
||||
if (currentTarget.dataset.librarySong && !currentTarget.dataset.play) {
|
||||
const providerId = decodeURIComponent(currentTarget.dataset.libraryProvider || '');
|
||||
if (!_providerSupports(providerId, 'song.sync')) return true;
|
||||
host.syncLibrarySong(
|
||||
providerId,
|
||||
decodeURIComponent(currentTarget.dataset.librarySong || ''),
|
||||
{ playWhenReady: true },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Song row OR card → play it. Pass `dataset.play` raw to
|
||||
// match the click delegate; `playSong` handles decoding
|
||||
// internally so decoding here would double-decode and
|
||||
// throw `URIError` on filenames containing `%`.
|
||||
playSong(currentTarget.dataset.play, undefined, { bridge: false });
|
||||
} else if (currentTarget.classList.contains('artist-header') ||
|
||||
currentTarget.classList.contains('album-header')) {
|
||||
// Header row → toggle the parent open/closed and re-derive
|
||||
// visible items so the next arrow press lands correctly.
|
||||
// `_toggleHeader` keeps `aria-expanded` in sync for
|
||||
// assistive tech.
|
||||
_toggleHeader(currentTarget);
|
||||
// Keep keyboard focus on the header we just toggled —
|
||||
// browsers sometimes drop focus to body when the
|
||||
// surrounding subtree changes display.
|
||||
currentTarget.focus({ preventScroll: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Home') { e.preventDefault(); _setLibSelection(items[0]); return true; }
|
||||
if (e.key === 'End') { e.preventDefault(); _setLibSelection(items[items.length - 1]); return true; }
|
||||
|
||||
if (mode === 'list') {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveSelectionInItems(items, 1); return true; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveSelectionInItems(items, -1); return true; }
|
||||
// Right/Left expand and collapse the artist/album under focus,
|
||||
// file-manager style. With nothing selected yet, both keys
|
||||
// initialize selection on the first visible item (matches
|
||||
// Up/Down behavior in `_moveSelectionInItems`) so the first
|
||||
// press doesn't fall through to native scroll.
|
||||
if (!currentTarget && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
|
||||
e.preventDefault();
|
||||
_setLibSelection(items[0]);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowRight' && currentTarget) {
|
||||
const parent = (currentTarget.classList.contains('artist-header') ||
|
||||
currentTarget.classList.contains('album-header'))
|
||||
? currentTarget.parentElement : null;
|
||||
if (parent && !parent.classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
// Use the shared toggle path so aria-expanded stays
|
||||
// synced with the visual state for screen readers.
|
||||
_toggleHeader(currentTarget);
|
||||
currentTarget.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
// Already open — step to the next visible item (which is
|
||||
// the first child of this header).
|
||||
e.preventDefault();
|
||||
_moveSelectionInItems(items, 1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && currentTarget) {
|
||||
// If on an open header, collapse it. If on a song row or
|
||||
// closed header, jump to the nearest enclosing header.
|
||||
const isHeader = currentTarget.classList.contains('artist-header') ||
|
||||
currentTarget.classList.contains('album-header');
|
||||
const headerParent = isHeader ? currentTarget.parentElement : null;
|
||||
if (headerParent && headerParent.classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
_toggleHeader(currentTarget);
|
||||
currentTarget.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
// Walk up to the nearest .album-header / .artist-header
|
||||
// ancestor's sibling header. Closest album-group → its
|
||||
// header; otherwise closest artist-row → its header.
|
||||
const albumGroup = currentTarget.closest('.album-group');
|
||||
if (albumGroup && albumGroup.contains(currentTarget) &&
|
||||
!currentTarget.classList.contains('album-header')) {
|
||||
e.preventDefault();
|
||||
_setLibSelection(albumGroup.querySelector('.album-header'));
|
||||
return true;
|
||||
}
|
||||
const artistRow = currentTarget.closest('.artist-row');
|
||||
if (artistRow && !currentTarget.classList.contains('artist-header')) {
|
||||
e.preventDefault();
|
||||
_setLibSelection(artistRow.querySelector('.artist-header'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Grid mode: 2D nav. Columns are read from the live CSS grid so
|
||||
// we follow the responsive breakpoints automatically.
|
||||
const cols = _gridColumns(container);
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); _moveSelectionInItems(items, 1); return true; }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); _moveSelectionInItems(items, -1); return true; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveSelectionInItems(items, cols); return true; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveSelectionInItems(items, -cols); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shortcut cheat-sheet overlay. Opens on `?` (Shift+/), closes on
|
||||
// Esc (handled by the generic modal close path) or on backdrop /
|
||||
// close-button click. The list mirrors the canonical shortcut table
|
||||
// in this file's keydown handler — when a shortcut changes here, the
|
||||
// table below should change too. We keep it inline rather than
|
||||
// fetching a separate file so the cheat sheet can never disagree
|
||||
// with the version of app.js the user actually loaded.
|
||||
export function _openShortcutsModal() {
|
||||
if (document.getElementById('shortcuts-modal')) return;
|
||||
|
||||
function _isTreeMode() {
|
||||
// Check if we're in tree view (not grid) on the active library screen
|
||||
const screen = document.querySelector('.screen.active');
|
||||
if (!screen) return false;
|
||||
const tree = screen.querySelector('#lib-tree,#fav-tree');
|
||||
return tree && !tree.classList.contains('hidden');
|
||||
}
|
||||
|
||||
const ctx = _getCurrentContext();
|
||||
|
||||
// Library shortcuts that are handled by the navigation system (not in registry)
|
||||
const navShortcuts = [
|
||||
{ keys: '↑ ↓', desc: 'Move selection' },
|
||||
{ keys: '→', desc: 'Step in', condition: _isTreeMode },
|
||||
{ keys: '←', desc: 'Step out', condition: _isTreeMode },
|
||||
{ keys: 'Home / End', desc: 'Jump to first / last item' },
|
||||
{ keys: 'Enter / Space', desc: 'Activate selection (play song / toggle header)' },
|
||||
];
|
||||
|
||||
// Filter out items whose condition returns false
|
||||
const filterNavItems = (items) => items.filter(item => !item.condition || item.condition());
|
||||
|
||||
// Format a shortcut entry for display, including modifier prefixes
|
||||
const formatShortcut = (s) => {
|
||||
const mods = s.modifiers || {};
|
||||
let label = '';
|
||||
if (mods.ctrl) label += 'Ctrl+';
|
||||
if (mods.alt) label += 'Alt+';
|
||||
if (mods.shift) label += 'Shift+';
|
||||
if (mods.meta) label += 'Meta+';
|
||||
return label + s.key;
|
||||
};
|
||||
|
||||
// Get shortcuts from active panel by scope
|
||||
const getPanelShortcuts = (panel, scope) => {
|
||||
const shortcuts = [];
|
||||
for (const [key, s] of panel.shortcuts) {
|
||||
if (s.scope === scope) {
|
||||
shortcuts.push({ keys: formatShortcut(s), desc: s.description });
|
||||
}
|
||||
}
|
||||
return shortcuts;
|
||||
};
|
||||
|
||||
const activePanel = _panels.get(_activePanel);
|
||||
const defaultPanel = _panels.get('default');
|
||||
|
||||
// Merge shortcuts from both active and default panel for display
|
||||
const mergeShortcuts = (scope) => {
|
||||
const result = [];
|
||||
if (activePanel) result.push(...getPanelShortcuts(activePanel, scope));
|
||||
if (defaultPanel && defaultPanel !== activePanel) result.push(...getPanelShortcuts(defaultPanel, scope));
|
||||
return result;
|
||||
};
|
||||
|
||||
const playerShortcuts = mergeShortcuts('player');
|
||||
const globalShortcuts = mergeShortcuts('global');
|
||||
const libraryShortcuts = mergeShortcuts('library');
|
||||
|
||||
// Get plugin shortcuts for current plugin screen
|
||||
const pluginShortcuts = [];
|
||||
if (ctx.isPlugin && activePanel) {
|
||||
for (const [key, s] of activePanel.shortcuts) {
|
||||
if (s.scope.startsWith('plugin-') && s.scope === ctx.screen) {
|
||||
pluginShortcuts.push({ keys: formatShortcut(s), desc: s.description });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get shortcuts from other panels (if multiple panels exist)
|
||||
const otherPanelShortcuts = [];
|
||||
if (_panels.size > 1) {
|
||||
for (const [panelId, panel] of _panels) {
|
||||
if (panelId === _activePanel) continue;
|
||||
for (const [key, s] of panel.shortcuts) {
|
||||
otherPanelShortcuts.push({ keys: formatShortcut(s), desc: s.description, panel: panelId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build sections based on current context
|
||||
const sections = [];
|
||||
if (ctx.isSettings) {
|
||||
sections.push({ heading: 'Settings', items: mergeShortcuts('settings') });
|
||||
} else if (ctx.isLibrary) {
|
||||
sections.push({ heading: 'Library', items: [
|
||||
...filterNavItems(navShortcuts),
|
||||
...libraryShortcuts,
|
||||
{ keys: 'Esc', desc: 'Clear search' }
|
||||
]});
|
||||
}
|
||||
if (ctx.isPlayer) {
|
||||
sections.push({ heading: 'Player', items: playerShortcuts });
|
||||
}
|
||||
if (!ctx.isSettings && globalShortcuts.length > 0) {
|
||||
sections.push({ heading: 'Global', items: globalShortcuts });
|
||||
}
|
||||
if (pluginShortcuts.length > 0) {
|
||||
sections.push({ heading: 'Current Plugin', items: pluginShortcuts });
|
||||
}
|
||||
if (otherPanelShortcuts.length > 0) {
|
||||
// Group other panel shortcuts by panel
|
||||
const byPanel = new Map();
|
||||
for (const item of otherPanelShortcuts) {
|
||||
if (!byPanel.has(item.panel)) {
|
||||
byPanel.set(item.panel, []);
|
||||
}
|
||||
byPanel.get(item.panel).push(item);
|
||||
}
|
||||
for (const [panelId, items] of byPanel) {
|
||||
sections.push({ heading: `Panel ${panelId}`, items });
|
||||
}
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'shortcuts-modal';
|
||||
modal.className = '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');
|
||||
modal.setAttribute('aria-label', 'Keyboard shortcuts');
|
||||
// Record the element that triggered the modal so Esc / close can
|
||||
// return focus to the correct entry even if _lastLibSelected drifts.
|
||||
// Scope to the active screen so a stale _lastLibSelected from a
|
||||
// different screen (e.g. Library vs Favorites) doesn't receive focus.
|
||||
const _scModal = document.querySelector('.screen.active');
|
||||
modal._opener = (_lastLibSelected && document.body.contains(_lastLibSelected)
|
||||
&& _scModal && _scModal.contains(_lastLibSelected))
|
||||
? _lastLibSelected : null;
|
||||
|
||||
const sectionsHtml = sections.map(section => {
|
||||
const itemsHtml = section.items.map(({ keys, desc }) => `
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-sm text-gray-300">${esc(desc)}</span>
|
||||
<kbd class="text-xs font-mono px-2 py-0.5 rounded bg-dark-600 border border-gray-700 text-gray-200 whitespace-nowrap">${esc(keys)}</kbd>
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<section class="mb-4 last:mb-0">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">${esc(section.heading)}</h4>
|
||||
${itemsHtml}
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-md mx-4 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-white">Keyboard shortcuts</h3>
|
||||
<button type="button" data-shortcuts-close
|
||||
class="text-gray-500 hover:text-white transition flex items-center gap-1.5" aria-label="Close shortcuts">
|
||||
<span class="text-xs text-gray-600">Esc</span>
|
||||
<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>
|
||||
${sectionsHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click outside the inner panel (i.e. on the backdrop) closes the
|
||||
// modal — matches the conventional dialog UX.
|
||||
modal.addEventListener('click', (ev) => {
|
||||
if (ev.target === modal || ev.target.closest('[data-shortcuts-close]')) {
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
// Move focus into the dialog so background shortcuts (and arrow
|
||||
// nav) can't fire on the underlying library entry while the
|
||||
// overlay is open. Close button is the safe default — there's no
|
||||
// primary input to focus on a read-only cheat sheet.
|
||||
const closeBtn = modal.querySelector('[data-shortcuts-close]');
|
||||
if (closeBtn) closeBtn.focus({ preventScroll: true });
|
||||
// Trap Tab / Shift+Tab inside the modal so focus can't escape to
|
||||
// the library content underneath while the overlay is open.
|
||||
_trapFocusInModal(modal);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Modifier-key combos belong to the browser / OS shortcuts; never
|
||||
// intercept those.
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
if (_handleLibArrowNav(e)) return;
|
||||
|
||||
// `?` (Shift+/) opens the keyboard-shortcuts cheat sheet. Some
|
||||
// Linux/Electron stacks report Shift+/ as key='/' with code='Slash',
|
||||
// so check the help shape before treating plain '/' as search.
|
||||
if (_isShortcutHelpKey(e)) {
|
||||
if (_isShortcutHelpSuppressedTarget(e.target || document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
// Stop other keydown listeners on document (notably the shortcut
|
||||
// registry below) from also consuming this event — otherwise a
|
||||
// Linux/Electron Shift+Slash reported as key='/' opens help here and
|
||||
// then the registry's plain `/` library-search shortcut focuses
|
||||
// #lib-filter behind the modal. (Copilot review on #602.)
|
||||
e.stopImmediatePropagation();
|
||||
_openShortcutsModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '/') {
|
||||
if (_isTextInput(document.activeElement)) return;
|
||||
// Also bail when focus is inside the filter drawer, a dialog, or
|
||||
// any other interactive region — those contexts have their own
|
||||
// keyboard semantics and shouldn't be hijacked by the search
|
||||
// shortcut (e.g. a focused checkbox inside the filters drawer).
|
||||
if (_isInsideInteractiveControl(document.activeElement)) return;
|
||||
const search = _activeSearchInput();
|
||||
if (!search) return;
|
||||
e.preventDefault(); // suppress the literal '/' the input would receive
|
||||
search.focus();
|
||||
// Move caret to end without mutating .value — round-tripping
|
||||
// the value resets the browser's undo stack and can fire
|
||||
// unexpected input events on some engines. setSelectionRange
|
||||
// is the no-side-effects path.
|
||||
try {
|
||||
const len = search.value.length;
|
||||
search.setSelectionRange(len, len);
|
||||
} catch {
|
||||
// Some input types (search/email/tel) don't support
|
||||
// selection APIs in older browsers; the focus alone is
|
||||
// still useful, just no caret-end guarantee.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-letter shortcuts that act on the focused / selected
|
||||
// library entry — works on both grid cards and tree rows. Each
|
||||
// dispatches to a button class that the entry markup already
|
||||
// exposes, so plugins can keep owning the actual behavior:
|
||||
// f → .fav-btn (favorite heart toggle)
|
||||
// e → .edit-btn (edit metadata modal)
|
||||
// No-op when no entry is currently focused / selected, when the
|
||||
// entry doesn't expose the requested button, or when the button is disabled.
|
||||
// Bails on text input / drawer focus so single-letter typing in
|
||||
// inputs still works.
|
||||
const entryShortcut = { f: 'button.fav-btn', e: 'button.edit-btn' }[e.key.toLowerCase()];
|
||||
if (entryShortcut) {
|
||||
if (_isInsideInteractiveControl(document.activeElement)) return;
|
||||
const ae = document.activeElement;
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
const isEntry = el => el && el.classList && (el.classList.contains('song-card') || el.classList.contains('song-row'));
|
||||
// Scope both candidates to the active screen so that a stale
|
||||
// _lastLibSelected from Library doesn't fire when the user is
|
||||
// on Favorites (or vice-versa), and so pressing f/e/c on a
|
||||
// hidden screen can't accidentally persist that filename into
|
||||
// the current screen's localStorage key.
|
||||
const inActiveScreen = el => activeScreen && activeScreen.contains(el);
|
||||
const target = (isEntry(ae) && inActiveScreen(ae)) ? ae
|
||||
: (isEntry(_lastLibSelected) && inActiveScreen(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (!target) return;
|
||||
const btn = target.querySelector(entryShortcut);
|
||||
if (!btn || btn.disabled) return;
|
||||
e.preventDefault();
|
||||
// Sync the persistent selection to the acted-on entry so that
|
||||
// Esc-to-close-modal returns focus to the correct element and
|
||||
// the `.selected` highlight stays consistent with the action.
|
||||
_setLibSelection(target, { focus: false });
|
||||
btn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// Modal-first: close the topmost open modal (edit-metadata,
|
||||
// shortcuts cheat sheet, future modals) so Esc dismisses
|
||||
// from anywhere — including when keyboard focus is inside
|
||||
// a form field within the modal. Restores focus to the
|
||||
// element that opened the modal (tracked in modal._opener)
|
||||
// so arrow nav resumes without an extra Tab; falls back to
|
||||
// _lastLibSelected when the opener is no longer in the DOM.
|
||||
const modals = document.querySelectorAll('[role="dialog"][aria-modal="true"].feedBack-modal');
|
||||
if (modals.length) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const modal = modals[modals.length - 1];
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
// Esc while typing in either search box clears + blurs. Other Esc
|
||||
// semantics (drawer close, screen back) are handled elsewhere; we
|
||||
// only act when a search box is the focused element.
|
||||
const ae = document.activeElement;
|
||||
if (ae && (ae.id === 'lib-filter' || ae.id === 'fav-filter')) {
|
||||
if (ae.value) {
|
||||
ae.value = '';
|
||||
ae.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
ae.blur();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export class ShortcutPanel {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.shortcuts = new Map();
|
||||
}
|
||||
|
||||
_compositeKey(key, scope) {
|
||||
return `${scope}::${key}`;
|
||||
}
|
||||
|
||||
registerShortcut(options) {
|
||||
const { key, description, scope = 'global', condition = null, handler, modifiers = null } = options;
|
||||
|
||||
if (!key || !handler) {
|
||||
console.error(`registerShortcut: key and handler are required`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
const validScopes = ['global', 'player', 'library', 'settings'];
|
||||
const isValidScope = validScopes.includes(scope) ||
|
||||
scope.startsWith('plugin-');
|
||||
if (!isValidScope) {
|
||||
console.warn(`registerShortcut: invalid scope '${scope}'. Valid scopes are: global, player, library, settings, or plugin-{id}`);
|
||||
}
|
||||
|
||||
// Conflict detection: warn if key+scope is already registered
|
||||
const compositeKey = this._compositeKey(key, scope);
|
||||
if (this.shortcuts.has(compositeKey)) {
|
||||
console.warn(`registerShortcut [${this.id}]: '${key}' in scope '${scope}' is already registered; overwriting. Previous:`, this.shortcuts.get(compositeKey));
|
||||
}
|
||||
|
||||
this.shortcuts.set(compositeKey, { key, description, scope, condition, handler, modifiers });
|
||||
}
|
||||
|
||||
unregisterShortcut(key, scope) {
|
||||
return this.shortcuts.delete(this._compositeKey(key, scope));
|
||||
}
|
||||
|
||||
clearShortcuts() {
|
||||
this.shortcuts.clear();
|
||||
}
|
||||
|
||||
listShortcuts() {
|
||||
return Array.from(this.shortcuts.entries()).map(([ck, s]) => [s.key, s]);
|
||||
}
|
||||
}
|
||||
|
||||
// Global panel management
|
||||
export const _panels = new Map();
|
||||
|
||||
export let _activePanel = null;
|
||||
|
||||
export let _defaultPanel = null;
|
||||
|
||||
// Create default panel on init
|
||||
export const defaultPanel = new ShortcutPanel('default');
|
||||
|
||||
_panels.set('default', defaultPanel);
|
||||
|
||||
_defaultPanel = 'default';
|
||||
|
||||
_activePanel = 'default';
|
||||
|
||||
window.createShortcutPanel = (id) => {
|
||||
if (_panels.has(id)) {
|
||||
console.warn(`createShortcutPanel: panel '${id}' already exists`);
|
||||
return _panels.get(id);
|
||||
}
|
||||
const panel = new ShortcutPanel(id);
|
||||
_panels.set(id, panel);
|
||||
return panel;
|
||||
};
|
||||
|
||||
window.setActiveShortcutPanel = (id) => {
|
||||
if (!_panels.has(id)) {
|
||||
console.error(`setActiveShortcutPanel: panel '${id}' does not exist`);
|
||||
return;
|
||||
}
|
||||
_activePanel = id;
|
||||
};
|
||||
|
||||
window.getActiveShortcutPanel = () => _activePanel;
|
||||
|
||||
window.isInShortcutPanel = () => {
|
||||
return _activePanel !== 'default';
|
||||
};
|
||||
|
||||
window.getGlobalShortcutContext = () => {
|
||||
console.warn('getGlobalShortcutContext: Global shortcuts are exceptional. Consider using panel-scoped shortcuts instead.');
|
||||
return _panels.get('default');
|
||||
};
|
||||
|
||||
window.registerShortcut = (options) => {
|
||||
const panelId = _activePanel || _defaultPanel || 'default';
|
||||
const panel = _panels.get(panelId);
|
||||
|
||||
if (!panel) {
|
||||
console.error(`registerShortcut: No panel found for registration: ${panelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
panel.registerShortcut(options);
|
||||
};
|
||||
|
||||
// Flat, read-only snapshot of every registered shortcut across all panels,
|
||||
// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same
|
||||
// shortcut can live in both the active panel and the default panel) and uses
|
||||
// the same modifier-prefix formatting as the shortcuts modal. Returns
|
||||
// [{ combo, description, scope }]; remapping is not supported, so this is
|
||||
// purely informational.
|
||||
window.getAllShortcuts = () => {
|
||||
const fmt = (s) => {
|
||||
const m = s.modifiers || {};
|
||||
return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '')
|
||||
+ (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key;
|
||||
};
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const [, panel] of _panels) {
|
||||
if (!panel || !panel.shortcuts) continue;
|
||||
for (const [, s] of panel.shortcuts) {
|
||||
const combo = fmt(s);
|
||||
const dedupe = combo + '|' + (s.scope || '');
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
out.push({ combo, description: s.description || '', scope: s.scope || 'global' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
window.unregisterShortcut = (key, scope) => {
|
||||
// Try the active panel first to preserve panel isolation; fall back to
|
||||
// other panels so a shortcut registered before a panel switch is still
|
||||
// removable.
|
||||
const resolvedScope = scope || 'global';
|
||||
const activePanelId = _activePanel || _defaultPanel || 'default';
|
||||
const activePanel = _panels.get(activePanelId);
|
||||
if (activePanel && activePanel.unregisterShortcut(key, resolvedScope)) {
|
||||
return true;
|
||||
}
|
||||
for (const [panelId, panel] of _panels) {
|
||||
if (panelId === activePanelId) continue;
|
||||
if (panel.unregisterShortcut(key, resolvedScope)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
window.clearWindowShortcuts = (windowId) => {
|
||||
// Remove all shortcuts registered for a specific window
|
||||
// This is for backward compatibility with window-specific shortcuts
|
||||
let removed = 0;
|
||||
for (const [panelId, panel] of _panels) {
|
||||
if (panelId.startsWith(`window-${windowId}`)) {
|
||||
panel.clearShortcuts();
|
||||
_panels.delete(panelId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export function _getCurrentContext() {
|
||||
const currentScreen = document.querySelector('.screen.active')?.id;
|
||||
return {
|
||||
screen: currentScreen,
|
||||
windowId: window.getShortcutWindowId(),
|
||||
activePanel: _activePanel,
|
||||
isPlayer: currentScreen === 'player',
|
||||
isLibrary: ['home', 'favorites'].includes(currentScreen),
|
||||
isSettings: currentScreen === 'settings',
|
||||
isPlugin: currentScreen?.startsWith('plugin-')
|
||||
};
|
||||
}
|
||||
|
||||
export function _isShortcutActive(shortcut, ctx) {
|
||||
if (shortcut.scope === 'global') return true;
|
||||
if (shortcut.scope === 'player' && ctx.isPlayer) return true;
|
||||
if (shortcut.scope === 'library' && ctx.isLibrary) return true;
|
||||
if (shortcut.scope === 'settings' && ctx.isSettings) return true;
|
||||
if (shortcut.scope.startsWith('plugin-')) {
|
||||
const pluginId = shortcut.scope.replace('plugin-', '');
|
||||
return ctx.screen === `plugin-${pluginId}`;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _modifiersMatch(e, modifiers) {
|
||||
if (!modifiers) return true;
|
||||
if (modifiers.ctrl !== undefined && modifiers.ctrl !== e.ctrlKey) return false;
|
||||
if (modifiers.alt !== undefined && modifiers.alt !== e.altKey) return false;
|
||||
if (modifiers.shift !== undefined && modifiers.shift !== e.shiftKey) return false;
|
||||
if (modifiers.meta !== undefined && modifiers.meta !== e.metaKey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Debug mode for keyboard shortcuts
|
||||
export let _DEBUG_SHORTCUTS = false;
|
||||
|
||||
window._setDebugShortcuts = (enabled) => {
|
||||
_DEBUG_SHORTCUTS = enabled;
|
||||
console.log(`[Shortcuts] Debug mode ${enabled ? 'ENABLED' : 'DISABLED'}`);
|
||||
};
|
||||
|
||||
window._listShortcuts = () => {
|
||||
console.log('=== Registered Shortcuts ===');
|
||||
for (const [panelId, panel] of _panels) {
|
||||
console.log(`Panel: ${panelId}`);
|
||||
for (const [, s] of panel.shortcuts) {
|
||||
console.log(` ${s.key.padEnd(15)} | ${s.scope.padEnd(10)} | ${s.description}`);
|
||||
}
|
||||
}
|
||||
console.log('=== End ===');
|
||||
};
|
||||
|
||||
window._testShortcut = (key, scope) => {
|
||||
// Mirror the dispatcher: try the active panel first, then default.
|
||||
const resolvedScope = scope || 'global';
|
||||
const tried = new Set();
|
||||
const panelOrder = [_activePanel, _defaultPanel, 'default'].filter(id => {
|
||||
if (!id || tried.has(id)) return false;
|
||||
tried.add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const panelId of panelOrder) {
|
||||
const panel = _panels.get(panelId);
|
||||
if (!panel) continue;
|
||||
const shortcut = panel.shortcuts.get(panel._compositeKey(key, resolvedScope));
|
||||
if (!shortcut) continue;
|
||||
|
||||
const ctx = _getCurrentContext();
|
||||
const active = _isShortcutActive(shortcut, ctx);
|
||||
let conditionMet = true;
|
||||
if (shortcut.condition) {
|
||||
try { conditionMet = !!shortcut.condition(); }
|
||||
catch (err) { conditionMet = `threw: ${err.message}`; }
|
||||
}
|
||||
console.log(`Shortcut '${key}' [${resolvedScope}] [${panelId}]:`, {
|
||||
description: shortcut.description,
|
||||
scope: shortcut.scope,
|
||||
currentContext: ctx,
|
||||
isActive: active,
|
||||
conditionMet
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Shortcut '${key}' (scope: ${resolvedScope}) not registered in any panel`);
|
||||
};
|
||||
|
||||
// Expose internals for debugging (prefixed with _ to indicate private)
|
||||
// These are for development/debugging only and should not be used by plugins.
|
||||
window._panels = _panels;
|
||||
|
||||
window._getCurrentContext = _getCurrentContext;
|
||||
|
||||
window._isShortcutActive = _isShortcutActive;
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (_shortcutDispatchBlocked(e)) return;
|
||||
|
||||
const ctx = _getCurrentContext();
|
||||
const activePanel = _panels.get(_activePanel);
|
||||
const defaultPanel = _panels.get('default');
|
||||
|
||||
if (!activePanel && !defaultPanel) return;
|
||||
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Key pressed:', { key: e.key, code: e.code, ctx, activePanel: _activePanel });
|
||||
}
|
||||
|
||||
// Try active panel first, then fall back to default
|
||||
const panelsToDispatch = [];
|
||||
if (activePanel && activePanel !== defaultPanel) panelsToDispatch.push(activePanel);
|
||||
if (defaultPanel) panelsToDispatch.push(defaultPanel);
|
||||
|
||||
for (const panel of panelsToDispatch) {
|
||||
for (const [, shortcut] of panel.shortcuts) {
|
||||
// Match on both e.key (character produced) and e.code (physical key)
|
||||
if (e.key !== shortcut.key && e.code !== shortcut.key) continue;
|
||||
|
||||
// Check modifier keys if specified
|
||||
if (!_modifiersMatch(e, shortcut.modifiers)) continue;
|
||||
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Matched shortcut:', shortcut.key, shortcut);
|
||||
}
|
||||
|
||||
// Check scope
|
||||
if (!_isShortcutActive(shortcut, ctx)) {
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Not active - scope mismatch:', shortcut.scope, ctx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check condition callback — guard against plugin errors
|
||||
if (shortcut.condition) {
|
||||
try {
|
||||
if (!shortcut.condition()) {
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Not active - condition failed');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Shortcuts] condition() threw for key:', shortcut.key, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Executing handler for:', shortcut.key);
|
||||
}
|
||||
// Guard handler against plugin errors
|
||||
try {
|
||||
shortcut.handler(e);
|
||||
} catch (err) {
|
||||
console.error('[Shortcuts] handler() threw for key:', shortcut.key, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] No shortcut matched for:', e.key, e.code);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
const windowId = window.getShortcutWindowId();
|
||||
const removed = window.clearWindowShortcuts(windowId);
|
||||
if (removed > 0 && _DEBUG_SHORTCUTS) {
|
||||
console.log(`[Shortcuts] Cleaned up ${removed} shortcuts for window ${windowId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Global shortcuts
|
||||
registerShortcut({
|
||||
key: '?',
|
||||
description: 'Show keyboard shortcuts',
|
||||
scope: 'global',
|
||||
handler: () => _openShortcutsModal()
|
||||
});
|
||||
|
||||
// Library shortcuts
|
||||
registerShortcut({
|
||||
key: '/',
|
||||
description: 'Focus search',
|
||||
scope: 'library',
|
||||
handler: () => {
|
||||
const input = _activeSearchInput();
|
||||
if (input) input.focus();
|
||||
}
|
||||
});
|
||||
@@ -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: window.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 (window.highway && typeof window.highway.setTime === 'function') {
|
||||
window.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 window.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 window.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') {
|
||||
window.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';
|
||||
window.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';
|
||||
window.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 — window.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 window.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 window.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 window.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 window.highway?.getSongInfo === 'function')
|
||||
? (window.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 window.highway?.getSongInfo === 'function')
|
||||
? (window.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 window.highway?.getSongInfo === 'function')
|
||||
? (window.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 window.highway. setRenderer(null) is
|
||||
// a no-op when the default is already active. If the previous Auto
|
||||
// pick was a WebGL renderer, window.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.
|
||||
window.highway.setRenderer(null);
|
||||
_notifyVizDomain('default', 'auto-match');
|
||||
_noteVizAutoMatch('default', false);
|
||||
// Update the label so the user can see Auto resolved to the built-in
|
||||
// window.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'}).`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* fee[dB]ack — the pop-out chip.
|
||||
*
|
||||
* One affordance, core-owned, identical everywhere: the small ⇱ button a plugin
|
||||
* drops into the panel it already has.
|
||||
*
|
||||
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
|
||||
* feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||
*
|
||||
* That is the entire adoption cost. Clicking the chip pops the panel out; a stub
|
||||
* takes its place so the user can find it again; closing the pane brings the panel
|
||||
* home and restores the chip. The plugin writes no show/hide logic — if it did,
|
||||
* every plugin would invent a slightly different one, which is exactly the
|
||||
* inconsistency this exists to prevent.
|
||||
*
|
||||
* The panel a chip is attached to is USUALLY the very element the pane moves into
|
||||
* the pop-out window — so most of the time there is nothing here left to hide, and
|
||||
* the job is simply to mark the hole it left. Hiding it would in fact be actively
|
||||
* harmful: `.fb-pane-detached` is `display:none !important`, and it would travel
|
||||
* with the node straight into the pane window and blank it.
|
||||
*
|
||||
* When the chip IS attached to something the pane didn't take (a wrapper, a
|
||||
* launcher row), that element stays put and is hidden with `.fb-pane-detached` —
|
||||
* a dedicated class, not `.hidden`/[hidden], because the panels we attach to
|
||||
* already toggle those themselves.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.register !== 'function') {
|
||||
console.error('[panes] pane-manager.js must load before pane-chip.js');
|
||||
return;
|
||||
}
|
||||
|
||||
// paneId -> { el, chip, stub, spec }
|
||||
const attached = new Map();
|
||||
|
||||
function _makeChip(spec) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'fb-pane-chip';
|
||||
b.title = 'Pop out';
|
||||
b.setAttribute('aria-label', 'Pop out ' + spec.title);
|
||||
b.textContent = '⇱';
|
||||
b.addEventListener('click', (e) => {
|
||||
// Rail popovers close on any document click that lands outside them
|
||||
// (player-chrome.js). Without this the popover would close under the
|
||||
// chip mid-click, which reads as the button not working.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
panes.detach(spec.id);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
function _makeStub(spec) {
|
||||
const s = document.createElement('button');
|
||||
s.type = 'button';
|
||||
s.className = 'fb-pane-stub';
|
||||
s.setAttribute('aria-label', 'Bring ' + spec.title + ' back');
|
||||
s.title = 'Bring it back';
|
||||
const glyph = document.createElement('span');
|
||||
glyph.className = 'fb-pane-stub-glyph';
|
||||
glyph.textContent = '⇲';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = spec.title + ' is popped out';
|
||||
s.appendChild(glyph);
|
||||
s.appendChild(label);
|
||||
s.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
panes.close(spec.id);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
// The pane is out. Leave a stub where its panel used to be.
|
||||
//
|
||||
// The subtlety: the panel a chip is attached to is USUALLY the very element the
|
||||
// pane moved into the pop-out window. It is no longer in this document at all —
|
||||
// so hiding it would be worse than pointless (the `display:none` travels with
|
||||
// the node and blanks the pane window, which is exactly the bug this fixes), and
|
||||
// the stub cannot be inserted "before it", because it is not here to be before.
|
||||
//
|
||||
// Hence `home`: the manager tells us where the element used to live, and the
|
||||
// stub goes there. If the chip is attached to something the pane did NOT take —
|
||||
// a wrapper, a launcher row — that element is still here, and we hide it as
|
||||
// before.
|
||||
function _onOpened(rec, detail) {
|
||||
// Did the pane take MY element?
|
||||
//
|
||||
// Ask the manager, which knows exactly what it handed to the host. Do not
|
||||
// try to infer it from the element:
|
||||
//
|
||||
// - `isConnected` says "still here" for a panel sitting in a pane window.
|
||||
// It IS connected — to that window.
|
||||
// - `ownerDocument` says "still here" for a panel moved into the DOCK,
|
||||
// which is in this very document. Hiding it there would blank a pane the
|
||||
// user is looking at.
|
||||
//
|
||||
// Both were live bugs. The manager's answer is the only one that holds for
|
||||
// every host, and it works when reconciling after the fact (detail == null),
|
||||
// which is what a plugin rebuilding its panel mid-pop-out triggers.
|
||||
const takenEl = (detail && detail.el) || panes.elementOf(rec.spec.id);
|
||||
const moved = takenEl === rec.el;
|
||||
|
||||
if (!moved && rec.el.isConnected) {
|
||||
rec.el.classList.add('fb-pane-detached');
|
||||
if (!rec.stub.isConnected && rec.el.parentNode) rec.el.parentNode.insertBefore(rec.stub, rec.el);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the hole the element left. `home` comes with the event, or from the
|
||||
// manager when we are reconciling after the fact.
|
||||
const home = (detail && detail.home) || panes.homeOf(rec.spec.id);
|
||||
if (!rec.stub.isConnected && home && home.parent && home.parent.isConnected) {
|
||||
const next = (home.next && home.next.parentNode === home.parent) ? home.next : null;
|
||||
home.parent.insertBefore(rec.stub, next);
|
||||
}
|
||||
}
|
||||
|
||||
function _onClosed(rec) {
|
||||
// The element is back. Whatever we did to hide it, undo — including a class
|
||||
// it might have carried out of the document and back.
|
||||
rec.el.classList.remove('fb-pane-detached');
|
||||
rec.stub.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* attachChip(el, paneId, opts)
|
||||
*
|
||||
* `el` — the dialog to hide when the pane pops out. The chip is injected
|
||||
* into `el.querySelector('[data-pane-header]')` when present, else
|
||||
* prepended to `el` itself.
|
||||
* `opts` — { header: Element } to place the chip somewhere specific.
|
||||
*
|
||||
* Returns a detach function that removes the chip and stub and restores the
|
||||
* dialog — call it if your plugin tears its dialog down.
|
||||
*/
|
||||
function attachChip(el, paneId, opts) {
|
||||
opts = opts || {};
|
||||
if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element');
|
||||
// Validate here, not at the insertBefore below. This is a public plugin API,
|
||||
// and a truthy non-Element `header` (a selector string, a jQuery-ish wrapper,
|
||||
// a ref object) is an easy mistake to make — one that would otherwise surface
|
||||
// as a confusing DOM exception from deep inside core.
|
||||
if (opts.header != null && !(opts.header instanceof Element)) {
|
||||
throw new TypeError('panes.attachChip(' + paneId + '): opts.header must be an Element');
|
||||
}
|
||||
const spec = panes.get(paneId);
|
||||
if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; }
|
||||
if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; }
|
||||
|
||||
const chip = _makeChip(spec);
|
||||
const stub = _makeStub(spec);
|
||||
const host = opts.header || el.querySelector('[data-pane-header]') || el;
|
||||
if (host === el) host.insertBefore(chip, host.firstChild);
|
||||
else host.appendChild(chip);
|
||||
|
||||
const rec = { el, chip, stub, spec };
|
||||
attached.set(paneId, rec);
|
||||
|
||||
// Reconcile immediately: register() reopens a pane the user left open at
|
||||
// last unload, and that can land before (or after) attachChip runs.
|
||||
if (panes.isOpen(paneId)) _onOpened(rec, null);
|
||||
|
||||
return () => {
|
||||
if (attached.get(paneId) !== rec) return;
|
||||
attached.delete(paneId);
|
||||
chip.remove();
|
||||
_onClosed(rec);
|
||||
};
|
||||
}
|
||||
|
||||
// One pair of bus listeners for every chip, rather than one pair per chip.
|
||||
const bus = window.feedBack;
|
||||
if (bus && typeof bus.on === 'function') {
|
||||
bus.on('panes:opened', (e) => {
|
||||
const rec = attached.get(e.detail && e.detail.id);
|
||||
if (rec) _onOpened(rec, e.detail);
|
||||
});
|
||||
bus.on('panes:closed', (e) => {
|
||||
const rec = attached.get(e.detail && e.detail.id);
|
||||
if (rec) _onClosed(rec);
|
||||
});
|
||||
}
|
||||
|
||||
window.feedBack.panes.attachChip = attachChip;
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* fee[dB]ack — desktop upgrades for pane windows.
|
||||
*
|
||||
* In the desktop app a pane window is a real BrowserWindow: it remembers where you
|
||||
* put it, it stays off the taskbar, it minimizes to the system tray, and the tray
|
||||
* lists every pane you have.
|
||||
*
|
||||
* Note what this file does NOT do: it does not open the window, and it does not
|
||||
* close it. That stays in pane-window-host.js, and it stays `window.open()` —
|
||||
* because the pane's element is MOVED into that window's document, and a window
|
||||
* the main process created for us would give this realm no handle to adopt into.
|
||||
*
|
||||
* Electron turns our same-origin `window.open()` into a real BrowserWindow anyway,
|
||||
* and the main process recognises it by its frame name (`fbpane-<id>`) and takes
|
||||
* over the OS-level behaviour from there. So the only thing left to say across IPC
|
||||
* is "here are the panes that exist" — for the tray — and to listen for the tray
|
||||
* saying "open that one".
|
||||
*
|
||||
* In a browser, or on an older desktop build, this file does nothing and pop-out
|
||||
* works anyway. Everything here is an upgrade, not a dependency.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
const desktop = window.feedBackDesktop && window.feedBackDesktop.panes;
|
||||
if (!panes || !bus || !desktop) return;
|
||||
|
||||
// The tray asked to toggle a pane. Only this realm knows what that means — the
|
||||
// pane might belong in the dock, and its element lives here.
|
||||
desktop.onToggle((paneId) => {
|
||||
if (panes.isOpen(paneId)) panes.close(paneId);
|
||||
else panes.detach(paneId);
|
||||
});
|
||||
|
||||
// Keep the tray's menu in step with the registry. Cheap and rare — panes are
|
||||
// registered at load and toggled by hand, never on a playback path.
|
||||
function sync() {
|
||||
desktop.sync(panes.list().map((p) => ({ id: p.id, title: p.title, icon: p.icon, open: p.open })));
|
||||
}
|
||||
bus.on('panes:registered', sync);
|
||||
bus.on('panes:unregistered', sync);
|
||||
bus.on('panes:opened', sync);
|
||||
bus.on('panes:closed', sync);
|
||||
sync();
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* fee[dB]ack — pane dock (the in-window pane host).
|
||||
*
|
||||
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
|
||||
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
|
||||
* opening the next), which is exactly why you cannot watch the mixer while riding
|
||||
* the camera. Cards here coexist.
|
||||
*
|
||||
* As everywhere in this system, the card holds the plugin's REAL element — moved,
|
||||
* not copied. The dock is a frame; the panel inside it is the panel.
|
||||
*
|
||||
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
|
||||
* child outside every .screen, so the per-song teardown never sees it.
|
||||
*
|
||||
* Registers as the `dock` host at priority 0 — the floor. Whatever else exists
|
||||
* (an OS window), a pane can always land here, so opening one can never fail.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.registerHost !== 'function') {
|
||||
console.error('[panes] pane-manager.js must load before pane-dock.js');
|
||||
return;
|
||||
}
|
||||
|
||||
let dockEl = null;
|
||||
const cards = new Map(); // paneId -> card element
|
||||
|
||||
function dock() {
|
||||
if (dockEl && dockEl.isConnected) return dockEl;
|
||||
dockEl = document.getElementById('fb-pane-dock');
|
||||
if (!dockEl) {
|
||||
dockEl = document.createElement('div');
|
||||
dockEl.id = 'fb-pane-dock';
|
||||
// `is-empty` from the start: panes.css hides an empty dock, and a dock
|
||||
// born without the class is a visible-to-CSS, announced-to-screen-readers
|
||||
// `role="region"` landmark with nothing in it until the first card
|
||||
// arrives. Born empty, because it is.
|
||||
dockEl.className = 'fb-pane-dock is-empty';
|
||||
dockEl.setAttribute('role', 'region');
|
||||
dockEl.setAttribute('aria-label', 'Panes');
|
||||
document.body.appendChild(dockEl);
|
||||
}
|
||||
return dockEl;
|
||||
}
|
||||
|
||||
function _syncEmpty() {
|
||||
dock().classList.toggle('is-empty', cards.size === 0);
|
||||
}
|
||||
|
||||
function place(spec, el) {
|
||||
const card = document.createElement('section');
|
||||
card.className = 'fb-pane-card';
|
||||
card.dataset.paneId = spec.id;
|
||||
card.setAttribute('aria-label', spec.title);
|
||||
|
||||
const head = document.createElement('header');
|
||||
head.className = 'fb-pane-card-head';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'fb-pane-card-title';
|
||||
// textContent, not innerHTML — a pane title comes from a plugin.
|
||||
title.textContent = spec.icon + ' ' + spec.title;
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'fb-pane-card-btn';
|
||||
close.setAttribute('aria-label', 'Close ' + spec.title);
|
||||
close.title = 'Close';
|
||||
close.textContent = '✕';
|
||||
close.addEventListener('click', () => panes.close(spec.id));
|
||||
|
||||
head.appendChild(title);
|
||||
head.appendChild(close);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'fb-pane-card-body';
|
||||
|
||||
// Same neutralisation as the window host: the panel was a fixed overlay
|
||||
// pinned to a corner of the app, and inside a card that positioning is
|
||||
// nonsense. .fb-paned unpins it and nothing else.
|
||||
el.classList.add('fb-paned');
|
||||
body.appendChild(el);
|
||||
|
||||
card.appendChild(head);
|
||||
card.appendChild(body);
|
||||
dock().appendChild(card);
|
||||
cards.set(spec.id, card);
|
||||
_syncEmpty();
|
||||
}
|
||||
|
||||
function unplace(id, el) {
|
||||
// Hand the element back unmarked. The manager returns it to its home right
|
||||
// after this, and it must arrive as the plugin left it — a panel that
|
||||
// stayed .fb-paned would come back with its own positioning stripped.
|
||||
if (el) el.classList.remove('fb-paned');
|
||||
const card = cards.get(id);
|
||||
if (card) card.remove();
|
||||
cards.delete(id);
|
||||
_syncEmpty();
|
||||
}
|
||||
|
||||
function focus(id) {
|
||||
const card = cards.get(id);
|
||||
if (!card) return;
|
||||
// Honour prefers-reduced-motion, as the flash animation below already does
|
||||
// in panes.css. A smooth scroll is motion too, and a user who asked for less
|
||||
// of it meant this as well.
|
||||
const calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
card.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
|
||||
// Re-trigger the flash even if the class is still there — repeat focus of
|
||||
// the same card would otherwise be a no-op animation.
|
||||
card.classList.remove('is-flash');
|
||||
void card.offsetWidth;
|
||||
card.classList.add('is-flash');
|
||||
setTimeout(() => card.classList.remove('is-flash'), 700);
|
||||
}
|
||||
|
||||
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* fee[dB]ack — pane launcher (the "Panes" rail popover).
|
||||
*
|
||||
* A chip only works for a pane that already has a dialog to hide. Panes with no
|
||||
* dialog — a readout, a plugin's optional extra — need somewhere to be opened
|
||||
* from, so every registered pane gets one: a checkbox list in the rail.
|
||||
*
|
||||
* The rail popover is the right home for this precisely because it IS exclusive
|
||||
* and transient. It's a menu, not a workspace; the panes it opens are the
|
||||
* workspace, and they persist.
|
||||
*
|
||||
* Populated from the registry, so a plugin that calls panes.register() appears
|
||||
* here with no further work. (The system tray will mirror this list.)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
if (!panes || !bus || typeof bus.on !== 'function') return;
|
||||
|
||||
let listEl = null;
|
||||
|
||||
function render() {
|
||||
if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list');
|
||||
if (!listEl) return;
|
||||
const all = panes.list();
|
||||
|
||||
// Toggling a pane from this list fires panes:opened/closed, which re-renders
|
||||
// the list — destroying the very button the user just pressed and dropping
|
||||
// focus to <body>. Remember which one had it and give it back, so keyboard
|
||||
// and screen-reader users can toggle several panes without losing their place.
|
||||
const focusedId = (listEl.contains(document.activeElement) && document.activeElement.dataset)
|
||||
? document.activeElement.dataset.paneId : null;
|
||||
|
||||
listEl.replaceChildren();
|
||||
|
||||
if (!all.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'v3-pop-empty';
|
||||
empty.textContent = 'No panes available.';
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
all.forEach((p) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'v3-pop-btn';
|
||||
b.dataset.paneId = p.id;
|
||||
b.setAttribute('aria-pressed', p.open ? 'true' : 'false');
|
||||
b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
|
||||
});
|
||||
listEl.appendChild(b);
|
||||
if (p.id === focusedId) b.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// The registry changes when plugins load and when panes open/close. Render is
|
||||
// cheap and rare (never on a playback path), so just re-run it.
|
||||
bus.on('panes:registered', render);
|
||||
bus.on('panes:unregistered', render);
|
||||
bus.on('panes:opened', render);
|
||||
bus.on('panes:closed', render);
|
||||
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', render);
|
||||
else render();
|
||||
})();
|
||||
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* fee[dB]ack — pane manager.
|
||||
*
|
||||
* The registry and host router behind `window.feedBack.panes`.
|
||||
*
|
||||
* A "pane" is a piece of UI a plugin already has — a mixer panel, a camera rig,
|
||||
* a settings board — that the user can pop out into its own OS window and leave
|
||||
* open: while they play, across song switches, on a second monitor, minimized to
|
||||
* the tray.
|
||||
*
|
||||
* The whole design is one sentence: WE MOVE THE REAL ELEMENT.
|
||||
*
|
||||
* Not a copy of it, not a re-implementation of it in the pop-out window — the
|
||||
* actual DOM node. Same-origin windows can adopt each other's nodes, and an
|
||||
* adopted node keeps its event listeners and its closures. So the panel goes on
|
||||
* running the plugin's own code, against the plugin's own state, in the plugin's
|
||||
* own realm. It looks and behaves exactly like the thing that was popped out,
|
||||
* because it IS the thing that was popped out.
|
||||
*
|
||||
* That is what makes the plugin's side of this two lines:
|
||||
*
|
||||
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
|
||||
* feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||
*
|
||||
* No state mirroring, no cross-window RPC, no second copy of the UI to keep in
|
||||
* step with the first. Those were all workarounds for a problem we simply do not
|
||||
* have once the node itself moves.
|
||||
*
|
||||
* The manager owns which pane is open and where, and — crucially — where each
|
||||
* pane's element CAME FROM, so docking it puts it back exactly where it was.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
|
||||
|
||||
// id -> normalized spec
|
||||
const specs = new Map();
|
||||
// id -> { spec, hostId, el, home: { parent, next } }
|
||||
const open = new Map();
|
||||
// hostId -> host provider
|
||||
const hosts = new Map();
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
// Only which pane was open, and where. A pane's CONTENTS are the plugin's own
|
||||
// DOM and the plugin's own state — none of our business.
|
||||
|
||||
// A pane id is plugin-controlled and is used as a key in the persisted
|
||||
// host map. `__proto__` and friends are not ids, they are booby traps: writing
|
||||
// `map['__proto__'] = 'window'` on a plain object corrupts the map (and can
|
||||
// reach Object.prototype), and reading `map[id]` can pick a value straight off
|
||||
// the prototype chain for a pane that was never remembered at all.
|
||||
//
|
||||
// Rejected at registration, so the id never reaches storage — and the reads
|
||||
// below are own-property checks anyway, because defence in depth is cheap here.
|
||||
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
|
||||
function _isUnsafeId(id) { return UNSAFE_KEYS.indexOf(id) >= 0; }
|
||||
|
||||
function _readJSON(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return fallback;
|
||||
// Re-key onto a null-prototype object: whatever was in storage (hand
|
||||
// edited, corrupt, polluted) can no longer smuggle in a prototype.
|
||||
const safe = Object.create(null);
|
||||
Object.keys(parsed).forEach((k) => { if (!_isUnsafeId(k)) safe[k] = parsed[k]; });
|
||||
return safe;
|
||||
} catch (e) { return fallback; } // private mode / corrupt value
|
||||
}
|
||||
function _writeJSON(key, value) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ }
|
||||
}
|
||||
function _rememberHost(id, hostId) {
|
||||
if (_isUnsafeId(id)) return;
|
||||
const map = _readJSON(HOSTS_KEY, Object.create(null));
|
||||
if (hostId) map[id] = hostId; else delete map[id];
|
||||
_writeJSON(HOSTS_KEY, map);
|
||||
}
|
||||
function _rememberedHost(id) {
|
||||
const map = _readJSON(HOSTS_KEY, Object.create(null));
|
||||
return Object.prototype.hasOwnProperty.call(map, id) ? map[id] : undefined;
|
||||
}
|
||||
|
||||
// ── Spec ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// A pane window's initial size. Plugin-controlled, and the window host builds
|
||||
// window.open()'s feature string by concatenation — so this has to come out the
|
||||
// other side as a number, not merely as something number-ish.
|
||||
const MIN_PANE_PX = 120;
|
||||
const MAX_PANE_PX = 4000; // wider than any real display; a guard, not a policy
|
||||
function _size(v, fallback) {
|
||||
const n = Math.round(Number(v));
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||
return Math.min(MAX_PANE_PX, Math.max(MIN_PANE_PX, n));
|
||||
}
|
||||
|
||||
function _normalize(spec) {
|
||||
if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object');
|
||||
if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required');
|
||||
// See UNSAFE_KEYS: a pane id becomes a key in the persisted host map.
|
||||
if (_isUnsafeId(spec.id)) throw new TypeError('panes.register: unsafe pane id: ' + spec.id);
|
||||
if (typeof spec.element !== 'function' && !(spec.element instanceof Element)) {
|
||||
throw new TypeError('panes.register(' + spec.id + '): spec.element must be an Element, or a function returning one');
|
||||
}
|
||||
return {
|
||||
id: spec.id,
|
||||
title: spec.title || spec.id,
|
||||
icon: spec.icon || '▣',
|
||||
// Resolved lazily: a plugin often builds its panel on first use, so the
|
||||
// element may not exist at registration time — and it may be rebuilt
|
||||
// later (Camera Director rebuilds its panel on every mode change).
|
||||
// Asking for it at open time means we always move the live one.
|
||||
element: typeof spec.element === 'function' ? spec.element : () => spec.element,
|
||||
// Coerced to real numbers, because these are plugin-controlled and the
|
||||
// window host concatenates them into window.open()'s feature string. A
|
||||
// `width` of '300,menubar=1' would not merely be an invalid size — it
|
||||
// would inject window features. Anything that isn't a finite positive
|
||||
// number falls back to the default, and absurd sizes are clamped rather
|
||||
// than honoured.
|
||||
width: _size(spec.width, 380),
|
||||
height: _size(spec.height, 560),
|
||||
defaultHost: spec.defaultHost || 'window',
|
||||
// Called after the element lands in (or returns from) a pane window,
|
||||
// for a plugin that needs to re-measure or re-anchor something.
|
||||
onHost: typeof spec.onHost === 'function' ? spec.onHost : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Host routing ─────────────────────────────────────────────────────────
|
||||
|
||||
function _resolveHost(preferred) {
|
||||
const wanted = hosts.get(preferred);
|
||||
if (wanted && wanted.available()) return wanted;
|
||||
// Fall back to the best available host. The dock registers at priority 0
|
||||
// and is always available, so a pane can never fail to open.
|
||||
let best = null;
|
||||
hosts.forEach((h) => {
|
||||
if (!h.available()) return;
|
||||
if (!best || h.priority > best.priority) best = h;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function _emit(name, detail) {
|
||||
const bus = window.feedBack;
|
||||
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
|
||||
}
|
||||
|
||||
// ── Open / close ─────────────────────────────────────────────────────────
|
||||
|
||||
function openPane(id, opts) {
|
||||
opts = opts || {};
|
||||
const spec = specs.get(id);
|
||||
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
|
||||
if (open.has(id)) { focusPane(id); return true; }
|
||||
|
||||
let el;
|
||||
try { el = spec.element(); } catch (e) { el = null; }
|
||||
if (!(el instanceof Element)) {
|
||||
console.warn('[panes] open: pane has no element yet:', id);
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = _resolveHost(opts.host || spec.defaultHost);
|
||||
if (!host) { console.error('[panes] open: no host available for', id); return false; }
|
||||
|
||||
// Where the element lives right now, so docking can put it back EXACTLY
|
||||
// there — same parent, same position among its siblings. Anything less and
|
||||
// a docked panel reappears at the bottom of its container, or not at all.
|
||||
const home = { parent: el.parentNode, next: el.nextSibling };
|
||||
|
||||
// An element on its way OUT of this document must not carry a class whose
|
||||
// whole job is to hide it IN this document. `.fb-pane-detached` is
|
||||
// `display:none !important`, and it travels with the node — straight into
|
||||
// the pane window, which then renders nothing at all.
|
||||
el.classList.remove('fb-pane-detached');
|
||||
|
||||
// Make it visible, and remember exactly how it wasn't.
|
||||
//
|
||||
// A plugin's panel is usually hidden until its launcher is clicked, and a
|
||||
// pane can be opened from the tray or the rail without that ever happening.
|
||||
// So we un-hide it — but only in the two ways a panel is actually hidden
|
||||
// (`hidden`, or an inline `display:none`), and we put both back on dock.
|
||||
//
|
||||
// Note what we do NOT do: force a `display`. A panel that is `display:flex`
|
||||
// must stay flex. Neutralising placement is one thing; silently re-laying
|
||||
// out someone's panel is another.
|
||||
const vis = { hidden: el.hidden, display: el.style.display };
|
||||
el.hidden = false;
|
||||
if (el.style.display === 'none') el.style.display = '';
|
||||
|
||||
try {
|
||||
host.place(spec, el);
|
||||
} catch (e) {
|
||||
console.error('[panes] host', host.id, 'failed to take', id, e);
|
||||
el.hidden = vis.hidden;
|
||||
el.style.display = vis.display;
|
||||
return false;
|
||||
}
|
||||
|
||||
open.set(id, { spec, hostId: host.id, el, home, vis });
|
||||
if (opts.remember !== false) _rememberHost(id, host.id);
|
||||
if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } }
|
||||
// `home` rides along because the element has LEFT this document — anything
|
||||
// that wants to mark the hole it left (the chip's stub) needs to know where
|
||||
// the hole is, and can no longer ask the element itself.
|
||||
_emit('panes:opened', { id: id, host: host.id, el: el, home: home });
|
||||
return true;
|
||||
}
|
||||
|
||||
function closePane(id, opts) {
|
||||
opts = opts || {};
|
||||
const entry = open.get(id);
|
||||
if (!entry) return false;
|
||||
open.delete(id);
|
||||
|
||||
// ORDER IS LOAD-BEARING: bring the element home BEFORE the host lets go of
|
||||
// it. The host's unplace() closes the pane window, and closing a window
|
||||
// tears down its document — with the element still inside it. The node
|
||||
// survives (we hold a reference) but comes back stripped of its event
|
||||
// listeners, so the panel returns looking perfect and completely dead: no
|
||||
// buttons, no sliders, nothing.
|
||||
//
|
||||
// Adopt first, while the pane window is still alive, and the node moves out
|
||||
// of a living document into a living document, which is the only case the
|
||||
// DOM actually guarantees.
|
||||
// ADOPT UNCONDITIONALLY, INSERT CONDITIONALLY. The rescue and the
|
||||
// re-homing are two different jobs, and only one of them is allowed to
|
||||
// fail.
|
||||
//
|
||||
// Adopting is what saves the element: it transfers ownership away from the
|
||||
// pane window's document, so that document can be destroyed without taking
|
||||
// the listeners with it. Do that FIRST, and always — even when there is
|
||||
// nowhere to put the element afterwards.
|
||||
//
|
||||
// Re-homing can legitimately be impossible: the panel may never have had a
|
||||
// parent (a plugin that builds it lazily and hands it straight to us), or
|
||||
// its container may have been torn down while the pane was out (a screen
|
||||
// change). Gating the adopt on a reachable home would mean that in exactly
|
||||
// those cases we leave the element inside a window we are about to close —
|
||||
// which is the "comes home dead" failure this whole ordering exists to
|
||||
// prevent. It just moves it from the common path to the rare one, where it
|
||||
// is far harder to spot.
|
||||
//
|
||||
// With no home, the element ends up owned by this document but not in it:
|
||||
// detached, intact, listeners alive, and ready for the plugin to re-insert
|
||||
// whenever it rebuilds its UI.
|
||||
try {
|
||||
// adoptNode, not appendChild: the node's owner is currently the pane
|
||||
// window's document, and adopting is what transfers ownership back.
|
||||
const node = document.adoptNode(entry.el);
|
||||
const home = entry.home;
|
||||
if (home && home.parent && home.parent.isConnected) {
|
||||
if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next);
|
||||
else home.parent.appendChild(node);
|
||||
} else {
|
||||
console.warn('[panes]', id, 'has no home to return to — the element is detached but intact');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[panes] could not bring', id, 'back out of its pane window', e);
|
||||
}
|
||||
|
||||
const host = hosts.get(entry.hostId);
|
||||
try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); }
|
||||
|
||||
// Put its visibility back exactly as we found it. A panel that was closed
|
||||
// when the pane was opened from the tray goes back to being closed; one that
|
||||
// was open stays open. We forced it visible; we un-force it.
|
||||
if (entry.vis) {
|
||||
entry.el.hidden = entry.vis.hidden;
|
||||
entry.el.style.display = entry.vis.display;
|
||||
}
|
||||
|
||||
if (opts.remember !== false) _rememberHost(id, null);
|
||||
if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } }
|
||||
_emit('panes:closed', { id: id, host: entry.hostId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusPane(id) {
|
||||
const entry = open.get(id);
|
||||
if (!entry) return false;
|
||||
const host = hosts.get(entry.hostId);
|
||||
if (host && typeof host.focus === 'function') host.focus(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// What the pop-out chip calls: put this pane wherever a pane most wants to
|
||||
// live. That is a window if one can be had, and the dock otherwise.
|
||||
function detach(id) {
|
||||
const spec = specs.get(id);
|
||||
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
|
||||
}
|
||||
|
||||
function dock(id) {
|
||||
if (open.has(id)) closePane(id, { remember: false });
|
||||
return openPane(id, { host: 'dock' });
|
||||
}
|
||||
|
||||
// ── Registry ─────────────────────────────────────────────────────────────
|
||||
|
||||
function register(spec) {
|
||||
const s = _normalize(spec);
|
||||
if (specs.has(s.id)) {
|
||||
// First registration wins, matching libraryCardActions.register. A
|
||||
// silent overwrite would swap the element out from under an open pane.
|
||||
console.warn('[panes] pane already registered, ignoring:', s.id);
|
||||
return () => {};
|
||||
}
|
||||
specs.set(s.id, s);
|
||||
_emit('panes:registered', { id: s.id, title: s.title });
|
||||
|
||||
// Reopen where the user left it. Deferred a tick so a plugin can call
|
||||
// register() and attachChip() back to back — the chip must exist before
|
||||
// the pane opens, or it has nothing to hide.
|
||||
//
|
||||
// A host may refuse to be auto-restored: a browser blocks window.open()
|
||||
// without a user gesture, so restoring a popped-out pane on page load
|
||||
// would only ever produce a "pop-up blocked" toast. Such a pane comes back
|
||||
// in the dock, and the chip pops it out again on the user's next click.
|
||||
let remembered = _rememberedHost(s.id);
|
||||
if (remembered) {
|
||||
const h = hosts.get(remembered);
|
||||
if (h && h.autoRestore === false) remembered = 'dock';
|
||||
setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
|
||||
}
|
||||
|
||||
return () => unregister(s.id);
|
||||
}
|
||||
|
||||
function unregister(id) {
|
||||
if (open.has(id)) closePane(id, { remember: false });
|
||||
specs.delete(id);
|
||||
_emit('panes:unregistered', { id: id });
|
||||
}
|
||||
|
||||
function registerHost(host) {
|
||||
if (!host || !host.id) throw new TypeError('panes: host needs an id');
|
||||
hosts.set(host.id, {
|
||||
id: host.id,
|
||||
priority: host.priority || 0,
|
||||
autoRestore: host.autoRestore !== false,
|
||||
available: typeof host.available === 'function' ? host.available : () => true,
|
||||
place: host.place,
|
||||
unplace: host.unplace,
|
||||
focus: host.focus,
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
version: 2,
|
||||
register,
|
||||
unregister,
|
||||
open: openPane,
|
||||
close: closePane,
|
||||
detach,
|
||||
dock,
|
||||
focus: focusPane,
|
||||
isOpen: (id) => open.has(id),
|
||||
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
|
||||
// Where an open pane's element came from. The chip needs this to mark the
|
||||
// hole the element left, since it can no longer ask the element itself.
|
||||
homeOf: (id) => { const e = open.get(id); return e ? e.home : null; },
|
||||
// The element a host actually took. The chip needs this to tell "the pane
|
||||
// took MY element" from "the pane took something else" — and it cannot ask
|
||||
// the element, which may now be in a dock card or another window entirely.
|
||||
elementOf: (id) => { const e = open.get(id); return e ? e.el : null; },
|
||||
get: (id) => specs.get(id) || null,
|
||||
list: () => Array.from(specs.values()).map((s) => ({
|
||||
id: s.id, title: s.title, icon: s.icon,
|
||||
open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null,
|
||||
})),
|
||||
registerHost,
|
||||
};
|
||||
|
||||
window.feedBack = window.feedBack || {};
|
||||
window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api);
|
||||
})();
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* fee[dB]ack — the pop-out window host.
|
||||
*
|
||||
* Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
|
||||
*
|
||||
* The move is the whole trick, and it works because the pane window is same-origin
|
||||
* and opener-linked: `document.adoptNode()` re-parents a live node into another
|
||||
* window's document, and an adopted node keeps its event listeners, its closures,
|
||||
* and every reference anything else holds to it. So the plugin's panel goes on
|
||||
* running the plugin's own code in the plugin's own realm — it is just being
|
||||
* *displayed* somewhere else. It looks and behaves exactly like what was popped
|
||||
* out, because it is exactly what was popped out.
|
||||
*
|
||||
* That is why this file must use `window.open()` and not ask the desktop's main
|
||||
* process to make a BrowserWindow: a window we didn't open gives us no handle to
|
||||
* its document, and without the handle there is nothing to adopt into.
|
||||
*
|
||||
* Electron turns this same-origin `window.open()` into a real BrowserWindow anyway
|
||||
* — its setWindowOpenHandler answers same-origin URLs with `action: 'allow'` — and
|
||||
* the main process then recognises the window by its frame name and gives it
|
||||
* remembered bounds, skip-taskbar and a system-tray entry. So we get the OS window
|
||||
* AND the DOM link. (That code lives in the separate desktop repo,
|
||||
* got-feedback/feedBack-desktop: src/main/main.ts and src/main/pane-hosts.ts. It is
|
||||
* not in this repo, and nothing here depends on it — in a plain browser this is
|
||||
* simply a pop-up.)
|
||||
*
|
||||
* Styles come across too — the pane document starts empty, so we copy the app's
|
||||
* stylesheets into it. Without that the panel would land unstyled, which is the
|
||||
* one thing a "pop out exactly this" feature cannot do.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.registerHost !== 'function') {
|
||||
console.error('[panes] pane-manager.js must load before pane-window-host.js');
|
||||
return;
|
||||
}
|
||||
|
||||
// The frame name every pane window is opened with. In the desktop app the main
|
||||
// process matches on this prefix to recognise a pane window and give it its
|
||||
// remembered bounds, skip-taskbar and tray entry — so changing it here without
|
||||
// changing it there silently downgrades every pane to a plain pop-up.
|
||||
//
|
||||
// The other half lives in a DIFFERENT REPO (got-feedback/feedBack-desktop,
|
||||
// src/main/pane-hosts.ts). There is no build-time link between them; this comment
|
||||
// is the link.
|
||||
const FRAME_PREFIX = 'fbpane-';
|
||||
|
||||
const wins = new Map(); // paneId -> Window
|
||||
let reaper = null;
|
||||
|
||||
// A pane window the user closed with the OS X button gets no reliable
|
||||
// beforeunload (a crashed renderer certainly gets none). Poll `closed` and
|
||||
// reap — otherwise the pane stays "open" forever, its chip stays stubbed out,
|
||||
// and the element it holds is stranded in a dead document with no way back.
|
||||
function _startReaper() {
|
||||
if (reaper != null) return;
|
||||
reaper = setInterval(() => {
|
||||
wins.forEach((w, id) => { if (w.closed) panes.close(id); });
|
||||
if (!wins.size) { clearInterval(reaper); reaper = null; }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Give the pane document the app's styles, so the panel looks identical.
|
||||
// Cloned rather than shared: a <link> node can only live in one document, and
|
||||
// we are not about to steal the app's own stylesheet out of its head.
|
||||
function _copyStyles(doc) {
|
||||
// pane.html already links panes.css, so don't clone a second copy of it —
|
||||
// duplicate sheets cost a redundant fetch and an extra style recalc for no
|
||||
// change in appearance.
|
||||
const own = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'));
|
||||
const have = new Set(own.map((l) => l.href));
|
||||
|
||||
// Insert the app's sheets BEFORE pane.html's own, not after.
|
||||
//
|
||||
// Cascade order is the whole game here. In the app document panes.css loads
|
||||
// LAST, after tailwind/style/v3 — so its rules win ties. Appending the app's
|
||||
// sheets into the pane document would put them after panes.css and silently
|
||||
// invert that, letting core styles override the pane chrome and the .fb-paned
|
||||
// placement rules. "Looks identical" has to include the order things are
|
||||
// said in.
|
||||
const anchor = own[0] || null;
|
||||
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
|
||||
if (node.tagName === 'LINK' && have.has(node.href)) return;
|
||||
try { doc.head.insertBefore(node.cloneNode(true), anchor); } catch (e) { /* skip a node we can't clone */ }
|
||||
});
|
||||
_syncChrome(doc);
|
||||
}
|
||||
|
||||
// The theme/scale hooks the app hangs on <html> and <body>. v3 keys off these
|
||||
// for its colour tokens and its interface scale, and a panel that lands without
|
||||
// them renders in the wrong palette at the wrong size.
|
||||
//
|
||||
// MERGE, don't assign: pane.html sets `class="fb-pane-window"` on <html>, and
|
||||
// panes.css hangs the pane window's own chrome off it. Overwriting the class
|
||||
// list would take that with it and the window would lose its own layout — the
|
||||
// app's classes and the pane document's are both wanted.
|
||||
//
|
||||
// Re-run on every theme/scale change for as long as the pane is open (see
|
||||
// _followChrome). A one-time snapshot would leave an already-open pane rendering
|
||||
// at the old scale the moment the user touched Interface size — "looks identical"
|
||||
// has to keep being true, not merely start out true.
|
||||
function _syncChrome(doc) {
|
||||
try {
|
||||
document.documentElement.classList.forEach((c) => doc.documentElement.classList.add(c));
|
||||
document.body.classList.forEach((c) => doc.body.classList.add(c));
|
||||
// The inline style on <html> carries the interface-scale custom property
|
||||
// (--fb-scale). Assign it wholesale: unlike the class lists, pane.html
|
||||
// sets no inline style of its own, so there is nothing here to preserve —
|
||||
// and merging by concatenation would grow the attribute without bound as
|
||||
// the user dragged the scale slider.
|
||||
doc.documentElement.style.cssText = document.documentElement.style.cssText;
|
||||
} catch (e) { /* the window may be closing under us */ }
|
||||
}
|
||||
|
||||
// paneId -> stop following the app's theme/scale
|
||||
const chromeFollowers = new Map();
|
||||
|
||||
function _followChrome(paneId, doc) {
|
||||
const bus = window.feedBack;
|
||||
if (!bus || typeof bus.on !== 'function') return;
|
||||
const sync = () => _syncChrome(doc);
|
||||
bus.on('scale:changed', sync);
|
||||
bus.on('theme:changed', sync);
|
||||
bus.on('v3:cosmetics-applied', sync);
|
||||
chromeFollowers.set(paneId, () => {
|
||||
bus.off('scale:changed', sync);
|
||||
bus.off('theme:changed', sync);
|
||||
bus.off('v3:cosmetics-applied', sync);
|
||||
});
|
||||
}
|
||||
|
||||
function _unfollowChrome(paneId) {
|
||||
const off = chromeFollowers.get(paneId);
|
||||
if (off) { off(); chromeFollowers.delete(paneId); }
|
||||
}
|
||||
|
||||
// How long a "we cannot even see the pop-out's document" condition has to persist
|
||||
// before we call it fatal. A SecurityError means the window is not reachable from
|
||||
// this realm at all, and waiting cannot fix that — but we give it a moment anyway
|
||||
// rather than bailing on the first tick, because a throw *during* the navigation
|
||||
// from about:blank to /pane would otherwise take down a pop-out that was about to
|
||||
// work perfectly. A second is far more than that transition needs, and far less
|
||||
// than the 10s a user would otherwise stare at a detached panel for.
|
||||
const UNREACHABLE_GRACE_MS = 1000;
|
||||
|
||||
// Wait for the REAL pane document.
|
||||
//
|
||||
// window.open() returns immediately, with an `about:blank` document that is
|
||||
// already readyState 'complete'. Adopt into that and it works for a few
|
||||
// milliseconds — and then /pane finishes loading, replaces the document, and
|
||||
// takes the panel with it. The window is left blank and the element is gone.
|
||||
//
|
||||
// So we do not trust readyState, and we do not trust 'load' (which may have
|
||||
// fired for about:blank before we could listen). We wait for the one thing that
|
||||
// only exists in the document we actually want: pane.html's #fb-pane-root.
|
||||
|
||||
function _whenReady(w, onReady, onFail) {
|
||||
const deadline = performance.now() + 10000;
|
||||
let reachFailure = null; // why we could never see the pop-out's document
|
||||
let reachFailureAt = 0; // when we first couldn't
|
||||
const tick = () => {
|
||||
if (w.closed) return;
|
||||
|
||||
let doc = null;
|
||||
try { doc = w.document; }
|
||||
catch (e) {
|
||||
// A SecurityError here is the one that matters: it means the pop-out
|
||||
// is not reachable from this realm at all (a separate process /
|
||||
// browsing-context group), and no amount of waiting will fix it —
|
||||
// adoptNode can never work.
|
||||
doc = null;
|
||||
if (!reachFailure) reachFailureAt = performance.now();
|
||||
reachFailure = e;
|
||||
}
|
||||
|
||||
// Unreachable, and it has stayed that way. Fail now rather than leaving
|
||||
// the panel detached and the UI mid-pop-out for the full 10s deadline,
|
||||
// when we already know this can never succeed.
|
||||
if (reachFailure && !doc && performance.now() - reachFailureAt > UNREACHABLE_GRACE_MS) {
|
||||
onFail(new Error('the pane window\'s document is NOT reachable from this window ('
|
||||
+ reachFailure.name + ': ' + reachFailure.message
|
||||
+ ') — it is in a separate process, so the element cannot be moved into it'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc && doc.readyState !== 'loading') {
|
||||
// Only ever adopt into the document we actually navigated TO.
|
||||
// about:blank reports readyState 'complete' from the moment
|
||||
// window.open() returns, and adopting into it means the panel is
|
||||
// destroyed when /pane replaces it a moment later.
|
||||
const href = (doc.location && doc.location.href) || '';
|
||||
const isPaneDoc = href.indexOf('/pane') >= 0;
|
||||
if (isPaneDoc) {
|
||||
// Prefer pane.html's own root, but never fail for want of it —
|
||||
// a stale cached copy of the page (or a future rename) must not
|
||||
// leave the user with a blank window and no panel.
|
||||
const root = doc.getElementById('fb-pane-root') || doc.body;
|
||||
if (root) { onReady(root); return; }
|
||||
}
|
||||
}
|
||||
|
||||
if (performance.now() > deadline) {
|
||||
let why;
|
||||
if (reachFailure) {
|
||||
why = 'the pane window\'s document is NOT reachable from this window ('
|
||||
+ reachFailure.name + ': ' + reachFailure.message
|
||||
+ ') — it is in a separate process, so the element cannot be moved into it';
|
||||
} else if (!doc) {
|
||||
why = 'the pane window exposed no document at all';
|
||||
} else {
|
||||
why = 'the pane window never loaded /pane (it is showing '
|
||||
+ ((doc.location && doc.location.href) || 'an unknown URL')
|
||||
+ ', readyState ' + doc.readyState + ')';
|
||||
}
|
||||
onFail(new Error(why));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 25);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function _adopt(w, root, spec, el) {
|
||||
const doc = w.document;
|
||||
_copyStyles(doc);
|
||||
// The panel was almost certainly a fixed/absolute overlay pinned to a
|
||||
// corner of the app. In a window of its own that positioning is nonsense —
|
||||
// it would sit 72px from the top of a 380px window, still 288px wide, still
|
||||
// casting a drop shadow over nothing. Neutralise the *placement* while
|
||||
// touching nothing else about how it looks.
|
||||
el.classList.add('fb-paned');
|
||||
root.appendChild(doc.adoptNode(el));
|
||||
doc.title = spec.title + ' — fee[dB]ack';
|
||||
|
||||
// Keep the pane window's theme and interface scale in step with the app for
|
||||
// as long as it is open. Stopped in unplace().
|
||||
_followChrome(spec.id, doc);
|
||||
|
||||
// THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES.
|
||||
//
|
||||
// When the user closes a pane window, its document is torn down — and the
|
||||
// panel is inside it. The node itself survives (we hold a reference) and
|
||||
// comes home looking perfect: right markup, right classes, right size. But
|
||||
// it comes home DEAD: every event listener in the subtree is gone with the
|
||||
// document that hosted them. A panel that renders and does nothing.
|
||||
//
|
||||
// The `closed` poll cannot save us: by the time `w.closed` is true, the
|
||||
// document is already gone. `beforeunload` fires while it is still alive, so
|
||||
// this is the last moment we can get the element out — and panes.close()
|
||||
// adopts it back into the main document synchronously.
|
||||
//
|
||||
// We attach it HERE, not when the window was opened: back then the window
|
||||
// still held its throwaway about:blank document, and a listener registered
|
||||
// on that is discarded when /pane replaces it.
|
||||
w.addEventListener('beforeunload', () => {
|
||||
if (panes.isOpen(spec.id)) panes.close(spec.id);
|
||||
});
|
||||
}
|
||||
|
||||
function place(spec, el) {
|
||||
const w = window.open(
|
||||
window.location.origin + '/pane',
|
||||
FRAME_PREFIX + spec.id,
|
||||
'popup,width=' + spec.width + ',height=' + spec.height,
|
||||
);
|
||||
|
||||
if (!w) {
|
||||
// Popup blocked. Throw BEFORE the manager records anything, so the
|
||||
// caller's panel stays exactly where it is — and say so out loud rather
|
||||
// than appearing to do nothing.
|
||||
if (window.fbNotify) {
|
||||
window.fbNotify.show({
|
||||
title: 'Pop-out blocked',
|
||||
message: 'Allow pop-ups for this site to detach ' + spec.title + '.',
|
||||
icon: '⚠️', accent: '#f59e0b',
|
||||
});
|
||||
}
|
||||
throw new Error('pop-up blocked');
|
||||
}
|
||||
|
||||
wins.set(spec.id, w);
|
||||
_startReaper();
|
||||
|
||||
// Take the element out of the document NOW, not when the window is ready.
|
||||
//
|
||||
// Everything below this line is async: the window has to load /pane before
|
||||
// there is anything to adopt into. But the manager emits `panes:opened` as
|
||||
// soon as we return, and the chip reacts by putting its "popped out" stub
|
||||
// where the element used to be — so for that whole gap the user would see
|
||||
// BOTH the real panel and a stub claiming it had left. On a window that
|
||||
// never loads, that lasts the full 10s timeout.
|
||||
//
|
||||
// Detaching is not destructive: the node keeps its owner document (this
|
||||
// one), its listeners and its closures. It is simply out of the tree,
|
||||
// waiting — and if the window never loads, closePane() puts it straight
|
||||
// back at its home.
|
||||
el.remove();
|
||||
|
||||
_whenReady(w, (root) => {
|
||||
try { _adopt(w, root, spec, el); }
|
||||
catch (e) {
|
||||
console.error('[panes] failed to move', spec.id, 'into its window', e);
|
||||
panes.close(spec.id); // brings the element home
|
||||
}
|
||||
}, (err) => {
|
||||
console.error('[panes]', spec.id, err);
|
||||
panes.close(spec.id); // never strand the element in a dead window
|
||||
});
|
||||
|
||||
// The pane window's 'beforeunload' listener is registered in _adopt(), NOT
|
||||
// here: a listener added now would attach to the window's throwaway
|
||||
// about:blank document and be discarded when /pane replaces it.
|
||||
}
|
||||
|
||||
function unplace(id, el) {
|
||||
_unfollowChrome(id);
|
||||
// Hand the element back unmarked. The manager returns it to its home right
|
||||
// after this, and it must arrive as the plugin left it — a panel that
|
||||
// stayed .fb-paned would come back with its own positioning stripped.
|
||||
if (el) el.classList.remove('fb-paned');
|
||||
const w = wins.get(id);
|
||||
wins.delete(id);
|
||||
// The manager adopts the element back into this document immediately after
|
||||
// this returns, so the window is empty by the time it closes.
|
||||
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
|
||||
}
|
||||
|
||||
function focus(id) {
|
||||
const w = wins.get(id);
|
||||
if (w && !w.closed) { try { w.focus(); } catch (e) { /* the OS may refuse */ } }
|
||||
}
|
||||
|
||||
// A BROWSER blocks window.open() outside a user gesture, so a pane remembered
|
||||
// here cannot be restored on page load — it would only ever produce a "blocked"
|
||||
// toast. Such a pane comes back in the dock, and the chip pops it out again on
|
||||
// the user's next click. The DESKTOP app has no such restriction, so there a
|
||||
// pane left popped out comes back popped out, where you left it.
|
||||
const isDesktop = !!(window.feedBackDesktop && window.feedBackDesktop.panes);
|
||||
|
||||
panes.registerHost({
|
||||
id: 'window',
|
||||
priority: 10,
|
||||
autoRestore: isDesktop,
|
||||
place, unplace, focus,
|
||||
});
|
||||
|
||||
// Our windows; they must not outlive us. A pane window whose opener is gone
|
||||
// holds an element belonging to a dead document — there is nothing left to
|
||||
// dock it back into.
|
||||
window.addEventListener('beforeunload', () => {
|
||||
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } });
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="fb-pane-window">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>fee[dB]ack</title>
|
||||
<link rel="icon" href="/static/assets/favicon.png">
|
||||
<!-- Deliberately almost empty.
|
||||
|
||||
This document does not build a pane; it RECEIVES one. The opener moves the
|
||||
real element in here with document.adoptNode() and copies the app's
|
||||
stylesheets across, so the panel arrives complete — its own markup, its own
|
||||
CSS, its own listeners, its own closures, still running the plugin's code
|
||||
back in the main window.
|
||||
|
||||
So there is nothing to load, nothing to boot, and nothing to keep in step
|
||||
with the app. Only panes.css, for the window chrome and the layout reset the
|
||||
adopted element needs. -->
|
||||
<link rel="stylesheet" href="/static/panes/panes.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="fb-pane-root"></main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,193 @@
|
||||
/* fee[dB]ack — detachable panes.
|
||||
*
|
||||
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin gets the chip
|
||||
* and the dock without shipping its own stylesheet — the same reason .fb-selectable
|
||||
* is hand-authored in core CSS.
|
||||
*
|
||||
* Z-index: the dock is a child of <body>, so it is NOT on the ladder from
|
||||
* docs/plugin-v3-ui.md (transport 20, rail 30, popovers 40) — those numbers live
|
||||
* *inside* #player's stacking context, and #player itself is `position:fixed;
|
||||
* z-index:100` covering the viewport. A dock below 100 is invisible on the one
|
||||
* screen panes exist for. Body-level ladder: #player 100 < dock 110 < toasts 120
|
||||
* < modals 200.
|
||||
*/
|
||||
|
||||
/* ── The popped-out element ──────────────────────────────────────────────────
|
||||
*
|
||||
* The single most important rule in this file.
|
||||
*
|
||||
* A plugin's panel is almost always a fixed overlay pinned to a corner of the app:
|
||||
* `position:fixed; top:72px; right:18px; width:288px; z-index:99999`, with a drop
|
||||
* shadow and a max-height sized against the viewport. Inside a dock card, or alone
|
||||
* in a 320px window, every one of those is wrong — it would float 72px down from
|
||||
* the top of its own window, still 288px wide, still casting a shadow over nothing.
|
||||
*
|
||||
* So we neutralise PLACEMENT and nothing else. Colours, borders, radius, padding,
|
||||
* fonts, the panel's own internal layout: all untouched, because the whole promise
|
||||
* of this feature is that what you popped out is what you get. */
|
||||
.fb-paned {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
margin: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
z-index: auto !important;
|
||||
box-shadow: none !important;
|
||||
/* Deliberately NO `display` override. Forcing `display:block` would silently
|
||||
re-lay-out a panel that is `display:flex` or `grid` — which is the opposite
|
||||
of "placement only", and exactly the kind of surprise this feature exists to
|
||||
avoid. Making a hidden panel visible is the manager's job (it clears the
|
||||
element's `hidden`/inline `display:none` on open and restores them on dock),
|
||||
and it does it without touching the panel's own display mode. */
|
||||
}
|
||||
|
||||
/* ── The pop-out chip ────────────────────────────────────────────────────── */
|
||||
|
||||
.fb-pane-chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 1px solid rgba(51, 65, 85, .7);
|
||||
border-radius: .4rem;
|
||||
background: rgba(30, 41, 59, .8);
|
||||
color: #94a3b8;
|
||||
font-size: .8rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.fb-pane-chip:hover {
|
||||
color: #e2e8f0;
|
||||
border-color: #4080e0;
|
||||
background: rgba(64, 128, 224, .18);
|
||||
}
|
||||
.fb-pane-chip:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
||||
|
||||
/* The panel, while its pane is popped out. A dedicated class rather than
|
||||
.hidden/[hidden]: the panels we attach to toggle those themselves, and two owners
|
||||
of one class is a bug waiting for a bad day. */
|
||||
.fb-pane-detached { display: none !important; }
|
||||
|
||||
/* What the user sees in the panel's place. */
|
||||
.fb-pane-stub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
padding: .35rem .6rem;
|
||||
border: 1px dashed rgba(64, 128, 224, .55);
|
||||
border-radius: .5rem;
|
||||
background: rgba(64, 128, 224, .08);
|
||||
color: #93b4e8;
|
||||
font-size: .72rem;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.fb-pane-stub:hover { background: rgba(64, 128, 224, .18); border-color: #4080e0; }
|
||||
.fb-pane-stub:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
||||
.fb-pane-stub-glyph { font-size: .85rem; }
|
||||
|
||||
/* ── The dock ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.fb-pane-dock {
|
||||
position: fixed;
|
||||
top: 4.5rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 110; /* above #player (100), below toasts (120) */
|
||||
width: 22rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .6rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* A frame around cards, not a surface — the empty space below them must never
|
||||
eat a click meant for the highway. */
|
||||
pointer-events: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.fb-pane-dock.is-empty { display: none; }
|
||||
|
||||
.fb-pane-card {
|
||||
pointer-events: auto;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 23, 42, .96);
|
||||
border: 1px solid rgba(51, 65, 85, .6);
|
||||
border-radius: .9rem;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fb-pane-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding: .5rem .7rem;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, .5);
|
||||
background: rgba(30, 41, 59, .6);
|
||||
}
|
||||
.fb-pane-card-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.fb-pane-card-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: .35rem;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: .75rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fb-pane-card-btn:hover { background: rgba(51, 65, 85, .7); color: #e2e8f0; }
|
||||
.fb-pane-card-btn:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
||||
|
||||
.fb-pane-card-body { overflow: auto; }
|
||||
|
||||
/* focus(id) — a brief highlight, so re-opening an already-open pane says so
|
||||
instead of appearing to do nothing. */
|
||||
.fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; }
|
||||
@keyframes fb-pane-flash {
|
||||
0% { border-color: #4080e0; box-shadow: 0 0 0 3px rgba(64, 128, 224, .35), 0 12px 40px rgba(0, 0, 0, .5); }
|
||||
100% { border-color: rgba(51, 65, 85, .6); box-shadow: 0 12px 40px rgba(0, 0, 0, .5); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fb-pane-card.is-flash { animation: none; }
|
||||
}
|
||||
|
||||
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────────
|
||||
*
|
||||
* The window's own chrome — everything INSIDE it is the adopted element, styled by
|
||||
* the app's stylesheets, which the host copies into this document. */
|
||||
|
||||
html.fb-pane-window,
|
||||
html.fb-pane-window body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
}
|
||||
html.fb-pane-window body { display: flex; flex-direction: column; overflow: hidden; }
|
||||
#fb-pane-root {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: .6rem;
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+11
-2
@@ -120,11 +120,20 @@
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
_tuningsByKey = data.tunings || {};
|
||||
// Build TUNING_NOTE from the first (lowest) string frequency of each tuning.
|
||||
// Build TUNING_NOTE from the lowest string of each tuning. Prefer the
|
||||
// exact integer midis the server now sends (tuningMidis, #829) — the
|
||||
// frequency path reconstructs the note via log2 against a hardcoded
|
||||
// 440 and can land a semitone off at non-440 reference pitches.
|
||||
// Frequencies remain the fallback for older cached responses.
|
||||
const midisByKey = data.tuningMidis || {};
|
||||
TUNING_NOTE = {};
|
||||
for (const key of Object.keys(_tuningsByKey)) {
|
||||
for (const [name, freqs] of Object.entries(_tuningsByKey[key])) {
|
||||
if (!(name in TUNING_NOTE) && Array.isArray(freqs) && freqs.length > 0) {
|
||||
if (name in TUNING_NOTE) continue;
|
||||
const midis = midisByKey[key] && midisByKey[key][name];
|
||||
if (Array.isArray(midis) && midis.length > 0 && Number.isFinite(midis[0])) {
|
||||
TUNING_NOTE[name] = NOTE_NAMES[((midis[0] % 12) + 12) % 12];
|
||||
} else if (Array.isArray(freqs) && freqs.length > 0) {
|
||||
TUNING_NOTE[name] = _freqToNote(freqs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
// Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 50–89% mid, <50% low.
|
||||
function accuracyBadge(acc) {
|
||||
if (acc == null) return '';
|
||||
const pct = Math.round(acc * 100);
|
||||
// Floor, never round: 100% must mean every note hit.
|
||||
const pct = Math.floor(acc * 100);
|
||||
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
|
||||
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
|
||||
return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text +
|
||||
@@ -207,12 +208,17 @@
|
||||
'</div></div></div>' +
|
||||
continueCard +
|
||||
'</div>' +
|
||||
// Stats row
|
||||
// Stats row. The third slot belongs to the career plugin (it
|
||||
// replaces the slot's content on v3:dashboard-rendered); the
|
||||
// plugin-count stat is the built-in fallback when career is
|
||||
// absent or has no state yet.
|
||||
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
|
||||
audioRoutingCard() +
|
||||
statCard(String(songCount), 'songs', 'text-fb-gold') +
|
||||
'<div id="v3-dash-career-slot" class="grid">' +
|
||||
statCard(String(pluginCount), 'active', 'text-fb-good') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
recentSection +
|
||||
'</div>';
|
||||
|
||||
|
||||
+56
-14
@@ -99,6 +99,10 @@
|
||||
<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">
|
||||
<!-- Detachable panes: the pop-out chip, the dock, and the widgets built-in
|
||||
panes render with. Hand-authored (not Tailwind-scanned) so a
|
||||
runtime-installed plugin can use the chip without shipping its own CSS. -->
|
||||
<link rel="stylesheet" href="/static/panes/panes.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
|
||||
@@ -119,19 +123,19 @@
|
||||
script logs anything; load it as early as possible. See
|
||||
docs/diagnostics-bundle-spec.md (feedBack#166). -->
|
||||
<script defer src="/static/diagnostics.js"></script>
|
||||
<script defer src="/static/capabilities.js"></script>
|
||||
<script defer src="/static/capabilities/library.js"></script>
|
||||
<script defer src="/static/capabilities/tuning.js"></script>
|
||||
<script defer src="/static/capabilities/working-tuning.js"></script>
|
||||
<script defer src="/static/capabilities/audio-session.js"></script>
|
||||
<script defer src="/static/capabilities/audio-effects.js"></script>
|
||||
<script defer src="/static/capabilities/playback.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 defer src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script defer src="/static/capabilities/visualization.js"></script>
|
||||
<script defer src="/static/capabilities/note-detection.js"></script>
|
||||
<script defer src="/static/capabilities/midi-input.js"></script>
|
||||
<script defer 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">
|
||||
|
||||
@@ -749,6 +753,21 @@
|
||||
<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>
|
||||
<!-- Window options — desktop-only; setupWindowOptions() unhides. -->
|
||||
<div id="window-options-block" class="hidden">
|
||||
<div class="fb-srow">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Fullscreen</div>
|
||||
<div class="fb-srow-desc">Run fee[dB]ack in fullscreen mode. On macOS, changes take effect on the next launch.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<label class="fb-switch">
|
||||
<input type="checkbox" id="setting-start-fullscreen">
|
||||
<span class="fb-switch-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Library folder path -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
@@ -1048,6 +1067,10 @@
|
||||
<span class="v3-rail-border"></span>
|
||||
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
|
||||
</button>
|
||||
<button class="v3-rail-icon" type="button" data-rail="panes" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-panes" title="Panes" aria-label="Panes">
|
||||
<span class="v3-rail-border"></span>
|
||||
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19,4H5A2,2 0 0,0 3,6V18A2,2 0 0,0 5,20H19A2,2 0 0,0 21,18V6A2,2 0 0,0 19,4M13,18H5V6H13V18M19,18H15V6H19V18Z"/></svg>
|
||||
</button>
|
||||
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
|
||||
<span class="v3-rail-border"></span>
|
||||
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
|
||||
@@ -1064,6 +1087,15 @@
|
||||
injects into #player-controls (the auto-hiding transport) into
|
||||
this stable, always-reachable popover. See player-chrome.js
|
||||
(rehoming MutationObserver). -->
|
||||
<!-- Panes: open/close any registered detachable pane. Populated from
|
||||
the pane registry by static/panes/pane-launcher.js — a plugin
|
||||
that calls feedBack.panes.register() shows up here for free. -->
|
||||
<div id="v3-rail-pop-panes" class="v3-rail-pop hidden" role="group" aria-label="Panes">
|
||||
<div class="v3-pop-label">Panes</div>
|
||||
<div id="v3-rail-panes-list" class="flex flex-col gap-1"></div>
|
||||
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">Panes stay open while you play, and across song switches.</p>
|
||||
</div>
|
||||
|
||||
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
|
||||
<div class="v3-pop-label">Plugin controls</div>
|
||||
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
|
||||
@@ -1241,10 +1273,10 @@
|
||||
</main>
|
||||
<!-- /#v3-main -->
|
||||
|
||||
<script defer src="/static/highway.js"></script>
|
||||
<script type="module" src="/static/highway.js"></script>
|
||||
<script defer src="/static/vendor/lottie.min.js"></script>
|
||||
<script defer src="/static/lottie-api.js"></script>
|
||||
<script defer src="/static/app.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>
|
||||
@@ -1275,6 +1307,7 @@
|
||||
saved 'off'/'full' motion preference on first paint. -->
|
||||
<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/venue-crowd.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>
|
||||
@@ -1299,6 +1332,15 @@
|
||||
<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>
|
||||
<!-- Detachable panes. The manager first; then the hosts, which register
|
||||
themselves with it; then the chip and the launcher, which drive it.
|
||||
pane-desktop only does anything inside the desktop app. -->
|
||||
<script defer src="/static/panes/pane-manager.js"></script>
|
||||
<script defer src="/static/panes/pane-dock.js"></script>
|
||||
<script defer src="/static/panes/pane-window-host.js"></script>
|
||||
<script defer src="/static/panes/pane-desktop.js"></script>
|
||||
<script defer src="/static/panes/pane-chip.js"></script>
|
||||
<script defer src="/static/panes/pane-launcher.js"></script>
|
||||
<script>
|
||||
// Navbar scroll effect
|
||||
window.addEventListener('scroll', () => {
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
if (st && st.passed) return '<span class="text-fb-good text-xs font-bold flex items-center gap-1">✓ Passed</span>';
|
||||
if (st && st.best_accuracy != null && st.best_accuracy > 0) {
|
||||
const acc = st.best_accuracy;
|
||||
const pct = Math.round(acc * 100);
|
||||
const pct = Math.floor(acc * 100);
|
||||
const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
|
||||
return '<span class="' + color + ' text-xs font-bold">' + pct + '%</span>';
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
function accuracyPct(hits, misses) {
|
||||
const judged = hits + misses;
|
||||
if (judged <= 0) return null;
|
||||
return Math.round((hits / Math.max(1, judged)) * 100);
|
||||
// Floor, never round: 100% must mean every judged note was hit.
|
||||
return Math.floor((hits / Math.max(1, judged)) * 100);
|
||||
}
|
||||
|
||||
function calculateLivePerformanceState({ hits = 0, misses = 0, streak = 0, bestStreak = 0 } = {}) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* auto-hiding bottom transport, and the speed-level visual (bars + chevrons).
|
||||
*
|
||||
* Design contract: the actual controls are the SAME legacy elements/handlers
|
||||
* (ids unchanged), just relocated into rail popovers — so app.js/highway.js
|
||||
* (ids unchanged), just relocated into rail popovers — so app.js/window.highway.js
|
||||
* keep populating and reacting to them unmodified. This module only adds
|
||||
* presentation behavior (open/close, reveal/hide, mirror state). It runs only
|
||||
* while #player is the active screen.
|
||||
@@ -146,8 +146,8 @@
|
||||
const rail = $('v3-player-rail');
|
||||
const lyr = rail && rail.querySelector('[data-rail-action="lyrics"]');
|
||||
if (!lyr) return;
|
||||
const on = (window.highway && typeof highway.getLyricsVisible === 'function')
|
||||
? highway.getLyricsVisible()
|
||||
const on = (window.highway && typeof window.highway.getLyricsVisible === 'function')
|
||||
? window.highway.getLyricsVisible()
|
||||
: lyr.classList.contains('is-active');
|
||||
lyr.classList.toggle('is-active', !!on);
|
||||
lyr.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
@@ -159,13 +159,13 @@
|
||||
rail.querySelectorAll('[data-rail]').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); openPopFor(b); }));
|
||||
// Mic icon: a direct lyrics toggle (clicks the hidden canonical button so
|
||||
// highway.toggleLyrics() + any label logic runs), mirroring on/off state.
|
||||
// window.highway.toggleLyrics() + any label logic runs), mirroring on/off state.
|
||||
const lyr = rail.querySelector('[data-rail-action="lyrics"]');
|
||||
if (lyr) lyr.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const real = $('btn-lyrics');
|
||||
if (real) real.click(); // runs highway.toggleLyrics() via its onclick
|
||||
else if (window.highway && typeof highway.toggleLyrics === 'function') highway.toggleLyrics();
|
||||
if (real) real.click(); // runs window.highway.toggleLyrics() via its onclick
|
||||
else if (window.highway && typeof window.highway.toggleLyrics === 'function') window.highway.toggleLyrics();
|
||||
syncLyricsIcon(); // reflect the ACTUAL toggled state, not click parity
|
||||
});
|
||||
// Click-outside + Esc close (bound once; harmless when no popover open).
|
||||
@@ -288,7 +288,7 @@
|
||||
if (t - lastUpNext >= UPNEXT_MS) {
|
||||
lastUpNext = t;
|
||||
updateUpNext();
|
||||
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
|
||||
// Re-sync the lyrics icon so programmatic window.highway.setLyricsVisible()
|
||||
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
|
||||
syncLyricsIcon();
|
||||
// Reconcile the edge-driven hover flag against ground truth at
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
|
||||
: '';
|
||||
const acc = (isAlbum && typeof opts.acc === 'number')
|
||||
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
|
||||
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(opts.acc * 100) + '%</span>'
|
||||
: '';
|
||||
const pin = (isAlbum && s.arrangement)
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
|
||||
|
||||
@@ -191,6 +191,10 @@
|
||||
'<div class="space-y-6">' +
|
||||
headerCard +
|
||||
bestsCard +
|
||||
// Passport wall — rendered by the career plugin on
|
||||
// v3:profile-rendered (absent-not-empty: nothing shows until a
|
||||
// passport exists).
|
||||
'<div id="v3-profile-passports-mount"></div>' +
|
||||
// Feats of Power trophy shelf — rendered by the achievements plugin
|
||||
// (earned Feats only; hidden-until-earned, so empty when none).
|
||||
'<div id="v3-profile-feats-slot"></div>' +
|
||||
@@ -232,7 +236,7 @@
|
||||
host.innerHTML =
|
||||
'<ol class="space-y-2">' + rows.map((s, i) => {
|
||||
const acc = Number(s.best_accuracy) || 0;
|
||||
const pct = Math.round(acc * 100);
|
||||
const pct = Math.floor(acc * 100);
|
||||
const score = Number(s.best_score) || 0;
|
||||
return '<li data-fn="' + esc(s.filename) + '" class="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 hover:bg-fb-card transition">' +
|
||||
'<span class="w-5 text-center text-fb-textDim font-semibold shrink-0">' + (i + 1) + '</span>' +
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
const onboarding = st.onboarding || {};
|
||||
if (onboarding.calibration_status === 'completed') return; // raced a 100% run
|
||||
const pending = onboarding.calibration_status === 'pending';
|
||||
const pct = Math.max(0, Math.min(100, Math.round((detail.accuracy || 0) * 100)));
|
||||
const pct = Math.max(0, Math.min(100, Math.floor((detail.accuracy || 0) * 100)));
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'v3-calibration-retry';
|
||||
|
||||
+40
-11
@@ -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' },
|
||||
@@ -318,22 +320,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── showScreen wrapper (idempotent rehydration — design/05 §Rehydration) ─
|
||||
// ── Stay in sync with the active screen (idempotent rehydration — design/05) ─
|
||||
//
|
||||
// This USED to monkey-patch window.showScreen. It doesn't any more, and that is the point.
|
||||
//
|
||||
// Three parties were wrapping that one global — app.js publishes it, this wrapped it, and the
|
||||
// stems plugin wrapped it again — each capturing whatever happened to be there at the time.
|
||||
// Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled, and
|
||||
// a capture taken before this installed silently dropped the home -> v3-songs mapping this
|
||||
// wrapper carried. That is why the library intermittently showed the legacy screen (#923).
|
||||
//
|
||||
// The mapping lives inside showScreen() now, where no wrapper can lose it. And everything
|
||||
// left here is just "the screen changed" — which showScreen already EMITS, and which app.js,
|
||||
// audio-mixer.js and tour-engine.js have always listened for rather than patching.
|
||||
//
|
||||
// So: be a listener, like everyone else. window.showScreen is a plain function again.
|
||||
function installShowScreenHook() {
|
||||
const hooks = window.__feedBackV3ShellHooks || (window.__feedBackV3ShellHooks = {});
|
||||
hooks.syncActive = syncActive; // always point at the latest impl
|
||||
hooks.syncActive = syncActive; // always point at the latest impl
|
||||
if (hooks.installed) return;
|
||||
hooks.installed = true;
|
||||
hooks.baseShowScreen = window.showScreen;
|
||||
window.showScreen = function (id) {
|
||||
// Route every "go to the library" navigation to the v3 native Songs
|
||||
// screen instead of the legacy #home library, so player-close,
|
||||
// settings-back, the hidden legacy navbar, etc. all stay in v3.
|
||||
const target = (id === 'home') ? 'v3-songs' : id;
|
||||
const r = hooks.baseShowScreen ? hooks.baseShowScreen.call(this, target) : undefined;
|
||||
try { hooks.syncActive && hooks.syncActive(target); } catch (e) { /* non-fatal */ }
|
||||
return r;
|
||||
|
||||
// RETRY IF THE BUS IS LATE. The old wrapper didn't need window.feedBack to exist; a
|
||||
// listener does. Bailing out when it isn't ready yet would silently leave the sidebar
|
||||
// highlight and topbar title frozen forever — a dead nav, with nothing thrown. (Codex
|
||||
// caught the identical hole in the stems plugin's version of this.)
|
||||
const wire = () => {
|
||||
const bus = window.feedBack;
|
||||
if (!bus || typeof bus.on !== 'function') {
|
||||
// `feedBack:capabilities:ready` — capabilities.js:1536. NOT the slopsmith: name:
|
||||
// that was the pre-DMCA event and NOTHING dispatches it any more, so a fallback
|
||||
// keyed on it can never fire. Codex caught exactly that here. (The old alias is
|
||||
// kept too, in case an older capabilities build is in play.)
|
||||
window.addEventListener('feedBack:capabilities:ready', wire, { once: true });
|
||||
window.addEventListener('slopsmith:capabilities:ready', wire, { once: true });
|
||||
return;
|
||||
}
|
||||
bus.on('screen:changed', (ev) => {
|
||||
const id = ev && ev.detail && ev.detail.id;
|
||||
if (!id) return;
|
||||
try { hooks.syncActive && hooks.syncActive(id); } catch (e) { /* non-fatal */ }
|
||||
});
|
||||
};
|
||||
wire();
|
||||
}
|
||||
|
||||
// ── Boot ────────────────────────────────────────────────────────────────
|
||||
|
||||
+3
-2
@@ -482,7 +482,8 @@
|
||||
function accuracyBadge(filename, variant) {
|
||||
const acc = state.accuracy[filename];
|
||||
if (acc == null) return '';
|
||||
const pct = Math.round(acc * 100);
|
||||
// Floor, never round: 100% must mean every note hit.
|
||||
const pct = Math.floor(acc * 100);
|
||||
if (variant === 'tree') {
|
||||
const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
|
||||
return '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>';
|
||||
@@ -1312,7 +1313,7 @@
|
||||
c.year ? String(c.year) : '']
|
||||
.filter(Boolean).join(' · ');
|
||||
const acc = (typeof c.best_accuracy === 'number')
|
||||
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(c.best_accuracy * 100) + '%</span>'
|
||||
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(c.best_accuracy * 100) + '%</span>'
|
||||
: '<span class="text-fb-textDim/60">not played</span>';
|
||||
return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' +
|
||||
' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' +
|
||||
|
||||
@@ -27,7 +27,60 @@
|
||||
let cur = null; // active session
|
||||
let recordedThisSession = false;
|
||||
|
||||
// Wall-clock play time (career hours odometer). Accrued across
|
||||
// play/resume ↔ pause/stop/ended spans — wall time, NOT song position:
|
||||
// position deltas double-count A-B loops and mis-read seeks.
|
||||
let playingSince = 0; // performance.now() at span start, 0 while not playing
|
||||
let accruedSeconds = 0; // played time not yet sent
|
||||
// Failed seconds keep their song identity — restoring them into the
|
||||
// global accumulator would let the NEXT song claim them after a session
|
||||
// switch. Bounded; oldest dropped beyond the cap (honest loss beats
|
||||
// misattribution).
|
||||
let pendingSeconds = []; // [{filename, arrangement, seconds}] awaiting retry
|
||||
|
||||
function queuePendingSeconds(filename, arrangement, seconds) {
|
||||
pendingSeconds.push({ filename, arrangement, seconds });
|
||||
if (pendingSeconds.length > 20) pendingSeconds.shift();
|
||||
}
|
||||
|
||||
function retryPendingSeconds() {
|
||||
if (!pendingSeconds.length) return;
|
||||
const batch = pendingSeconds;
|
||||
pendingSeconds = [];
|
||||
for (const body of batch) {
|
||||
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, body.seconds); });
|
||||
}
|
||||
}
|
||||
|
||||
function clockStart() { if (!playingSince) playingSince = performance.now(); }
|
||||
function clockStop() {
|
||||
if (!playingSince) return;
|
||||
const delta = (performance.now() - playingSince) / 1000;
|
||||
playingSince = 0;
|
||||
// A single unbroken span beyond 2h of wall clock is a suspend/sleep
|
||||
// artifact, not practice — clamp it.
|
||||
if (Number.isFinite(delta) && delta > 0) accruedSeconds += Math.min(delta, 7200);
|
||||
}
|
||||
// Take whatever has accrued (closing any open span) for sending; the
|
||||
// caller restores it if the POST fails so the time isn't lost.
|
||||
function takeSeconds() {
|
||||
clockStop();
|
||||
const s = Math.round(accruedSeconds);
|
||||
accruedSeconds = 0;
|
||||
return s > 0 ? s : 0;
|
||||
}
|
||||
// Unsent seconds belong to the outgoing song/arrangement — flush before
|
||||
// a session reset would re-attribute them.
|
||||
function flushSeconds() {
|
||||
const s = takeSeconds();
|
||||
if (!s) return;
|
||||
if (!cur || !cur.filename) return; // no session to attribute to — drop
|
||||
const body = { filename: cur.filename, arrangement: cur.arrangement, seconds: s };
|
||||
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, s); });
|
||||
}
|
||||
|
||||
function reset(filename, arrangement) {
|
||||
flushSeconds();
|
||||
cur = {
|
||||
filename: filename || null,
|
||||
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
|
||||
@@ -48,6 +101,10 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// A 4xx/5xx JSON error body must read as FAILURE — callers
|
||||
// re-queue accrued seconds on null, and a parsed error object
|
||||
// would silently drop them.
|
||||
if (!r.ok) return null;
|
||||
try { return await r.json(); } catch (e) { return null; }
|
||||
} catch (e) { return null; /* offline / endpoint absent — non-fatal */ }
|
||||
}
|
||||
@@ -84,6 +141,7 @@
|
||||
if (!cur || !cur.filename || recordedThisSession) return;
|
||||
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
|
||||
recordedThisSession = true;
|
||||
const seconds = takeSeconds();
|
||||
const body = {
|
||||
filename: cur.filename,
|
||||
arrangement: cur.arrangement,
|
||||
@@ -94,7 +152,9 @@
|
||||
bestStreak: cur.bestStreak,
|
||||
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
|
||||
};
|
||||
if (seconds) body.seconds = seconds;
|
||||
post(body).then(async (response) => {
|
||||
if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds);
|
||||
await notifyProgression(response, body, !!natural);
|
||||
// Refresh the profile badge AFTER the progression state moved so
|
||||
// the rank/dB it renders are post-award values.
|
||||
@@ -112,7 +172,10 @@
|
||||
// Allow 0: restarting a song and stopping at the very beginning must be
|
||||
// able to clear a stale Continue offset. Only negatives are invalid.
|
||||
if (!Number.isFinite(position) || position < 0) return;
|
||||
post({ filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position });
|
||||
const seconds = takeSeconds();
|
||||
const body = { filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position };
|
||||
if (seconds) body.seconds = seconds;
|
||||
post(body).then((r) => { if (r == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds); });
|
||||
}
|
||||
|
||||
// ── Session lifecycle ─────────────────────────────────────────────────--
|
||||
@@ -164,13 +227,28 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Play-time clock ───────────────────────────────────────────────────--
|
||||
sm.on('song:play', () => { clockStart(); retryPendingSeconds(); });
|
||||
sm.on('song:resume', clockStart);
|
||||
|
||||
// ── Finalize / resume-position ────────────────────────────────────────--
|
||||
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true));
|
||||
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time));
|
||||
sm.on('song:ended', (e) => {
|
||||
clockStop();
|
||||
finalizeScored(e && e.detail && e.detail.time, true);
|
||||
// Unscored natural end: no finalize POST and no position touch
|
||||
// (Continue must not point at the end of the song) — bank the play
|
||||
// time on its own.
|
||||
flushSeconds();
|
||||
});
|
||||
sm.on('song:pause', (e) => {
|
||||
clockStop();
|
||||
touchPosition(e && e.detail && e.detail.time);
|
||||
});
|
||||
sm.on('song:stop', (e) => {
|
||||
// Record the scored session if it wasn't already (e.g. user closed the
|
||||
// player before the track ended), then persist the resume position.
|
||||
// Not a natural end — no calibration-retry prompt for deliberate quits.
|
||||
clockStop();
|
||||
const t = e && e.detail && e.detail.time;
|
||||
finalizeScored(t, false);
|
||||
touchPosition(t);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user