mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 17:34:29 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8807c139e | ||
|
|
51c2e15e15 | ||
|
|
946132d40c | ||
|
|
0d35228d56 | ||
|
|
dd1927e27b | ||
|
|
7c897e9f2b | ||
|
|
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 |
@@ -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__/
|
||||
|
||||
+172
@@ -7,6 +7,173 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Gold tier (career passports)** — an earned badge turns **gold** when
|
||||
Virtuoso verifies an improvised jam in the passport's style (the
|
||||
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
|
||||
family's style, gained-only, and gold never substitutes for the badge bar
|
||||
itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on
|
||||
the shelf cover, profile wall, and passport card; the bronze page's "Gold
|
||||
rung coming" preview becomes a live invitation to jam it.
|
||||
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
|
||||
a gig poster proposes the setlist (re-roll for a different bill; save or
|
||||
copy the poster as a PNG), "Play the gig" hands the set to the play queue
|
||||
with the venue on stage, a floating strip tracks the set, and finishing it
|
||||
logs dated entries with per-song accuracies in the passport book — with an
|
||||
encore celebration (crowd eruption + confetti) when the whole set clears
|
||||
the bar, and a summary poster to share. Quitting mid-set simply abandons
|
||||
it: no log, no fail state.
|
||||
- **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 +194,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.
|
||||
|
||||
@@ -61,6 +61,8 @@ extractions and twenty-two `routers/` modules, plus lib/library_registry.py for
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
|
||||
(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a
|
||||
`scriptType: module` file when career work next touches it) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
by policy — the norm governs source files.
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ module.exports = [
|
||||
// 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', 'static/app.js', 'static/js/**/*.js'],
|
||||
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.
|
||||
+44
-6
@@ -288,17 +288,55 @@ def demo_mode_enabled() -> bool:
|
||||
|
||||
|
||||
def start_janitor() -> None:
|
||||
"""Start the hourly session janitor. Called from server.py's startup hook.
|
||||
"""Start the hourly session janitor, at most one at a time. server.py's startup hook.
|
||||
|
||||
NB the caller's guard is the buggy one described in this module's header (issue #902).
|
||||
Behaviour is preserved verbatim: this starts a thread every time it is called.
|
||||
━━━ 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
|
||||
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
|
||||
_DEMO_JANITOR_STOP.clear()
|
||||
|
||||
def _janitor():
|
||||
while not _DEMO_JANITOR_STOP.wait(timeout=3600):
|
||||
# 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:
|
||||
|
||||
+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)
|
||||
|
||||
+98
-15
@@ -23,9 +23,18 @@ Engine selection
|
||||
Two transcription paths share a common output:
|
||||
|
||||
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
||||
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
|
||||
reference server already hosts WhisperX alongside Demucs at the same
|
||||
URL).
|
||||
stem to the `/transcribe` endpoint on a feedBack-demucs-server
|
||||
(got-feedBack's reference server already hosts WhisperX alongside
|
||||
Demucs at the same URL).
|
||||
|
||||
It used to POST to `/align`, which is *forced alignment* — "here are
|
||||
the lyrics, tell me when each word is sung". Its `text` field is
|
||||
required and we have no lyrics (transcribing them is the point), so
|
||||
the server answered 422 from FastAPI's validation layer before its
|
||||
handler ran, and remote transcription never worked for anyone
|
||||
(feedBack-plugin-stem-splitter#17). `/transcribe` takes only audio.
|
||||
Requires feedBack-demucs-server ≥ the revision adding that endpoint;
|
||||
an older server answers 404 and the error says so.
|
||||
|
||||
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
||||
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
||||
@@ -416,6 +425,38 @@ def transcribe_vocals_local(
|
||||
|
||||
# ── Remote transcription ────────────────────────────────────────────────────
|
||||
|
||||
_MAX_ERR_BODY = 4000
|
||||
|
||||
|
||||
def _err_body(resp) -> str:
|
||||
"""The server's error body, whole if it plausibly is one, and marked when it isn't.
|
||||
|
||||
This was capped at 300 chars, which is enough for "Internal Server Error" and not much else.
|
||||
The bodies carrying the most diagnosis are the long ones — a FastAPI validation body naming
|
||||
the field it rejected, a 500 whose traceback answers on its LAST line — and those are exactly
|
||||
the ones a 300-char cap decapitates. The cap survives so a server answering with a 2 MB HTML
|
||||
error page can't dump a novel into a log line.
|
||||
"""
|
||||
# Strip FIRST, then measure: a body that is 300 chars of JSON and 3900 of trailing whitespace
|
||||
# is not a long body, and truncating it would cut real content to make room for blanks.
|
||||
text = (getattr(resp, "text", "") or "").strip()
|
||||
if len(text) <= _MAX_ERR_BODY:
|
||||
return text
|
||||
|
||||
# Keep the HEAD **and the TAIL**. Head-only truncation throws away the exception line — and
|
||||
# on a traceback the exception line is the answer. This docstring said as much while the code
|
||||
# did the opposite: it cut off precisely the part it exists to preserve, which is the same
|
||||
# mistake, one level up, as the 300-char cap it replaced.
|
||||
#
|
||||
# The marker sits inside the bound, not past it: otherwise _MAX_ERR_BODY is a suggestion, and
|
||||
# the callers who trust it (a log line, a job record persisted to disk) are the ones surprised.
|
||||
marker = f"\n… [truncated, {len(text)} chars total] …\n"
|
||||
budget = max(0, _MAX_ERR_BODY - len(marker))
|
||||
head = budget * 2 // 3 # context: what was being attempted
|
||||
tail = budget - head # verdict: what actually went wrong
|
||||
return text[:head].rstrip() + marker + text[len(text) - tail:].lstrip()
|
||||
|
||||
|
||||
def transcribe_vocals_remote(
|
||||
vocals_path: Path,
|
||||
server_url: str,
|
||||
@@ -426,7 +467,17 @@ def transcribe_vocals_remote(
|
||||
min_word_score: float = 0.35,
|
||||
progress_cb: ProgressCB = None,
|
||||
) -> list[dict]:
|
||||
"""POST the vocal stem to `{server_url}/align` and parse the response.
|
||||
"""POST the vocal stem to `{server_url}/transcribe` and parse the response.
|
||||
|
||||
NOT `/align` — that endpoint is forced alignment ("here are the lyrics,
|
||||
tell me when each word is sung") and its `text` field is required. We
|
||||
have no lyrics; producing them is the point. Posting there returned a
|
||||
422 from FastAPI's validation layer before the server's handler ran, so
|
||||
remote transcription never worked at all
|
||||
(feedBack-plugin-stem-splitter#17).
|
||||
|
||||
Requires a feedBack-demucs-server carrying `/transcribe`; an older one
|
||||
answers 404 and the raised error says so.
|
||||
|
||||
Expects the server to respond with a JSON object carrying a `words` (or
|
||||
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
|
||||
@@ -454,21 +505,53 @@ def transcribe_vocals_remote(
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
params: dict[str, str] = {}
|
||||
# POST to /transcribe, not /align.
|
||||
#
|
||||
# /align is FORCED ALIGNMENT: "here are the lyrics, tell me when each word is sung". Its
|
||||
# `text` field is required, and we have no lyrics — transcription is the whole point. So the
|
||||
# server rejected every request with a 422 in FastAPI's validation layer, before its handler
|
||||
# ever ran, and remote transcription has never worked for anyone. /transcribe answers the
|
||||
# question we are actually asking and takes only the audio.
|
||||
# (feedBack-plugin-stem-splitter#17; endpoint added in feedBack-demucs-server#14.)
|
||||
#
|
||||
# `language` goes in the FORM BODY, not the query string: the server reads it with
|
||||
# Form(""), and a query param would be silently ignored — so an explicit language hint would
|
||||
# do nothing and Whisper's auto-detection would quietly decide instead, which is exactly the
|
||||
# kind of "it works but it's wrong" that hides for months.
|
||||
form: dict[str, str] = {}
|
||||
if language:
|
||||
params["language"] = language
|
||||
form["language"] = language
|
||||
|
||||
with open(vocals_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{server_url}/align",
|
||||
files={"file": (vocals_path.name, f, "audio/ogg")},
|
||||
params=params,
|
||||
headers=headers or None,
|
||||
timeout=timeout,
|
||||
# Everything that can go wrong out here comes back as RuntimeError, which is what the
|
||||
# docstring promises and what the caller catches. A DNS failure, a timeout, a reset
|
||||
# connection or an unreadable stem file would otherwise surface as requests.RequestException
|
||||
# or OSError and escape the one handler written to log-and-continue — turning "this song's
|
||||
# lyrics failed" into "the whole batch died".
|
||||
try:
|
||||
with open(vocals_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{server_url}/transcribe",
|
||||
files={"file": (vocals_path.name, f, "audio/ogg")},
|
||||
data=form or None,
|
||||
headers=headers or None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(f"could not reach the WhisperX server at {server_url}: {e}") from e
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"could not read the vocal stem {vocals_path.name}: {e}") from e
|
||||
|
||||
if resp.status_code == 404:
|
||||
# The endpoint isn't there. Say what that means, because "404" on its own sends someone
|
||||
# hunting for a typo in their URL when the real answer is that their server predates the
|
||||
# feature. (feedBack-demucs-server#14 added /transcribe.)
|
||||
raise RuntimeError(
|
||||
f"the WhisperX server at {server_url} has no /transcribe endpoint (404) — it "
|
||||
f"predates remote transcription support. Update the server, or use 'Check for "
|
||||
f"update' if it is the plugin-managed one."
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
|
||||
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {_err_body(resp)}")
|
||||
|
||||
data = resp.json()
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
/* 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 ink — a REAL gold badge (comb-verified improv). */
|
||||
.pp-stamp-gold {
|
||||
border-color: #b8860b;
|
||||
color: #a97b1b;
|
||||
box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b;
|
||||
}
|
||||
.pp-stamp-mini.pp-stamp-gold {
|
||||
color: #f0c75e;
|
||||
border-color: #f0c75e;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Gold foil chip — rendered only alongside an earned gold stamp. */
|
||||
.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 solid #d9a253;
|
||||
color: #c89040;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
|
||||
|
||||
.pp-poster {
|
||||
position: relative;
|
||||
width: min(92vw, 420px);
|
||||
padding: 2rem 1.6rem 1.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(180deg, #141019, #241318);
|
||||
border: 2px solid rgba(217, 162, 83, 0.45);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
|
||||
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
|
||||
.pp-poster-title {
|
||||
color: #d9a253;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
|
||||
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
|
||||
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
|
||||
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
|
||||
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
|
||||
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
|
||||
.pp-poster-summary { cursor: default; }
|
||||
|
||||
.pp-gig-strip {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
|
||||
background: rgba(10, 8, 14, 0.85);
|
||||
border: 1px solid rgba(217, 162, 83, 0.4);
|
||||
border-radius: 999px;
|
||||
color: rgba(240, 226, 195, 0.85);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
|
||||
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
|
||||
|
||||
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
|
||||
.pp-giglog-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
|
||||
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,917 @@
|
||||
"""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 {}
|
||||
gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {}
|
||||
return doc.get("received_at"), by_node, gold
|
||||
|
||||
|
||||
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 _merge_gold(old, new):
|
||||
"""Gained-only merge of goldImprov artifacts: a minted style never
|
||||
un-mints via a stale relay; the FIRST artifact per style is kept."""
|
||||
out = dict(old)
|
||||
for style_id, art in (new or {}).items():
|
||||
if isinstance(art, dict) and style_id not in out:
|
||||
out[style_id] = art
|
||||
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, gold_improv = _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):
|
||||
# Bronze is earned; GOLD upgrades it when a verified improv
|
||||
# artifact exists for this genre's jam style. Virtuoso mints
|
||||
# under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...),
|
||||
# which are mostly NOT family keys — so match in family space:
|
||||
# the same keyword bucketing genres get ('punk' and 'punk
|
||||
# rock' both bucket to 'rock'), with the exact key as a direct
|
||||
# hit. Bronze remains a standalone win; gold never becomes an
|
||||
# obligation.
|
||||
fam = _genre_family(gkey)
|
||||
gold = any(
|
||||
s == gkey or (fam is not None and _genre_family(s) == fam)
|
||||
for s in gold_improv
|
||||
)
|
||||
badge = "gold" if gold else "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.")
|
||||
gold_in = body.get("goldImprov", {})
|
||||
if not isinstance(gold_in, dict):
|
||||
# A relay bug must be LOUD, not a silent 200 that drops gold.
|
||||
raise HTTPException(400, "goldImprov must be an object keyed by style id.")
|
||||
# Keep only plausible artifacts: a dict that names its verifier —
|
||||
# an empty {} must not mint an evidence-free gold.
|
||||
gold_in = {k: v for k, v in gold_in.items()
|
||||
if isinstance(v, dict) and v.get("verifier")}
|
||||
# Same pre-merge bound byNode gets: the gained-only merge dropping
|
||||
# junk must not become a size-guard bypass (nor lock-held CPU burn).
|
||||
if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
with _lock:
|
||||
_, existing, existing_gold = _drill_by_node()
|
||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||
"byNode": _merge_drill_nodes(existing, body["byNode"]),
|
||||
"goldImprov": _merge_gold(existing_gold, gold_in)}
|
||||
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,249 @@
|
||||
// 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);
|
||||
});
|
||||
|
||||
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
let remaining = 1;
|
||||
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
|
||||
t.setGigRun({
|
||||
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
|
||||
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
|
||||
});
|
||||
// First song ends, one remains → the strip advances, no completion.
|
||||
t.onGigSongEnded();
|
||||
assert.equal(t.getGigRun().idx, 1);
|
||||
// Stop while the queue is still active (end-of-song teardown) → run survives.
|
||||
t.onGigSongStop();
|
||||
assert.notEqual(t.getGigRun(), null);
|
||||
// User quits: queue cleared → stop with a dead queue abandons (no log).
|
||||
remaining = 0;
|
||||
t.onGigSongStop();
|
||||
assert.equal(t.getGigRun(), null);
|
||||
});
|
||||
|
||||
test('a gold upgrade notifies even when the bronze moment was already seen', () => {
|
||||
// Bronze seen under the legacy un-suffixed id; the badge then turns gold.
|
||||
const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||
const t = w.__careerPassportTest;
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } };
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
assert.match(w.notifications[0].title, /Gold/);
|
||||
// Same session: no duplicate.
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
// Gold slam seen → fresh session stays silent.
|
||||
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('a gold slam marks the bronze moment seen too — never both ceremonies', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||
const seen = JSON.parse(JSON.stringify(t.seenBadges()));
|
||||
assert.equal(seen['guitar/blues@gold'], 1);
|
||||
assert.equal(seen['guitar/blues'], 1);
|
||||
// A later view where the badge reads 'earned' (e.g. gold state lost
|
||||
// server-side) must not replay the bronze ceremony.
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('careerTotals counts gold badges on the wall', () => {
|
||||
const t = load().__careerPassportTest;
|
||||
t.setView({
|
||||
config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 },
|
||||
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 },
|
||||
] } },
|
||||
});
|
||||
const totals = t.careerTotals();
|
||||
assert.equal(totals.badges, 1);
|
||||
assert.equal(totals.walls[0].earned[0].badge, 'gold');
|
||||
});
|
||||
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).
|
||||
|
||||
+104
-2229
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,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;
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ const _CREDITS_HOLD_MS = 3000;
|
||||
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
|
||||
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
|
||||
// a count-in handoff that never plays). This hard cap guarantees the credits
|
||||
// never linger over the highway. Generous enough to outlast a normal count-in.
|
||||
// never linger over the window.highway. Generous enough to outlast a normal count-in.
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
export function _cancelCountIn() {
|
||||
_countInGen++;
|
||||
@@ -107,7 +107,7 @@ function _creditLineLabel(role) {
|
||||
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
|
||||
}
|
||||
|
||||
// Show the feedpak contributor credits over the highway. `authors` is the
|
||||
// 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
|
||||
@@ -196,7 +196,7 @@ export async function startCountIn(opts = {}) {
|
||||
return;
|
||||
}
|
||||
S.lastAudioTime = loopA;
|
||||
highway.setTime(loopA);
|
||||
window.highway.setTime(loopA);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export async function startCountIn(opts = {}) {
|
||||
// Ease out quad
|
||||
const eased = 1 - (1 - t) * (1 - t);
|
||||
const currentT = fromTime + (toTime - fromTime) * eased;
|
||||
highway.setTime(currentT);
|
||||
window.highway.setTime(currentT);
|
||||
if (t < 1) {
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
} else {
|
||||
@@ -262,7 +262,7 @@ export async function startCountIn(opts = {}) {
|
||||
// marker for "new iteration starts at A", not the actual
|
||||
// audio position.
|
||||
S.lastAudioTime = r.to;
|
||||
highway.setTime(r.to);
|
||||
window.highway.setTime(r.to);
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
beginCount();
|
||||
});
|
||||
@@ -271,7 +271,7 @@ export async function startCountIn(opts = {}) {
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
|
||||
function beginCount() {
|
||||
const bpm = highway.getBPM(loopA);
|
||||
const bpm = window.highway.getBPM(loopA);
|
||||
const beatInterval = 60 / bpm;
|
||||
let count = 0;
|
||||
|
||||
@@ -339,7 +339,7 @@ export async function startSongCountIn() {
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
const startT = S.lastAudioTime || 0;
|
||||
let bpm = highway.getBPM(startT);
|
||||
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;
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
// 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 {
|
||||
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
|
||||
}
|
||||
try {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed during download: ${e.message}`;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -155,7 +155,7 @@ function _hwcSlotKeysForChart(sc, isBass) {
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
}
|
||||
|
||||
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
|
||||
// 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 (_) {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -111,7 +111,7 @@ import { S } from './player-state.js';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// highway.js's initial song-load routing consults this for the same
|
||||
// window.highway.js's initial song-load routing consults this for the same
|
||||
// feedpak-under-exclusive decision the watcher makes below.
|
||||
window._juceOutputIsExclusive = _outputIsExclusive;
|
||||
// Returns true when window._currentSongAudio no longer references the exact
|
||||
@@ -383,7 +383,7 @@ import { S } from './player-state.js';
|
||||
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
|
||||
// are never routable (per-stem mix can't ride a single transport).
|
||||
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
|
||||
// Don't race highway.js's own initial song-load routing: it owns
|
||||
// Don't race window.highway.js's own initial song-load routing: it owns
|
||||
// _juceMode until _juceRoutingPromise settles. Re-running our switch
|
||||
// concurrently would double-call loadBackingTrack for the same URL.
|
||||
if (window._highwayJuceRoutingPending) return;
|
||||
|
||||
@@ -160,7 +160,7 @@ export function _resetPlaybackSpeedForNewSong() {
|
||||
//
|
||||
// Debounced trailing-edge (300ms) so dragging the slider — which fires
|
||||
// oninput per pixel — doesn't flood the server with concurrent writes
|
||||
// to config.json. highway.setMastery() still fires every oninput so
|
||||
// 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) {
|
||||
@@ -209,7 +209,7 @@ export function _applyMastery(v, opts = {}) {
|
||||
// unlike #mastery-label above, whose markup carries no trailing unit.
|
||||
const setLabel = document.getElementById('setting-highway-speed-val');
|
||||
if (setLabel) setLabel.textContent = pct;
|
||||
highway.setMastery(pct / 100);
|
||||
window.highway.setMastery(pct / 100);
|
||||
if (!opts.skipPersist) _persistMastery(pct);
|
||||
}
|
||||
// Reflect phrase-data availability on the slider after every `ready`.
|
||||
|
||||
@@ -31,12 +31,12 @@ const _RESUME_END_GUARD_S = 5; // ignore basically-finished
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
// 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 highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
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
|
||||
|
||||
@@ -38,7 +38,7 @@ export function _sectionPracticeBarContains(el) {
|
||||
}
|
||||
|
||||
// ── Section Practice Bar ────────────────────────────────────────────────
|
||||
// One-click looping over song section markers (highway.getSections —
|
||||
// One-click looping over song section markers (window.highway.getSections —
|
||||
// same array as 3D highway bundle.sections / "Now / Up Next").
|
||||
// Reuses setLoop() so manual A/B controls and saved loops stay canonical.
|
||||
let _sectionPracticeRanges = [];
|
||||
@@ -127,7 +127,7 @@ export function _resetSectionPracticeLog() {
|
||||
}
|
||||
|
||||
function _sectionPracticeHighway() {
|
||||
return window.highway || (typeof highway !== 'undefined' ? highway : null);
|
||||
return window.highway || null;
|
||||
}
|
||||
|
||||
function _sectionPracticeDuration() {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
// 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
|
||||
@@ -29,6 +29,8 @@
|
||||
// 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...';
|
||||
@@ -66,14 +68,7 @@ export async function exportSettings() {
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -172,7 +172,7 @@ export function _songEventPayload() {
|
||||
return {
|
||||
time: audioT,
|
||||
audioT,
|
||||
chartT: highway.getTime(),
|
||||
chartT: window.highway.getTime(),
|
||||
perfNow: performance.now(),
|
||||
};
|
||||
}
|
||||
@@ -289,8 +289,8 @@ export async function _audioSeek(s, reason) {
|
||||
// _audioSeek resolves (e.g. the auto-resume song:play in
|
||||
// changeArrangement) sees an in-sync chartT via _songEventPayload.
|
||||
// Without this, chartT lags by one 60Hz tick after a seek.
|
||||
if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
|
||||
highway.setTime(to);
|
||||
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 };
|
||||
|
||||
+19
-19
@@ -62,7 +62,7 @@ function _hasPromotedFlag() {
|
||||
// Pending nag: queued during _populateVizPicker, fired on the first
|
||||
// `song:ready` (so the toast lands when the user actually opens the
|
||||
// player, not at page load when they're still in the library).
|
||||
// `song:ready` is emitted by highway.js via window.feedBack.emit(), so
|
||||
// `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
|
||||
@@ -326,7 +326,7 @@ export async function _populateVizPicker(plugins) {
|
||||
// plugin options — _autoMatchViz saw no candidates and left the
|
||||
// default active. Now that plugins are registered, re-evaluate
|
||||
// against whatever song is currently loaded (a no-op when no song
|
||||
// has been loaded yet, since highway.getSongInfo() returns {}).
|
||||
// has been loaded yet, since window.highway.getSongInfo() returns {}).
|
||||
if (sel.value === 'auto') _autoMatchViz();
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ function _noteVizAutoMatch(id, matched) {
|
||||
}
|
||||
|
||||
function _installVizRenderer(renderer, id, source = 'user-select') {
|
||||
highway.setRenderer(_tagVizRenderer(renderer, id));
|
||||
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.
|
||||
@@ -377,7 +377,7 @@ export function setViz(id) {
|
||||
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (sel) sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
window.highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
@@ -399,7 +399,7 @@ export function setViz(id) {
|
||||
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
|
||||
const _sel = document.getElementById('viz-picker');
|
||||
if (_sel) _sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
window.highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
@@ -483,7 +483,7 @@ export function setViz(id) {
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
// Validate shape — highway.setRenderer will itself fall back to
|
||||
// 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') {
|
||||
@@ -503,7 +503,7 @@ export function setViz(id) {
|
||||
|
||||
// Auto mode: evaluate each registered viz factory's static
|
||||
// `matchesArrangement(songInfo)` predicate and install the first
|
||||
// matching renderer. No match → fall back to the built-in 2D highway.
|
||||
// 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
|
||||
@@ -530,10 +530,10 @@ function _setAutoVizLabel(resolvedText) {
|
||||
let _cancelPendingAutoLabel = null;
|
||||
|
||||
// One-shot (per song) hint shown when a notation-only arrangement falls back
|
||||
// to the built-in 2D highway. Such arrangements carry no wire notes
|
||||
// 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 highway. Core ships no notation view; point at
|
||||
// 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) {
|
||||
@@ -579,8 +579,8 @@ function _dropStaleNotationHint(activeVizId) {
|
||||
const curFilename = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.filename) || '';
|
||||
if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const 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) {
|
||||
@@ -593,8 +593,8 @@ function _dropStaleNotationHint(activeVizId) {
|
||||
|
||||
export function _maybeShowNotationViewHint(activeVizId) {
|
||||
_dropStaleNotationHint(activeVizId);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
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;
|
||||
@@ -641,8 +641,8 @@ export function _autoMatchViz() {
|
||||
// Reset label at evaluation start so a stale resolved label never persists
|
||||
// if the song changes or the picker re-evaluates with a different outcome.
|
||||
_setAutoVizLabel(null);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
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.
|
||||
@@ -714,17 +714,17 @@ export function _autoMatchViz() {
|
||||
_noteVizAutoMatch(id, true);
|
||||
return;
|
||||
}
|
||||
// No match — restore the built-in 2D highway. setRenderer(null) is
|
||||
// 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, highway.setRenderer() handles the
|
||||
// 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.
|
||||
highway.setRenderer(null);
|
||||
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
|
||||
// highway. Read from the DOM rather than hard-coding the name so a
|
||||
// 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');
|
||||
|
||||
@@ -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>';
|
||||
|
||||
|
||||
+43
-1
@@ -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
|
||||
@@ -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,7 +1273,7 @@
|
||||
</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 type="module" src="/static/app.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);
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
/*
|
||||
* fee[dB]ack — Venue crowd video layer (career mode PR1).
|
||||
*
|
||||
* Crossfades pre-rendered crowd-state loop videos behind the highway based on
|
||||
* v3:live-performance-state, plus one-shot reaction stingers. Renders through
|
||||
* two video backdrop planes owned by the highway_3d venue background style
|
||||
* (window.h3dVenueBackdropSetVideo / window.h3dVenueBackdropSetMix).
|
||||
*
|
||||
* Inert unless a venue pack manifest is set — by the career plugin via
|
||||
* v3VenueCrowd.setManifest(), or (dev only) a JSON manifest in localStorage
|
||||
* under feedBack-venue-crowd-dev. With no manifest the static bg plate
|
||||
* behaves exactly as before.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
// live-performance-hud state → crowd state.
|
||||
const CROWD_OF_PERF = {
|
||||
smoke: 'bored',
|
||||
recovery: 'bored',
|
||||
idle: 'neutral',
|
||||
steady: 'neutral',
|
||||
strong: 'engaged',
|
||||
fire: 'ecstatic',
|
||||
};
|
||||
const CROWD_STATES = ['bored', 'neutral', 'engaged', 'ecstatic'];
|
||||
const CROWD_RANK = { bored: 0, neutral: 1, engaged: 2, ecstatic: 3 };
|
||||
|
||||
const STABLE_MS = 3000; // target must hold this long before a switch
|
||||
const DWELL_MS = 8000; // min time between committed switches
|
||||
const FADE_MS = 1200; // loop crossfade
|
||||
const STINGER_FADE_MS = 400; // stinger fade-in/out
|
||||
const STINGER_MIN_GAP_MS = 20000;
|
||||
const STREAK_MILESTONES = [25, 50, 100];
|
||||
const CANPLAY_TIMEOUT_MS = 4000;
|
||||
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
|
||||
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pure, clock-injected decision logic (unit-tested in
|
||||
// tests/js/venue_crowd.test.js — keep DOM-free).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function crowdStateOfPerf(perfState) {
|
||||
return CROWD_OF_PERF[String(perfState || '').toLowerCase()] || 'neutral';
|
||||
}
|
||||
|
||||
// Hysteresis: a new target must be observed continuously for STABLE_MS,
|
||||
// and at least DWELL_MS must have passed since the last committed switch.
|
||||
function createCrowdMachine() {
|
||||
let current = 'neutral';
|
||||
let candidate = null;
|
||||
let candidateSince = 0;
|
||||
let lastSwitchAt = -Infinity;
|
||||
return {
|
||||
get current() { return current; },
|
||||
reset() {
|
||||
current = "neutral";
|
||||
candidate = null;
|
||||
lastSwitchAt = -Infinity;
|
||||
},
|
||||
// Commit a state NOW, bypassing stability/dwell (badge ceremony).
|
||||
// Stamping lastSwitchAt makes the dwell window hold the forced
|
||||
// state before the real perf machine can reassert.
|
||||
force(state, nowMs) {
|
||||
if (!CROWD_STATES.includes(state)) return;
|
||||
current = state;
|
||||
candidate = null;
|
||||
lastSwitchAt = nowMs;
|
||||
},
|
||||
// Feed the latest perf state; returns the new crowd state when a
|
||||
// transition commits, else null.
|
||||
update(perfState, nowMs) {
|
||||
const target = crowdStateOfPerf(perfState);
|
||||
if (target === current) {
|
||||
candidate = null;
|
||||
return null;
|
||||
}
|
||||
if (target !== candidate) {
|
||||
candidate = target;
|
||||
candidateSince = nowMs;
|
||||
return null;
|
||||
}
|
||||
if (nowMs - candidateSince < STABLE_MS) return null;
|
||||
if (nowMs - lastSwitchAt < DWELL_MS) return null;
|
||||
current = target;
|
||||
candidate = null;
|
||||
lastSwitchAt = nowMs;
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cheer when the streak crosses a milestone (rising edge only).
|
||||
function stingerForStreak(prevStreak, streak) {
|
||||
for (const m of STREAK_MILESTONES) {
|
||||
if (prevStreak < m && streak >= m) return 'cheer';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// End-of-song reaction from final accuracy.
|
||||
function stingerForAccuracy(accuracyPct) {
|
||||
const a = Number(accuracyPct);
|
||||
if (!Number.isFinite(a)) return null;
|
||||
if (a >= 90) return 'cheer';
|
||||
if (a >= 75) return 'clap';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Video layer controller (browser only).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const machine = createCrowdMachine();
|
||||
let _manifest = null; // { loops: {state: url}, stingers: {name: url} }
|
||||
let _venueActive = false;
|
||||
let _videos = [null, null];
|
||||
let _activeLayer = 0; // layer currently showing the loop
|
||||
let _mix = 0; // 0 → layer0 visible, 1 → layer1 visible
|
||||
let _fadeRaf = 0;
|
||||
let _stopGen = 0; // bumped by stop(): invalidates ALL in-flight loads
|
||||
let _boundToRenderer = false;
|
||||
let _pendingLoop = null; // loop switch deferred by an active stinger
|
||||
let _loadingLoop = null; // loop currently waiting on canplaythrough
|
||||
let _fadingLoop = null; // loop currently crossfading in (not yet active)
|
||||
let _stingerUntilEnded = false;
|
||||
let _stingerGen = 0; // identity for ended/timeout handlers
|
||||
let _introActive = false;
|
||||
let _introGen = 0;
|
||||
let _audioEl = null; // crowd ambience during the intro flyover
|
||||
let _audioFadeTimer = 0;
|
||||
let _lastStingerAt = -Infinity;
|
||||
let _prevStreak = 0;
|
||||
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
||||
let _bound = false;
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
function h3d(name) {
|
||||
return root && typeof root[name] === 'function' ? root[name] : null;
|
||||
}
|
||||
|
||||
function normalizeManifest(m) {
|
||||
if (!m || typeof m !== 'object' || !m.loops) return null;
|
||||
const base = typeof m.base === 'string' ? m.base : '';
|
||||
const abs = (u) => (typeof u === 'string' && u ? base + u : '');
|
||||
const loops = {};
|
||||
for (const s of CROWD_STATES) loops[s] = abs(m.loops[s]);
|
||||
if (!CROWD_STATES.every((s) => loops[s])) return null;
|
||||
const stingers = {};
|
||||
for (const k of ['clap', 'cheer']) stingers[k] = abs(m.stingers && m.stingers[k]);
|
||||
const intro = {
|
||||
video: abs(m.intro && m.intro.video),
|
||||
audio: abs(m.intro && m.intro.audio),
|
||||
};
|
||||
const sfx = {
|
||||
up: abs(m.sfx && m.sfx.up),
|
||||
down: abs(m.sfx && m.sfx.down),
|
||||
};
|
||||
return { loops, stingers, intro, sfx };
|
||||
}
|
||||
|
||||
function ensureVideos() {
|
||||
if (!_videos[0] && typeof document !== 'undefined') {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const v = document.createElement('video');
|
||||
// Same autoplay-safe recipe as the highway_3d video bg style:
|
||||
// muted + playsInline bypasses gesture requirements; same-origin
|
||||
// URLs so VideoTexture never taints.
|
||||
v.muted = true;
|
||||
v.playsInline = true;
|
||||
v.preload = 'auto';
|
||||
v.loop = true;
|
||||
v.style.display = 'none';
|
||||
document.body.appendChild(v);
|
||||
_videos[i] = v;
|
||||
}
|
||||
}
|
||||
bindVideosToRenderer();
|
||||
}
|
||||
|
||||
// The highway_3d plugin (and its globals) can register after the venue
|
||||
// pack starts — e.g. Venue selected at page load, renderer ready later.
|
||||
// Idempotent and retried from start() and the perf-event path so a late
|
||||
// renderer still picks the videos up.
|
||||
function bindVideosToRenderer() {
|
||||
if (_boundToRenderer || !_videos[0]) return;
|
||||
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
||||
if (!setVideo) return;
|
||||
setVideo(0, _videos[0]);
|
||||
setVideo(1, _videos[1]);
|
||||
_boundToRenderer = true;
|
||||
setMix(_mix); // re-push mix the renderer missed while unregistered
|
||||
}
|
||||
|
||||
function setMix(v) {
|
||||
_mix = Math.max(0, Math.min(1, v));
|
||||
const fn = h3d('h3dVenueBackdropSetMix');
|
||||
if (fn) fn(_mix);
|
||||
}
|
||||
|
||||
function cancelFade() {
|
||||
if (_fadeRaf && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(_fadeRaf);
|
||||
}
|
||||
_fadeRaf = 0;
|
||||
}
|
||||
|
||||
function fadeMixTo(target, durationMs, done) {
|
||||
cancelFade();
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
setMix(target);
|
||||
if (done) done();
|
||||
return;
|
||||
}
|
||||
const from = _mix;
|
||||
const t0 = now();
|
||||
const step = () => {
|
||||
const k = Math.min(1, (now() - t0) / durationMs);
|
||||
setMix(from + (target - from) * k);
|
||||
if (k < 1) {
|
||||
_fadeRaf = requestAnimationFrame(step);
|
||||
} else {
|
||||
_fadeRaf = 0;
|
||||
if (done) done();
|
||||
}
|
||||
};
|
||||
_fadeRaf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// Load url into the video, resolve when it can play through (or after a
|
||||
// timeout — a stalled fetch must not wedge the crowd forever). Tokens are
|
||||
// per-element: a later load on the SAME video (a stinger preempting the
|
||||
// idle layer) cancels this one, but loads on the other layer don't.
|
||||
function loadAndPlay(video, url, loop, cb) {
|
||||
const token = (video._fbCrowdToken = (video._fbCrowdToken || 0) + 1);
|
||||
const gen = _stopGen;
|
||||
let settled = false;
|
||||
const settle = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Cleanup must run even for superseded loads or stale listeners
|
||||
// accumulate on the two persistent elements; only the callback
|
||||
// is gated on still being the current load.
|
||||
video.removeEventListener('canplaythrough', onReady);
|
||||
video.removeEventListener('error', onError);
|
||||
if (token !== video._fbCrowdToken || gen !== _stopGen) return;
|
||||
cb(ok);
|
||||
};
|
||||
const onReady = () => settle(true);
|
||||
const onError = () => settle(false);
|
||||
video.addEventListener('canplaythrough', onReady);
|
||||
video.addEventListener('error', onError);
|
||||
video.loop = loop;
|
||||
video.src = url;
|
||||
video.play().catch(() => { /* browser retries on visibility/gesture */ });
|
||||
setTimeout(() => settle(video.readyState >= 3), CANPLAY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function idleLayer() { return _activeLayer === 0 ? 1 : 0; }
|
||||
|
||||
// Crossfade the loop for `state` in on the idle layer.
|
||||
function showLoop(state, fadeMs) {
|
||||
if (!_manifest || !_videos[0]) return;
|
||||
const layer = idleLayer();
|
||||
const video = _videos[layer];
|
||||
_loadingLoop = state;
|
||||
loadAndPlay(video, _manifest.loops[state], true, (ok) => {
|
||||
if (_loadingLoop === state) _loadingLoop = null;
|
||||
if (!ok || !_venueActive) return;
|
||||
_fadingLoop = state;
|
||||
fadeMixTo(layer === 1 ? 1 : 0, fadeMs, () => {
|
||||
// Preempted mid-fade (stinger claimed this layer while we
|
||||
// were still ramping): the layer no longer holds this loop —
|
||||
// promoting it would pause the real loop and hand fade-back
|
||||
// the wrong target.
|
||||
if (_fadingLoop !== state) return;
|
||||
_fadingLoop = null;
|
||||
const old = _videos[_activeLayer];
|
||||
_activeLayer = layer;
|
||||
if (old && !old.paused) old.pause();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function playStinger(name) {
|
||||
if (!_manifest || !_manifest.stingers[name] || !_videos[0]) return;
|
||||
if (_stingerUntilEnded) return;
|
||||
const t = now();
|
||||
if (t - _lastStingerAt < STINGER_MIN_GAP_MS) return;
|
||||
_lastStingerAt = t;
|
||||
_stingerUntilEnded = true;
|
||||
const layer = idleLayer();
|
||||
const video = _videos[layer];
|
||||
// The stinger reuses the idle layer's element, cancelling any loop
|
||||
// load still in flight there — and idleLayer() is still the fading-in
|
||||
// layer while a crossfade runs (_activeLayer flips on completion), so
|
||||
// a mid-fade loop gets overwritten too. Requeue either for when the
|
||||
// stinger ends (the machine already advanced, nothing re-fires it).
|
||||
const interrupted = _loadingLoop || _fadingLoop;
|
||||
if (interrupted) {
|
||||
// Freeze any in-flight crossfade: its ramp would keep pushing the
|
||||
// mix toward this layer while the stinger replaces the src (loop
|
||||
// vanishing / stinger popping in at full opacity).
|
||||
cancelFade();
|
||||
_pendingLoop = interrupted;
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
}
|
||||
// A loop switch deferred (or preempted) by this stinger must play
|
||||
// once the stinger is done OR failed — the machine already advanced,
|
||||
// so nothing re-triggers it later.
|
||||
const flushPending = () => {
|
||||
if (!_pendingLoop || !_venueActive) return;
|
||||
const pending = _pendingLoop;
|
||||
_pendingLoop = null;
|
||||
showLoop(pending, FADE_MS);
|
||||
};
|
||||
const myGen = ++_stingerGen;
|
||||
const back = () => {
|
||||
// Always detach: a handler left behind by a stop()/manifest swap
|
||||
// must not fire into a LATER stinger's lifecycle on this reused
|
||||
// element (the gen check below guards that; the boolean alone
|
||||
// would pass once a new stinger is active).
|
||||
video.removeEventListener('ended', back);
|
||||
if (_stingerGen !== myGen || !_stingerUntilEnded) return;
|
||||
_stingerUntilEnded = false;
|
||||
// Fade back to the loop layer (which kept playing underneath).
|
||||
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
|
||||
flushPending();
|
||||
};
|
||||
loadAndPlay(video, _manifest.stingers[name], false, (ok) => {
|
||||
if (!ok || !_venueActive) {
|
||||
_stingerUntilEnded = false;
|
||||
flushPending();
|
||||
return;
|
||||
}
|
||||
video.addEventListener('ended', back);
|
||||
fadeMixTo(layer === 1 ? 1 : 0, STINGER_FADE_MS);
|
||||
// Safety: an `ended` that never fires (decode stall) must not
|
||||
// freeze the crowd on a stinger frame.
|
||||
setTimeout(back, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureAudio() {
|
||||
if (_audioEl || typeof document === 'undefined') return;
|
||||
_audioEl = document.createElement('audio');
|
||||
_audioEl.preload = 'auto';
|
||||
_audioEl.style.display = 'none';
|
||||
document.body.appendChild(_audioEl);
|
||||
}
|
||||
|
||||
function fadeAudioOut(durationMs) {
|
||||
if (!_audioEl || _audioEl.paused) return;
|
||||
if (_audioFadeTimer) return; // already fading
|
||||
const from = _audioEl.volume;
|
||||
const t0 = now();
|
||||
_audioFadeTimer = setInterval(() => {
|
||||
const k = Math.min(1, (now() - t0) / durationMs);
|
||||
_audioEl.volume = from * (1 - k);
|
||||
if (k >= 1) {
|
||||
clearInterval(_audioFadeTimer);
|
||||
_audioFadeTimer = 0;
|
||||
_audioEl.pause();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
if (_audioFadeTimer) { clearInterval(_audioFadeTimer); _audioFadeTimer = 0; }
|
||||
if (_audioEl && !_audioEl.paused) _audioEl.pause();
|
||||
}
|
||||
|
||||
// One-shot flyover intro on song load: video flies from the back of the
|
||||
// room onto the stage, crowd ambience plays and ducks out as the song
|
||||
// starts (song:play) or as the flyover lands, whichever comes first.
|
||||
function playIntro() {
|
||||
if (!_manifest || !_manifest.intro || !_manifest.intro.video || !_videos[0]) {
|
||||
return false;
|
||||
}
|
||||
const myGen = ++_introGen;
|
||||
_introActive = true;
|
||||
const layer = idleLayer();
|
||||
const video = _videos[layer];
|
||||
const land = () => {
|
||||
if (_introGen !== myGen || !_introActive) return;
|
||||
_introActive = false;
|
||||
video.removeEventListener('ended', land);
|
||||
fadeAudioOut(1200);
|
||||
const pending = _pendingLoop;
|
||||
_pendingLoop = null;
|
||||
showLoop(pending || machine.current, 400);
|
||||
};
|
||||
loadAndPlay(video, _manifest.intro.video, false, (ok) => {
|
||||
if (_introGen !== myGen) return;
|
||||
if (!ok || !_venueActive) {
|
||||
// Failed intro must not leave the song loop-less: fall back
|
||||
// to the normal loop exactly like the no-intro path.
|
||||
_introActive = false;
|
||||
if (_venueActive) showLoop(machine.current, FADE_MS);
|
||||
return;
|
||||
}
|
||||
fadeMixTo(layer === 1 ? 1 : 0, 300);
|
||||
video.addEventListener('ended', land);
|
||||
setTimeout(land, 15000); // decode-stall safety
|
||||
if (_manifest.intro.audio) {
|
||||
ensureAudio();
|
||||
_audioEl.src = _manifest.intro.audio;
|
||||
_audioEl.volume = 1;
|
||||
// The user's play gesture precedes song:loaded, so autoplay
|
||||
// with sound is normally allowed; degrade silently if not.
|
||||
_audioEl.play().catch(() => { /* no gesture yet */ });
|
||||
// start ducking shortly before the flyover lands
|
||||
video.addEventListener('timeupdate', function duck() {
|
||||
if (video.duration && video.duration - video.currentTime < 1.5) {
|
||||
video.removeEventListener('timeupdate', duck);
|
||||
fadeAudioOut(1400);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let _sfxEl = null;
|
||||
|
||||
function sfxEnabled() {
|
||||
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
// One-shot crowd reaction on committed mood transitions (toggleable):
|
||||
// up the ladder → cheer, down → boos. Committed transitions are already
|
||||
// hysteresis-limited, so this can't spam.
|
||||
function playMoodSfx(direction) {
|
||||
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
|
||||
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
|
||||
if (!url || typeof document === 'undefined') return;
|
||||
if (!_sfxEl) {
|
||||
_sfxEl = document.createElement('audio');
|
||||
_sfxEl.preload = 'auto';
|
||||
_sfxEl.style.display = 'none';
|
||||
document.body.appendChild(_sfxEl);
|
||||
}
|
||||
_sfxEl.src = url;
|
||||
_sfxEl.volume = 0.6;
|
||||
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
|
||||
}
|
||||
|
||||
function onSongPlay() {
|
||||
// Song audio starting is the hard cue: the ambience must yield.
|
||||
fadeAudioOut(1000);
|
||||
}
|
||||
|
||||
function onPerformanceState(e) {
|
||||
if (!_venueActive || !_manifest) return;
|
||||
bindVideosToRenderer();
|
||||
const d = (e && e.detail) || {};
|
||||
// Number(null) === 0: HUD reset events (accuracyPct: null) must not
|
||||
// wipe the value the end-of-song stinger reads via stats:recorded.
|
||||
if (d.accuracyPct != null && Number.isFinite(Number(d.accuracyPct))) {
|
||||
_lastAccuracyPct = Number(d.accuracyPct);
|
||||
}
|
||||
const streak = Number(d.streak) || 0;
|
||||
const sting = stingerForStreak(_prevStreak, streak);
|
||||
_prevStreak = streak;
|
||||
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
||||
playStinger(sting);
|
||||
}
|
||||
const prevRank = CROWD_RANK[machine.current];
|
||||
const next = machine.update(d.state, now());
|
||||
if (next) {
|
||||
playMoodSfx(CROWD_RANK[next] - prevRank);
|
||||
// A stinger or the intro owns the idle layer; defer the switch.
|
||||
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
||||
else showLoop(next, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onSongLoaded() {
|
||||
machine.reset();
|
||||
_prevStreak = 0;
|
||||
_lastAccuracyPct = null;
|
||||
// Abort any stinger/pending state from the previous song: its ended
|
||||
// handler must not fade back into the old song's layers.
|
||||
cancelFade();
|
||||
_stingerGen++;
|
||||
_introGen++;
|
||||
_stingerUntilEnded = false;
|
||||
_introActive = false;
|
||||
stopAudio();
|
||||
_pendingLoop = null;
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
if (_venueActive && _manifest) {
|
||||
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onStatsRecorded() {
|
||||
if (!_venueActive || !_manifest) return;
|
||||
// stats:recorded carries only {filename, arrangement} — the accuracy
|
||||
// comes from the last v3:live-performance-state of the finished song.
|
||||
const sting = stingerForAccuracy(_lastAccuracyPct);
|
||||
_lastAccuracyPct = null; // one reaction per song
|
||||
if (sting) {
|
||||
_lastStingerAt = -Infinity; // end-of-song reaction always allowed
|
||||
playStinger(sting);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
ensureVideos();
|
||||
if (!_videos[0]) return;
|
||||
_prevStreak = 0;
|
||||
// Boot straight into the current machine state on the active layer.
|
||||
const video = _videos[_activeLayer];
|
||||
loadAndPlay(video, _manifest.loops[machine.current], true, (ok) => {
|
||||
if (!ok || !_venueActive) return;
|
||||
setMix(_activeLayer === 1 ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
cancelFade();
|
||||
_stopGen++;
|
||||
_stingerGen++;
|
||||
_introGen++;
|
||||
_introActive = false;
|
||||
stopAudio();
|
||||
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
|
||||
_stingerUntilEnded = false;
|
||||
_pendingLoop = null;
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
for (const v of _videos) {
|
||||
if (v && !v.paused) v.pause();
|
||||
}
|
||||
// Unbind from the renderer: a paused video still holds its last
|
||||
// frame, and the venue style keeps a bound plane visible whenever
|
||||
// videoWidth > 0 — without this a removed pack would leave a frozen
|
||||
// crowd frame over the static plate. start() re-binds.
|
||||
const setVideo = h3d('h3dVenueBackdropSetVideo');
|
||||
if (_boundToRenderer && setVideo) {
|
||||
setVideo(0, null);
|
||||
setVideo(1, null);
|
||||
}
|
||||
_boundToRenderer = false;
|
||||
// Mix and active layer must reset together: mix 0 shows layer 0, so a
|
||||
// restart that left _activeLayer at 1 would flash layer 0's stale
|
||||
// frame until the new loop loads.
|
||||
_activeLayer = 0;
|
||||
setMix(0);
|
||||
}
|
||||
|
||||
function setVenueActive(on) {
|
||||
const next = !!on;
|
||||
if (next === _venueActive) {
|
||||
// Re-activation (e.g. viz:renderer:ready after a late plugin
|
||||
// load): don't restart the loop, but do retry renderer binding.
|
||||
if (next && _manifest) bindVideosToRenderer();
|
||||
return;
|
||||
}
|
||||
_venueActive = next;
|
||||
if (_venueActive && _manifest) start();
|
||||
else stop();
|
||||
}
|
||||
|
||||
function setManifest(m) {
|
||||
const norm = normalizeManifest(m);
|
||||
_manifest = norm;
|
||||
if (_venueActive) {
|
||||
// Full stop first even when replacing pack-for-pack: it bumps
|
||||
// _stopGen so an in-flight load from the OLD manifest can't
|
||||
// settle and fade a stale URL in after the new pack starts.
|
||||
stop();
|
||||
if (norm) start();
|
||||
}
|
||||
}
|
||||
|
||||
function readDevManifest() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DEV_FLAG_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bindRuntime() {
|
||||
if (_bound) return;
|
||||
_bound = true;
|
||||
const sm = root && root.feedBack;
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
sm.on('v3:live-performance-state', onPerformanceState);
|
||||
sm.on('stats:recorded', onStatsRecorded);
|
||||
// A new song must not inherit the previous song's crowd mood
|
||||
// through the hysteresis/dwell window.
|
||||
sm.on('song:loaded', onSongLoaded);
|
||||
sm.on('song:play', onSongPlay);
|
||||
}
|
||||
const dev = readDevManifest();
|
||||
if (dev && !_manifest) setManifest(dev);
|
||||
}
|
||||
|
||||
// Badge-ceremony hook (career passports): the crowd erupts NOW — ecstatic
|
||||
// loop bypassing stability/dwell (the dwell window then holds it while
|
||||
// the real perf state waits its turn) plus a cheer. Degrades to a no-op
|
||||
// without a pack / outside the player, like every other entry point.
|
||||
function celebrate() {
|
||||
if (!_venueActive || !_manifest || !_videos[0]) return false;
|
||||
machine.force('ecstatic', now());
|
||||
if (_stingerUntilEnded || _introActive) {
|
||||
// A stinger/intro owns the idle layer (likely the end-of-song
|
||||
// accuracy cheer — the crowd is already reacting); queue the
|
||||
// ecstatic loop for when it ends, same as onPerformanceState.
|
||||
_pendingLoop = 'ecstatic';
|
||||
} else {
|
||||
showLoop('ecstatic', FADE_MS);
|
||||
_lastStingerAt = -Infinity; // a badge earn always gets its cheer
|
||||
playStinger('cheer');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
venueActive: _venueActive,
|
||||
hasManifest: !!_manifest,
|
||||
crowdState: machine.current,
|
||||
activeLayer: _activeLayer,
|
||||
mix: _mix,
|
||||
stingerActive: _stingerUntilEnded,
|
||||
introActive: _introActive,
|
||||
};
|
||||
}
|
||||
|
||||
const api = {
|
||||
CROWD_STATES,
|
||||
STABLE_MS,
|
||||
DWELL_MS,
|
||||
crowdStateOfPerf,
|
||||
createCrowdMachine,
|
||||
stingerForStreak,
|
||||
stingerForAccuracy,
|
||||
normalizeManifest,
|
||||
setManifest,
|
||||
setVenueActive,
|
||||
bindRuntime,
|
||||
getState,
|
||||
celebrate,
|
||||
};
|
||||
|
||||
if (root) root.v3VenueCrowd = api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
// Same defer/DOMContentLoaded dance as venue-scene-3d.js.
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', bindRuntime);
|
||||
} else {
|
||||
bindRuntime();
|
||||
}
|
||||
}
|
||||
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
|
||||
@@ -73,7 +73,7 @@
|
||||
function readArrangementSignal() {
|
||||
// Intentional karaoke/vocals signal: active arrangement name from the
|
||||
// highway WS (user selected Vocals in #arr-select). Do NOT use
|
||||
// highway.getLyricsVisible() — lyrics overlay stays on during normal
|
||||
// window.highway.getLyricsVisible() — lyrics overlay stays on during normal
|
||||
// guitar practice and must not force vocals POV.
|
||||
try {
|
||||
const si = root.highway && typeof root.highway.getSongInfo === 'function'
|
||||
@@ -96,6 +96,7 @@
|
||||
if (_active) {
|
||||
syncInstrumentPov();
|
||||
syncVenueMotion();
|
||||
syncCrowd(true);
|
||||
return;
|
||||
}
|
||||
_active = true;
|
||||
@@ -105,6 +106,17 @@
|
||||
setH3dMood(_lastMood);
|
||||
syncInstrumentPov();
|
||||
syncVenueMotion();
|
||||
syncCrowd(true);
|
||||
}
|
||||
|
||||
function syncCrowd(on) {
|
||||
// Reactive crowd video layer (career mode) — inert without a pack.
|
||||
try {
|
||||
if (root && root.v3VenueCrowd &&
|
||||
typeof root.v3VenueCrowd.setVenueActive === 'function') {
|
||||
root.v3VenueCrowd.setVenueActive(!!on);
|
||||
}
|
||||
} catch (_) { /* visual-only */ }
|
||||
}
|
||||
|
||||
function syncVenueMotion() {
|
||||
@@ -128,6 +140,7 @@
|
||||
_assetsLoaded = false;
|
||||
_loadFailed = false;
|
||||
setH3dActive(false);
|
||||
syncCrowd(false);
|
||||
syncPlaceholderVisibility();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The R3c perf gate: highway.js's render loop must not get more expensive.
|
||||
*
|
||||
* ── WHY FRAME RATE IS THE WRONG THING TO MEASURE ──────────────────────────────
|
||||
*
|
||||
* The highway AUTO-SCALES. When the smoothed draw cost climbs past its budget
|
||||
* (_DRAW_BUDGET_HI_MS = 12ms) it LOWERS THE RENDER RESOLUTION to protect the frame rate
|
||||
* (#654). That is exactly right for players. It also means a real performance regression
|
||||
* does not show up as dropped frames — it shows up as a BLURRIER PICTURE at a perfectly
|
||||
* healthy 60fps.
|
||||
*
|
||||
* Benchmark fps and you measure the feedback loop, not the renderer, and cheerfully
|
||||
* conclude that nothing changed while the picture quietly got worse.
|
||||
*
|
||||
* So this pins the scale — setRenderScale(1) + setMinRenderScale(1), which clamps
|
||||
* autoScale to [1, 1] — and measures `drawMs`, the renderer's own cost, straight from
|
||||
* highway.getPerf(). With the adaptive loop held still, drawMs is the signal.
|
||||
*
|
||||
* ── WHAT IT ASSERTS, AND THE TRAP I FELL INTO FIRST ──────────────────────────
|
||||
*
|
||||
* My first cut asserted "the auto-scaler was not forced to intervene" — i.e. effectiveScale
|
||||
* still == 1. That gate is VACUOUS, and the bite test proved it: I injected a 10x
|
||||
* regression (drawMs 2.4 -> 22.4ms, nearly double the 12ms budget) and the test PASSED.
|
||||
*
|
||||
* Of course it did. setMinRenderScale(1) sets the auto-scaler's FLOOR to 1, so
|
||||
* effectiveScale CANNOT drop below 1 — the very pinning that stops the scaler from hiding a
|
||||
* regression also stops it from ever reporting one. A guard that cannot fail.
|
||||
*
|
||||
* So with the scale pinned, drawMs IS the signal, and the threshold is the app's own:
|
||||
* _DRAW_BUDGET_HI_MS (12ms) is the cost at which the highway itself decides it is too
|
||||
* expensive and starts dropping resolution in production. Exceeding it is not an arbitrary
|
||||
* line in a benchmark — it is the renderer failing its own budget.
|
||||
*
|
||||
* That is a real gate and not a flaky one: the current cost is ~2.4ms, so there is ~5x
|
||||
* headroom before it trips, which is far more than headless-CI variance and far less than
|
||||
* any regression worth shipping.
|
||||
*/
|
||||
test('highway draw cost stays within its own render budget', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const perf = await page.evaluate(async () => {
|
||||
const w = window as any;
|
||||
const hw = w.highway;
|
||||
if (!hw || typeof hw.getPerf !== 'function') {
|
||||
return { error: 'highway.getPerf() missing — the perf gate is blind' };
|
||||
}
|
||||
|
||||
// Pin the adaptive loop so it cannot mask a regression by dropping resolution.
|
||||
hw.setRenderScale(1);
|
||||
hw.setMinRenderScale(1);
|
||||
|
||||
// Load a real chart and get the transport ACTUALLY RUNNING.
|
||||
//
|
||||
// Codex [P2] on the first cut of this, and it was right: headless Chromium may block
|
||||
// autoplay, in which case playSong() only LOADS the chart. The audio clock never
|
||||
// advances, the draw loop treats the session as paused, and _drawMsEMA keeps whatever
|
||||
// stale value it had at startup. The gate would then sample an IDLE renderer and
|
||||
// cheerfully report a healthy 2ms — while measuring nothing at all, on exactly the path
|
||||
// it exists to protect.
|
||||
const d = await (await fetch('/api/library?limit=1')).json();
|
||||
const f = d.songs && d.songs[0] && (d.songs[0].filename || d.songs[0].id);
|
||||
if (!f) return { error: 'no song in the library — the perf gate has nothing to render' };
|
||||
|
||||
const audio = document.getElementById('audio') as HTMLAudioElement | null;
|
||||
if (audio) audio.muted = true; // so autoplay policy cannot refuse us
|
||||
// ENCODE. playSong() decodes its argument before interpolating it into the /ws/highway
|
||||
// path, so every real caller hands it encodeURIComponent(filename) (app.js:2879, 4137).
|
||||
// A raw filename containing #, ?, % or / builds an invalid WebSocket URL and the song
|
||||
// never loads — on which libraries this gate would silently measure an idle renderer
|
||||
// rather than fail. Codex [P2], and correct.
|
||||
await w.playSong(encodeURIComponent(f));
|
||||
|
||||
// WAIT for playback to start; do NOT force it on a fixed timer.
|
||||
//
|
||||
// playSong() autoplays, but it takes ~3-4s to get there — it is fetching and decoding
|
||||
// stems. An earlier version of this test called togglePlay() after a flat 2s "if not
|
||||
// playing yet", which fired BEFORE autoplay, started playback, and then had the app's
|
||||
// own autoplay toggle it straight back to PAUSED. The renderer then idled through the
|
||||
// whole measurement and the gate happily reported 2ms of nothing.
|
||||
const playDeadline = Date.now() + 12000;
|
||||
while (!w.feedBack.isPlaying && Date.now() < playDeadline) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
// Only intervene if it truly never started (a stricter autoplay policy than we expect).
|
||||
if (!w.feedBack.isPlaying) await w.togglePlay();
|
||||
|
||||
// Wait for the CHART CLOCK to actually move. That is the proof the render loop is doing
|
||||
// real per-frame work, not sitting paused.
|
||||
const t0 = hw.getTime();
|
||||
const deadline = Date.now() + 8000;
|
||||
while (hw.getTime() - t0 < 0.5 && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
const advanced = hw.getTime() - t0;
|
||||
|
||||
// Let the EMAs settle under load (they are 0.9/0.1, so they need a few dozen frames).
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// Sample — and measure the clock ACROSS the sampling window, not just before it.
|
||||
// "It advanced at some point earlier" is not good enough: if playback stopped before we
|
||||
// started sampling (short song, ended track, autoplay revoked), the EMAs decay toward
|
||||
// idle and we would be reading the cost of drawing nothing.
|
||||
const sampleStart = hw.getTime();
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
samples.push(hw.getPerf().drawMs);
|
||||
}
|
||||
const advancedDuringSampling = hw.getTime() - sampleStart;
|
||||
|
||||
return {
|
||||
...hw.getPerf(),
|
||||
samples,
|
||||
advanced,
|
||||
advancedDuringSampling,
|
||||
isPlaying: !!w.feedBack.isPlaying,
|
||||
};
|
||||
});
|
||||
|
||||
expect(perf.error, String(perf.error)).toBeUndefined();
|
||||
|
||||
console.log(
|
||||
`[highway perf] drawMs=${(perf.drawMs ?? 0).toFixed(2)} frameMs=${(perf.frameMs ?? 0).toFixed(2)} ` +
|
||||
`renderScale=${perf.renderScale} autoScale=${perf.autoScale} effectiveScale=${perf.effectiveScale} ` +
|
||||
`budget=${perf.drawBudgetLoMs}..${perf.drawBudgetHiMs}ms ` +
|
||||
`playing=${perf.isPlaying} advancedBefore=${(perf.advanced ?? 0).toFixed(2)}s ` +
|
||||
`advancedDuringSampling=${(perf.advancedDuringSampling ?? 0).toFixed(3)}s`,
|
||||
);
|
||||
|
||||
// 0. THE GATE MUST NOT BE MEASURING AN IDLE RENDERER. If the transport never started, the
|
||||
// draw loop is paused, _drawMsEMA is a stale startup value, and every assertion below
|
||||
// passes while testing nothing. Assert the chart clock actually MOVED.
|
||||
expect(
|
||||
perf.advanced,
|
||||
'the chart clock never advanced — playback did not start, so drawMs is a stale idle ' +
|
||||
'value and this gate is measuring nothing',
|
||||
).toBeGreaterThan(0.5);
|
||||
|
||||
// …and it must STILL have been advancing while we sampled. "It moved at some point
|
||||
// earlier" is not good enough: if playback stopped before the sampling window, the EMAs
|
||||
// decay toward idle and we would be measuring the cost of drawing nothing.
|
||||
expect(
|
||||
perf.advancedDuringSampling,
|
||||
'the chart clock was not advancing DURING the sampling window — playback stopped, so ' +
|
||||
'these drawMs samples are the cost of an idle renderer, not a rendering one',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// 1. the renderer actually ran and reports a sane cost
|
||||
expect(Number.isFinite(perf.drawMs)).toBe(true);
|
||||
expect(perf.drawMs).toBeGreaterThan(0);
|
||||
|
||||
// 2. THE REGRESSION SIGNAL. With the scale pinned, drawMs is the renderer's true cost.
|
||||
// _DRAW_BUDGET_HI_MS is the app's OWN definition of "too expensive" — the cost at
|
||||
// which it starts sacrificing resolution for players in production. Blow through it
|
||||
// and the renderer has failed its own budget.
|
||||
//
|
||||
// Do NOT be tempted to assert on effectiveScale instead: pinning the scale makes that
|
||||
// number a constant, so it can never report anything. See the note above.
|
||||
expect(
|
||||
perf.drawMs,
|
||||
`highway draw cost ${perf.drawMs.toFixed(2)}ms exceeds its own budget of ` +
|
||||
`${perf.drawBudgetHiMs}ms — in production this is the point where the highway starts ` +
|
||||
`dropping render resolution to keep up`,
|
||||
).toBeLessThan(perf.drawBudgetHiMs);
|
||||
});
|
||||
@@ -18,7 +18,14 @@ const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// auto-exit machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin)
|
||||
// stayed in app.js.
|
||||
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
// R3d: the song session (showScreen / playSong / closeCurrentSong, and the autoplay hold and
|
||||
// auto-exit timer they own) was carved out of app.js into static/js/session.js. This file slices
|
||||
// functions from BOTH — `_resultsOverlayVisible` is still in app.js; `_releaseAutoplay` and
|
||||
// `_resolvePlayerOrigin` moved. Read both and strip `export`, exactly as CONTROLS_SRC already
|
||||
// does, rather than re-pinning each extraction at whichever file currently holds it.
|
||||
const SESSION_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(SESSION_JS, 'utf8').replace(/^export /gm, '');
|
||||
// the module is ESM; these sandboxes evaluate plain script text
|
||||
const CONTROLS_SRC = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career');
|
||||
const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js');
|
||||
|
||||
test('career plugin manifest is complete and bundled', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
||||
assert.equal(manifest.id, 'career');
|
||||
assert.equal(manifest.bundled, true);
|
||||
assert.equal(manifest.screen, 'screen.html');
|
||||
assert.equal(manifest.script, 'screen.js');
|
||||
assert.equal(manifest.routes, 'routes.py');
|
||||
for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) {
|
||||
assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test('venues.json defines the 3 ascending tiers with star thresholds', () => {
|
||||
const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8'));
|
||||
assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]);
|
||||
const venues = content.venues;
|
||||
assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']);
|
||||
assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked');
|
||||
for (let i = 1; i < venues.length; i++) {
|
||||
assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold,
|
||||
'thresholds must ascend');
|
||||
}
|
||||
});
|
||||
|
||||
test('bar venue pack ships with intro media in the plugin checkout', () => {
|
||||
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'bar');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
|
||||
assert.deepEqual(Object.keys(manifest.loops).sort(),
|
||||
['bored', 'ecstatic', 'engaged', 'neutral']);
|
||||
assert.equal(manifest.intro.video, 'intro.mp4');
|
||||
assert.equal(manifest.intro.audio, 'bar-ambience.mp3');
|
||||
for (const f of [
|
||||
...Object.values(manifest.loops),
|
||||
...Object.values(manifest.stingers),
|
||||
manifest.intro.video,
|
||||
manifest.intro.audio,
|
||||
]) {
|
||||
const stat = fs.statSync(path.join(packDir, f));
|
||||
assert.ok(stat.size > 0, `${f} must be present`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shell promotes the career plugin into the sidebar', () => {
|
||||
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
||||
assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/);
|
||||
});
|
||||
|
||||
test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8');
|
||||
assert.match(src, /v3VenueCrowd/);
|
||||
assert.match(src, /setManifest\(manifest\)/);
|
||||
assert.match(src, /manifest\.base = /);
|
||||
assert.match(src, /feedBack-career-venue/);
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Regression guards for two Edit-Metadata modal fixes (static/app.js):
|
||||
// Regression guards for two Edit-Metadata modal fixes (static/js/edit-modal.js):
|
||||
//
|
||||
// 1. Year is editable — the modal renders an `edit-year` field and
|
||||
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
|
||||
@@ -19,8 +19,11 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const readApp = () => fs.readFileSync(APP_JS, 'utf8');
|
||||
// R3d: the edit modal was carved out of app.js into its own module. Bodies unchanged — only
|
||||
// the file moved. (It could go cleanly because the LIBRARY came out first: every dependency the
|
||||
// modal has is a module now, and it reads six library bindings without writing any.)
|
||||
const EDIT_MODAL_JS = path.join(__dirname, '..', '..', 'static', 'js', 'edit-modal.js');
|
||||
const readApp = () => fs.readFileSync(EDIT_MODAL_JS, 'utf8');
|
||||
|
||||
function loadFn(signature, sandbox, exportAs) {
|
||||
const fnSrc = extractFunction(readApp(), signature);
|
||||
|
||||
@@ -27,16 +27,33 @@ function extractBlock(src, signature) {
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
|
||||
// means they break again on the next carve — and worse, a source-shape assertion that silently
|
||||
// stops finding its target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) {
|
||||
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('highway declares adaptive-scale state with a floor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
assert.match(src, /(?:export\s+)?const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
});
|
||||
|
||||
test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
// Derives from the (sanitized) user ceiling and auto factor.
|
||||
assert.match(fn, /_renderScale/, 'effective scale must derive from the user _renderScale');
|
||||
@@ -47,7 +64,7 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
|
||||
});
|
||||
|
||||
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Hard floor constant kept; configurable floor read from localStorage.
|
||||
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
|
||||
@@ -65,7 +82,7 @@ test('min render scale floor is user-configurable + exposed on the api (#654)',
|
||||
});
|
||||
|
||||
test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resize', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(fn, /_DRAW_BUDGET_HI_MS/, 'must scale down past the high budget');
|
||||
assert.match(fn, /_DRAW_BUDGET_LO_MS/, 'must scale up below the low budget');
|
||||
@@ -74,27 +91,27 @@ test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resiz
|
||||
});
|
||||
|
||||
test('draw() only adapts during active playback and feeds the HUD', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
assert.match(fn, /if\s*\(\s*!_paused\s*\)\s*_adaptRenderScale/, 'must skip adaptation while paused');
|
||||
assert.match(fn, /_updatePerfHud\(\)/, 'must update the perf HUD each drawn frame');
|
||||
});
|
||||
|
||||
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
|
||||
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
|
||||
});
|
||||
|
||||
test('api exposes effective scale + perf stats', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /getEffectiveRenderScale\(\)\s*\{\s*return\s+_effectiveRenderScale\(\)/, 'api.getEffectiveRenderScale missing');
|
||||
assert.match(src, /getPerfStats\(\)\s*\{/, 'api.getPerfStats missing');
|
||||
});
|
||||
|
||||
// Robustness fixes from the #655 Copilot review.
|
||||
test('render scale is sanitized on load and effective scale guards non-finite', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /parseFloat\(localStorage\.getItem\('renderScale'\)[\s\S]{0,160}?Number\.isFinite/,
|
||||
'render scale load must validate via Number.isFinite + clamp');
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
@@ -102,7 +119,7 @@ test('render scale is sanitized on load and effective scale guards non-finite',
|
||||
});
|
||||
|
||||
test('stop() tears down the perf HUD and resets per-session accumulators', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /stop\(\)\s*\{[\s\S]{0,400}?_perfHud\.remove\(\)/,
|
||||
'stop() must remove the perf HUD so it cannot strand in the DOM');
|
||||
assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_autoScale\s*=\s*1/,
|
||||
@@ -112,7 +129,7 @@ test('stop() tears down the perf HUD and resets per-session accumulators', () =>
|
||||
});
|
||||
|
||||
test('perf HUD throttles its localStorage flag read off the hot path', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _updatePerfHud()');
|
||||
assert.match(fn, /_hudFlagAt/, 'HUD must cache the flag and re-read on an interval, not every frame');
|
||||
});
|
||||
|
||||
@@ -25,7 +25,9 @@ function loadFn(file, name) {
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
|
||||
// R3c: the PURE geometry/label primitives were carved out of highway.js into
|
||||
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
|
||||
const bnvNormalizedPoints = loadFn('static/js/highway-geometry.js', 'bnvNormalizedPoints');
|
||||
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
|
||||
|
||||
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -25,7 +25,9 @@ function loadFn(file, name) {
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const labels2D = loadFn('static/highway.js', 'chordHarmonyLabels');
|
||||
// R3c: the PURE geometry/label primitives were carved out of highway.js into
|
||||
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
|
||||
const labels2D = loadFn('static/js/highway-geometry.js', 'chordHarmonyLabels');
|
||||
const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels');
|
||||
|
||||
for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) {
|
||||
|
||||
@@ -19,8 +19,23 @@ const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set rather than re-pinning at whichever file currently holds a function — re-pinning
|
||||
// just breaks again next time, and a source-shape assertion that silently stops finding its
|
||||
// target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// The cache key triple must include chordTemplates — without it, a
|
||||
// late-arriving `chord_templates` WS message leaves cached
|
||||
// nonZeroNotes / nonZeroFrets stale until the next chord transition.
|
||||
@@ -41,7 +56,7 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
});
|
||||
|
||||
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// The cache-invalidation block must clear both _chordFretLineNotes
|
||||
// (so _updateFretLinePreview re-publishes with corrected isOpen
|
||||
// classification) and _frameMismatchWarned (so a chord ID warned
|
||||
|
||||
@@ -60,7 +60,7 @@ function buildClockSandbox(perfNowImpl) {
|
||||
performance: { now: perfNowImpl },
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
const setTimeBody = extractBlock(src, 'setTime(t) {');
|
||||
const getTimeBody = extractBlock(src, 'getTime() {');
|
||||
// Strip trailing comma if present (object-literal method declarations).
|
||||
@@ -72,8 +72,25 @@ function buildClockSandbox(perfNowImpl) {
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
|
||||
// means they break again on the next carve — and worse, a source-shape assertion that silently
|
||||
// stops finding its target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) {
|
||||
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Both anchor fields use NaN sentinels — _chartAnchorAudioT in
|
||||
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
|
||||
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
|
||||
@@ -82,11 +99,11 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
assert.match(src, /(?:export\s+)?const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
});
|
||||
|
||||
test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
|
||||
assert.ok(m, 'getTime() body not found');
|
||||
const slice = m[0];
|
||||
@@ -98,7 +115,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
|
||||
});
|
||||
|
||||
test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually changes', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Repeated setTime calls with the same value must not refresh the
|
||||
// anchor (else interpolation stutters); they also must not refresh
|
||||
// _chartLastAdvanceAt (else getTime would never detect a stalled
|
||||
@@ -115,7 +132,7 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
|
||||
});
|
||||
|
||||
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Find the actual getTime body. Match the whole brace-balanced
|
||||
// method (using a generous greedy slice to ensure we capture both
|
||||
// the stall check and the interpolation expression below it).
|
||||
@@ -139,7 +156,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
});
|
||||
|
||||
test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Use the brace-balanced extractor so the assertions are scoped to
|
||||
// the actual stop() body — a fixed-size slice would falsely match
|
||||
// resets that landed in an adjacent method.
|
||||
|
||||
@@ -12,6 +12,10 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
// R3c: _noteState moved to static/js/highway-state-primitives.js and gained an explicit
|
||||
// hwState first parameter — it has to, because createHighway() is a factory and a module
|
||||
// cannot import per-instance state without two panels sharing it.
|
||||
const primitivesJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-state-primitives.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_visibility.test.js).
|
||||
@@ -32,13 +36,28 @@ function extractBlock(src, signature) {
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set rather than re-pinning at whichever file currently holds a function — re-pinning
|
||||
// just breaks again next time, and a source-shape assertion that silently stops finding its
|
||||
// target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('highway declares the note-state provider slot', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
});
|
||||
|
||||
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
@@ -47,15 +66,23 @@ test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteSt
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// The bundle field must point straight at _noteState — not a fresh
|
||||
// arrow each frame (the per-frame allocation the review flagged).
|
||||
assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
||||
// R3c: _noteState now takes hwState first, so the bundle hands out a per-INSTANCE bound
|
||||
// view created ONCE in the factory (boundNoteState) rather than the raw function. The
|
||||
// contract that matters is unchanged and still asserted: ONE stable reference, never a
|
||||
// fresh arrow per frame (feedBack#254). Assert it is a bare identifier, not an inline
|
||||
// function expression.
|
||||
assert.match(fn, /getNoteState\s*[:=]\s*(?:boundNoteState|_noteState)\b/,
|
||||
'bundle.getNoteState must be a stable reference (a name), not a per-frame arrow');
|
||||
assert.doesNotMatch(fn, /getNoteState\s*[:=]\s*(?:\(|function)/,
|
||||
'bundle.getNoteState must NOT be a fresh function per frame');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// Same allocation discipline as getNoteState: highway_3d uses this
|
||||
// bundle field to tell "provider attached" from "no provider but
|
||||
@@ -78,8 +105,8 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
});
|
||||
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(fs.readFileSync(primitivesJs, 'utf8'), 'function _noteState(hwState, note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
@@ -90,12 +117,24 @@ test('_noteState normalizes provider output as documented', () => {
|
||||
});
|
||||
|
||||
test('default 2D renderer threads note state into drawNote / drawSustains / chord path', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// drawNote takes the trailing `ns` param.
|
||||
assert.match(src, /function\s+drawNote\(\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, 'drawNote must accept the trailing ns param');
|
||||
// R3c: drawNote moved to ./static/js/highway-draw.js and gained hwState as its FIRST arg
|
||||
// (createHighway is a factory — a module cannot import per-instance state without two
|
||||
// panels sharing it). The contract asserted here is unchanged: `ns` is still the TRAILING
|
||||
// parameter, which is what the note-state threading depends on.
|
||||
assert.match(src, /function\s+drawNote\(\s*hwState\s*,\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/,
|
||||
'drawNote must take hwState first and keep ns as the trailing param');
|
||||
// drawNotes / drawSustains / drawChords gate the lookup on the provider.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*n\s*,\s*n\.t\s*\)\s*:\s*null/, 'visible-note paths must skip the lookup when no provider is set');
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/, 'chord-note path must key the lookup by the chord time and gate on the provider');
|
||||
// R3c: _noteState gained an explicit hwState first arg (it lives in a module now, and
|
||||
// createHighway is a factory). The CONTRACT here is unchanged and still the point: skip
|
||||
// the lookup entirely when no provider is set — a per-visible-note call on every frame.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*n\s*,\s*n\.t\s*\)\s*:\s*null/,
|
||||
'visible-note paths must skip the lookup when no provider is set');
|
||||
// Same, for the chord path: keyed by the CHORD's time (ch.t), not the note's, and still
|
||||
// gated on the provider. Only the hwState arg is new.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/,
|
||||
'chord-note path must key the lookup by the chord time and gate on the provider');
|
||||
});
|
||||
|
||||
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user