mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 17:04:29 +00:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c606e0b4c | ||
|
|
2991612531 | ||
|
|
af611770aa | ||
|
|
ea9da0acde | ||
|
|
be473dc7af | ||
|
|
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 | ||
|
|
34215fbd32 | ||
|
|
f5d448af5c | ||
|
|
8f014e6a30 | ||
|
|
6b8f79dd9a | ||
|
|
70dbe45e27 | ||
|
|
1cef01d02c | ||
|
|
756588678b | ||
|
|
bd830328f0 | ||
|
|
09f7e450a5 | ||
|
|
8bec8d2466 | ||
|
|
8d0e270345 | ||
|
|
dc429ecd16 | ||
|
|
11f8c36b61 |
@@ -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.
|
||||
@@ -117,6 +117,16 @@ invalidate_song_caches = None
|
||||
stat_for_cache = None
|
||||
scan_status = None
|
||||
|
||||
# The directory containing server.py: the repo root in dev, resources/feedBack when
|
||||
# bundled — the tree that actually holds docs/ and data/.
|
||||
#
|
||||
# It is published HERE, by server.py, precisely so no module under lib/ ever computes it.
|
||||
# `Path(__file__).resolve().parent` is correct in server.py and silently WRONG anywhere in
|
||||
# lib/ (it yields lib/, which has no docs/ or data/), and it fails by finding nothing
|
||||
# rather than by raising — the builtin-content seeds would just quietly never run. See
|
||||
# lib/builtin_content.py's header. Read it; never re-derive it.
|
||||
server_root = None
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings", "tuning_providers",
|
||||
"library_providers", "local_library_provider",
|
||||
@@ -127,6 +137,7 @@ _SLOTS = frozenset({
|
||||
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
|
||||
"default_settings",
|
||||
"kick_scan", "invalidate_song_caches", "stat_for_cache", "scan_status",
|
||||
"server_root",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Builtin content seeding: the calibration/diagnostic sloppaks and the starter library.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b) — with ONE deliberate signature change, and it is
|
||||
the whole reason this module is safe.
|
||||
|
||||
━━━ WHY THE ROOT IS A PARAMETER ━━━
|
||||
|
||||
server.py had `_feedBack_server_root()` = `Path(__file__).resolve().parent`. That is
|
||||
correct *in server.py*: the repo root in dev, resources/feedBack when bundled — the tree
|
||||
that actually holds docs/ and data/.
|
||||
|
||||
Move that body here unchanged and it keeps working, silently, and returns `lib/`. There is
|
||||
no docs/diagnostics under lib/, so every seed would quietly find nothing and log "source
|
||||
missing" — a verbatim move whose meaning changed because `__file__` did. Nothing would
|
||||
fail; the starter library would just never appear.
|
||||
|
||||
So this module CANNOT compute a root: it takes `server_root` as a parameter, and server.py
|
||||
— the only place that legitimately knows where it lives — passes it in. The trap is now
|
||||
structurally impossible rather than merely avoided. (_copy_builtin_packs already took the
|
||||
root this way; the two seed helpers now do too.)
|
||||
|
||||
Everything else is byte-identical. `log` is this module's own logger under the same
|
||||
`feedBack.` hierarchy, and CONFIG_DIR is read late as `appstate.config_dir` — see appstate.py
|
||||
for why those reads must be late-bound (tests monkeypatch it).
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir
|
||||
|
||||
log = logging.getLogger("feedBack.builtin_content")
|
||||
|
||||
|
||||
BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
|
||||
|
||||
|
||||
BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"feedBack-diagnostic-basic-guitar.sloppak",
|
||||
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def builtin_diagnostic_filename() -> str:
|
||||
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
|
||||
the onboarding challenge target (spec 010)."""
|
||||
return f"{BUILTIN_DIAGNOSTIC_SUBDIR}/{BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
|
||||
|
||||
|
||||
def _copy_builtin_packs(
|
||||
root: Path,
|
||||
dest_dir: Path,
|
||||
sources: list[tuple[str, str]],
|
||||
label: str,
|
||||
update_existing: bool = True,
|
||||
) -> int:
|
||||
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
|
||||
|
||||
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
|
||||
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
|
||||
bundled). A pack is copied when its destination is missing. Never deletes
|
||||
user files; refuses to follow a symlinked seed directory or destination and
|
||||
refuses to clobber a non-regular destination (any would let a copy escape
|
||||
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
|
||||
prefixes every log line.
|
||||
|
||||
``update_existing`` controls what happens when a *regular* destination file
|
||||
already exists: when True (diagnostic seed) a bundle copy newer than the
|
||||
destination refreshes it; when False (one-time starter content) an existing
|
||||
file is always left as-is so the user's copy is never overwritten.
|
||||
|
||||
Returns the number of ``sources`` that are present at their destination
|
||||
afterwards (freshly seeded, refreshed, or already current) — so callers can
|
||||
tell whether every pack made it. A skip (missing source, symlink/non-regular
|
||||
refusal, copy error) does not count.
|
||||
"""
|
||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
|
||||
# and copies would land at the link target, outside the DLC tree. The
|
||||
# per-file symlink guard below cannot catch this.
|
||||
if dest_dir.is_symlink():
|
||||
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
|
||||
return 0
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
|
||||
# dest_dir *after* the check above cannot redirect the per-file stat /
|
||||
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
|
||||
# os.replace accepts dir_fd on POSIX even though it isn't listed in
|
||||
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
|
||||
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
|
||||
dir_fd = None
|
||||
if (
|
||||
hasattr(os, "O_NOFOLLOW")
|
||||
and hasattr(os, "O_DIRECTORY")
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.rename in os.supports_dir_fd
|
||||
):
|
||||
try:
|
||||
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
|
||||
return 0
|
||||
|
||||
try:
|
||||
present = 0
|
||||
for dest_name, rel_source in sources:
|
||||
source = root / rel_source
|
||||
if not source.is_file():
|
||||
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
|
||||
continue
|
||||
|
||||
# lstat the destination without following symlinks. Pinned by dir_fd
|
||||
# this resolves within the real seed dir, immune to a parent swap.
|
||||
try:
|
||||
if dir_fd is not None:
|
||||
dstat = os.lstat(dest_name, dir_fd=dir_fd)
|
||||
else:
|
||||
dstat = os.lstat(dest_dir / dest_name)
|
||||
dest_exists = True
|
||||
dest_islink = stat.S_ISLNK(dstat.st_mode)
|
||||
except FileNotFoundError:
|
||||
dest_exists = False
|
||||
dest_islink = False
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
|
||||
continue
|
||||
|
||||
# Refuse to seed through a symlink at the destination name.
|
||||
if dest_islink:
|
||||
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
# A non-regular destination (directory, fifo, …) the user placed
|
||||
# there: never clobber it, and never count it as present — otherwise
|
||||
# a one-time seed would mark itself done without a real pack on disk.
|
||||
if dest_exists and not stat.S_ISREG(dstat.st_mode):
|
||||
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
if dest_exists:
|
||||
# A regular file is already there. One-time seeds (starter
|
||||
# content) must never overwrite the user's copy; refreshing
|
||||
# seeds (diagnostics) replace it only when the bundle is newer.
|
||||
if not update_existing:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
try:
|
||||
src_mtime = source.stat().st_mtime
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat source %s: %s", label, source, exc)
|
||||
continue
|
||||
if src_mtime <= dstat.st_mtime:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
action = "updated"
|
||||
else:
|
||||
action = "seeded"
|
||||
|
||||
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
|
||||
present += 1
|
||||
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
|
||||
else:
|
||||
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
|
||||
|
||||
return present
|
||||
finally:
|
||||
if dir_fd is not None:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def _write_builtin_pack(
|
||||
source: Path,
|
||||
dest_dir: Path,
|
||||
dest_name: str,
|
||||
dir_fd: int | None,
|
||||
) -> bool:
|
||||
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
|
||||
|
||||
Writes to a temp file then ``os.replace()``s onto the final name so a
|
||||
symlink raced in at the destination is overwritten (rename semantics), not
|
||||
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
|
||||
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
|
||||
replace), closing the parent-directory TOCTOU; otherwise falls back to
|
||||
path-based temp+replace. Returns True on success. Never raises.
|
||||
"""
|
||||
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
|
||||
# can't permanently block later seeds via an EEXIST collision.
|
||||
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
||||
try:
|
||||
src_stat = source.stat()
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
|
||||
return False
|
||||
if dir_fd is not None:
|
||||
tmp_fd = None
|
||||
try:
|
||||
tmp_fd = os.open(
|
||||
tmp_name,
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
|
||||
0o644,
|
||||
dir_fd=dir_fd,
|
||||
)
|
||||
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
|
||||
tmp_fd = None # fdopen now owns the descriptor
|
||||
shutil.copyfileobj(sf, tf)
|
||||
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
||||
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
|
||||
# refresh check matches the shutil.copy2 fallback path. Best-effort.
|
||||
try:
|
||||
os.utime(
|
||||
dest_name,
|
||||
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
|
||||
dir_fd=dir_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
|
||||
if tmp_fd is not None:
|
||||
try:
|
||||
os.close(tmp_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp_name, dir_fd=dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
|
||||
os.close(fd)
|
||||
shutil.copy2(source, tmp)
|
||||
os.replace(tmp, dest_dir / dest_name)
|
||||
tmp = None
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def seed_builtin_diagnostic_sloppaks(server_root: Path, dlc: Path | None = None) -> None:
|
||||
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
||||
|
||||
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
||||
when the destination is missing or older than the repo/bundle source.
|
||||
Never deletes user files or touches manually copied paths (e.g.
|
||||
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
|
||||
diagnostic target is always available. Logs and continues on errors.
|
||||
"""
|
||||
try:
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||||
return
|
||||
_copy_builtin_packs(
|
||||
server_root,
|
||||
dlc / BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
BUILTIN_DIAGNOSTIC_SOURCES,
|
||||
"Builtin diagnostic seed",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
|
||||
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
|
||||
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
|
||||
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
|
||||
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
|
||||
# seeded packs surface as ordinary library songs.
|
||||
BUILTIN_STARTER_SUBDIR = "starter"
|
||||
|
||||
|
||||
BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"beethoven-fur_elise.feedpak",
|
||||
"content/starter/beethoven-fur_elise.feedpak",
|
||||
),
|
||||
(
|
||||
"star_spangled_banner.feedpak",
|
||||
"content/starter/star_spangled_banner.feedpak",
|
||||
),
|
||||
(
|
||||
"the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
STARTER_SEED_MARKER = ".starter-content-seeded"
|
||||
|
||||
|
||||
def seed_builtin_starter_content(server_root: Path, dlc: Path | None = None) -> None:
|
||||
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
|
||||
|
||||
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
|
||||
folder configured seeds the packs and writes the marker; subsequent runs are
|
||||
no-ops, so a user who deletes the starter song does not get it back on the
|
||||
next launch. Symlink-safe; never deletes user files. Logs, never raises.
|
||||
"""
|
||||
try:
|
||||
marker = appstate.config_dir / STARTER_SEED_MARKER
|
||||
# Already seeded? The marker is a sentinel: any existing path there
|
||||
# (regular file, or a symlink/dir a user deliberately planted to opt
|
||||
# out) means "done" — lstat so we detect it without following a symlink.
|
||||
# Worst case of a planted marker is simply no starter content, never a
|
||||
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
|
||||
# *through* a symlink regardless.
|
||||
try:
|
||||
os.lstat(marker)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
|
||||
return
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
# No DLC yet — leave the marker unwritten so we retry once a
|
||||
# library folder is configured.
|
||||
log.debug("Starter content seed: no DLC folder configured, skipping")
|
||||
return
|
||||
present = _copy_builtin_packs(
|
||||
server_root,
|
||||
dlc / BUILTIN_STARTER_SUBDIR,
|
||||
BUILTIN_STARTER_SOURCES,
|
||||
"Starter content seed",
|
||||
update_existing=False,
|
||||
)
|
||||
# Only mark seeding complete once every starter pack is actually in
|
||||
# place. If a source was missing or a copy failed, leave the marker
|
||||
# unwritten so the next launch retries rather than permanently skipping.
|
||||
if present < len(BUILTIN_STARTER_SOURCES):
|
||||
log.info(
|
||||
"Starter content seed: %d/%d packs present, will retry next launch",
|
||||
present,
|
||||
len(BUILTIN_STARTER_SOURCES),
|
||||
)
|
||||
return
|
||||
# Record completion with an exclusive, no-follow create so a planted or
|
||||
# raced symlink at the marker path can't redirect the write outside
|
||||
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
|
||||
# symlink, so we never write through one.
|
||||
try:
|
||||
appstate.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(marker, flags, 0o644)
|
||||
try:
|
||||
os.write(fd, b"1\n")
|
||||
finally:
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
pass # already marked (or a non-regular path is squatting) — fine
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
|
||||
except Exception:
|
||||
log.warning("Starter content seed: unexpected error", exc_info=True)
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Demo mode: the read-only request guard and the hourly session janitor.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b). Bodies are byte-identical — including a bug, see
|
||||
below.
|
||||
|
||||
━━━ THE MIDDLEWARE NEEDS `app`, SO THIS MODULE TAKES IT ━━━
|
||||
|
||||
`_demo_mode_guard` is an @app.middleware("http"), and a middleware has to be attached to an
|
||||
app object. Rather than reach for a global, this module exposes install(app): server.py
|
||||
owns the app and hands it over. Same direction as every other seam here — server.py knows
|
||||
things lib/ must not have to guess.
|
||||
|
||||
The janitor is symmetrical: start_janitor() / stop_janitor(), called from server.py's
|
||||
startup and shutdown hooks, which is where the process lifecycle actually lives.
|
||||
|
||||
━━━ register_demo_janitor_hook IS PART OF THE PLUGIN CONTRACT ━━━
|
||||
|
||||
It is a key in plugin_context, so plugins hold it as a LIVE REFERENCE from setup(). Moving
|
||||
the function is fine; wrapping or renaming it is not. server.py imports this exact object
|
||||
and puts it in the dict unchanged, so callable identity is preserved —
|
||||
tests/test_plugin_context_contract.py (#898) fails if that ever stops being true.
|
||||
|
||||
━━━ A BUG MOVED VERBATIM, ON PURPOSE ━━━
|
||||
|
||||
The janitor start guard in server.py reads:
|
||||
|
||||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
|
||||
and not _DEMO_JANITOR_STARTED:
|
||||
|
||||
`and` binds tighter than `or`, so that is `A or (B and C)` — the `not _DEMO_JANITOR_STARTED`
|
||||
re-entry guard is DEAD whenever the env var is truthy, which is the only case that runs. A
|
||||
second startup leaks a janitor thread (the handle is overwritten, so shutdown joins only
|
||||
the last). Preserved exactly as-is here and filed as issue #902: a carve whose value is
|
||||
being provably behaviour-neutral is not the place to change behaviour.
|
||||
"""
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.demo_mode")
|
||||
|
||||
|
||||
# Plugins that maintain session stores can register a cleanup callback here.
|
||||
# The demo-mode janitor calls every registered hook once per hour so stale
|
||||
# sessions are swept without the core needing to know plugin internals.
|
||||
_DEMO_JANITOR_HOOKS: list = []
|
||||
|
||||
|
||||
_DEMO_JANITOR_HOOKS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
_DEMO_JANITOR_STARTED = False
|
||||
|
||||
|
||||
_DEMO_JANITOR_STOP = threading.Event()
|
||||
|
||||
|
||||
_DEMO_JANITOR_THREAD: threading.Thread | None = None
|
||||
|
||||
|
||||
def register_demo_janitor_hook(fn) -> None:
|
||||
"""Register a zero-argument callable to be invoked hourly by the demo
|
||||
janitor. Plugins call this from their ``setup(app, context)`` when they
|
||||
want to participate in session cleanup under demo mode.
|
||||
|
||||
The callable must accept no required arguments. Async (coroutine)
|
||||
functions are rejected: the janitor runs in a plain thread and cannot
|
||||
await coroutines.
|
||||
"""
|
||||
if not callable(fn):
|
||||
raise TypeError(
|
||||
f"register_demo_janitor_hook expects a callable, got {type(fn).__name__!r}"
|
||||
)
|
||||
# Reject coroutine functions — check both the callable itself and its
|
||||
# __call__ method so objects with an async __call__ (e.g. class instances,
|
||||
# functools.partial wrappers around async functions) are also caught.
|
||||
_call = getattr(fn, "__call__", None)
|
||||
if inspect.iscoroutinefunction(fn) or (
|
||||
_call is not None and inspect.iscoroutinefunction(_call)
|
||||
):
|
||||
raise TypeError(
|
||||
"register_demo_janitor_hook does not accept async functions; "
|
||||
"the janitor runs in a plain thread and cannot await coroutines"
|
||||
)
|
||||
# Validate that the callable accepts zero required arguments so it won't
|
||||
# crash at sweep time (hourly, far from the registration site).
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
except ValueError:
|
||||
# inspect.signature() raises ValueError for built-in C callables whose
|
||||
# signature cannot be determined. Accept them as-is; if they fail at
|
||||
# runtime the janitor will catch and log the exception.
|
||||
pass
|
||||
else:
|
||||
required = [
|
||||
p for p in sig.parameters.values()
|
||||
if p.default is inspect.Parameter.empty
|
||||
and p.kind not in (
|
||||
inspect.Parameter.VAR_POSITIONAL,
|
||||
inspect.Parameter.VAR_KEYWORD,
|
||||
)
|
||||
]
|
||||
if required:
|
||||
raise TypeError(
|
||||
f"register_demo_janitor_hook expects a zero-argument callable; "
|
||||
f"{fn!r} has {len(required)} required parameter(s): "
|
||||
+ ", ".join(p.name for p in required)
|
||||
)
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
_DEMO_JANITOR_HOOKS.append(fn)
|
||||
|
||||
|
||||
def _run_janitor_hook(hook) -> None:
|
||||
"""Run a single janitor hook inline, swallowing and logging any exception.
|
||||
|
||||
If the hook returns an awaitable (e.g. a coroutine slipped through the
|
||||
async-function guard), the coroutine is closed immediately to avoid
|
||||
``RuntimeWarning: coroutine was never awaited`` noise, and a warning is
|
||||
emitted so the plugin author knows to fix their hook.
|
||||
"""
|
||||
try:
|
||||
result = hook()
|
||||
except Exception:
|
||||
log.exception("janitor hook %r raised", hook)
|
||||
return
|
||||
if inspect.iscoroutine(result):
|
||||
# A coroutine slipped through the async-function guard (e.g. via a
|
||||
# wrapper/partial). Close it to suppress "coroutine never awaited",
|
||||
# then warn so the plugin author knows to fix their hook.
|
||||
try:
|
||||
result.close()
|
||||
except Exception:
|
||||
log.exception("error closing coroutine from janitor hook %r", hook)
|
||||
warnings.warn(
|
||||
f"janitor hook {hook!r} returned a coroutine; "
|
||||
"hooks must be plain synchronous callables — "
|
||||
"register_demo_janitor_hook does not accept async functions",
|
||||
RuntimeWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
elif inspect.isawaitable(result):
|
||||
# Future/Task: no .close() method; just warn and leave it alone.
|
||||
warnings.warn(
|
||||
f"janitor hook {hook!r} returned an awaitable (Future/Task); "
|
||||
"hooks must be plain synchronous callables",
|
||||
RuntimeWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
|
||||
_DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("POST", re.compile(r"^/api/settings$")),
|
||||
("POST", re.compile(r"^/api/settings/import$")),
|
||||
("POST", re.compile(r"^/api/settings/reset$")),
|
||||
("POST", re.compile(r"^/api/rescan$")),
|
||||
("POST", re.compile(r"^/api/rescan/full$")),
|
||||
("POST", re.compile(r"^/api/songs/upload$")),
|
||||
("DELETE", re.compile(r"^/api/song/.+$")),
|
||||
("POST", re.compile(r"^/api/favorites/toggle$")),
|
||||
("POST", re.compile(r"^/api/loops$")),
|
||||
("DELETE", re.compile(r"^/api/loops/[^/]+$")),
|
||||
("POST", re.compile(r"^/api/audio-effects/mappings$")),
|
||||
("DELETE", re.compile(r"^/api/audio-effects/mappings/[^/]+$")),
|
||||
("POST", re.compile(r"^/api/audio-effects/mappings/[^/]+/activate$")),
|
||||
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
|
||||
("POST", re.compile(r"^/api/song/.*/meta$")),
|
||||
("POST", re.compile(r"^/api/song/.*/art/upload$")),
|
||||
("PUT", re.compile(r"^/api/song/.+/overrides$")),
|
||||
("GET", re.compile(r"^/api/plugins/updates$")),
|
||||
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/save$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/build$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/upload-art$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/upload-audio$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/youtube-audio$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/import-gp$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/import-midi$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/align$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/generate-pitch$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/save-lyrics$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_sync/align$")),
|
||||
("POST", re.compile(r"^/api/plugins/lyrics_sync/save$")),
|
||||
("POST", re.compile(r"^/api/plugins/studio/sessions/[^/]+/extract-drums$")),
|
||||
("POST", re.compile(r"^/api/diagnostics/export$")),
|
||||
("GET", re.compile(r"^/api/diagnostics/preview$")),
|
||||
("GET", re.compile(r"^/api/diagnostics/hardware$")),
|
||||
# Bundled core plugin — video background upload/delete
|
||||
("POST", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||||
("DELETE", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||||
# fee[dB]ack v0.3.0 write endpoints — demo mode is read-only, so block the
|
||||
# new profile / XP / stats / playlists / saved mutators too.
|
||||
("POST", re.compile(r"^/api/profile$")),
|
||||
("POST", re.compile(r"^/api/profile/avatar$")),
|
||||
("POST", re.compile(r"^/api/xp/award$")),
|
||||
("POST", re.compile(r"^/api/stats$")),
|
||||
("POST", re.compile(r"^/api/playlists$")),
|
||||
("PATCH", re.compile(r"^/api/playlists/[^/]+$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("POST", re.compile(r"^/api/saved/toggle$")),
|
||||
# Progression (spec 010) write endpoints — demo mode stays read-only.
|
||||
("POST", re.compile(r"^/api/progression/paths$")),
|
||||
("POST", re.compile(r"^/api/progression/onboarding$")),
|
||||
("POST", re.compile(r"^/api/progression/events$")),
|
||||
("POST", re.compile(r"^/api/shop/buy$")),
|
||||
("POST", re.compile(r"^/api/shop/equip$")),
|
||||
# Enrichment (P8): review writes mutate the local match cache, and the
|
||||
# search proxy / manual kick relay to MusicBrainz — none of it belongs to
|
||||
# anonymous demo visitors (they'd spend the shared rate limit).
|
||||
("POST", re.compile(r"^/api/enrichment/review/.+$")),
|
||||
("POST", re.compile(r"^/api/enrichment/kick$")),
|
||||
("POST", re.compile(r"^/api/enrichment/cancel$")),
|
||||
("POST", re.compile(r"^/api/enrichment/rematch$")),
|
||||
("GET", re.compile(r"^/api/enrichment/search$")),
|
||||
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
|
||||
# and spend the shared AcoustID rate budget on the caller's behalf — same
|
||||
# rule as the search/kick relays above; not for anonymous demo visitors.
|
||||
("POST", re.compile(r"^/api/enrichment/identify$")),
|
||||
("POST", re.compile(r"^/api/enrichment/identify/.+$")),
|
||||
# Context menus (R2): the per-song re-match mutates the cache + spends
|
||||
# rate limit; Get-info exposes filesystem paths.
|
||||
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
|
||||
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
|
||||
# Gap-fill (R4a) rewrites pack files on disk — never for demo visitors.
|
||||
("POST", re.compile(r"^/api/song/.+/gap-fill$")),
|
||||
# Art layer (R3): all three mutate server state / touch the network on a
|
||||
# visitor's behalf — the base64 upload writes files, the URL fetch makes the
|
||||
# server request arbitrary images, and the override delete removes files.
|
||||
("POST", re.compile(r"^/api/song/.+/art/upload$")),
|
||||
("POST", re.compile(r"^/api/song/.+/art/url$")),
|
||||
("DELETE", re.compile(r"^/api/art/.+/override$")),
|
||||
# Cover picker (PR-C): read-only, but a cache-miss open spends 1-3
|
||||
# throttled Cover Art Archive calls — anonymous demo visitors don't get
|
||||
# to spend the shared rate budget (same rule as enrichment search/kick).
|
||||
("GET", re.compile(r"^/api/song/.+/art/candidates$")),
|
||||
# Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a
|
||||
# visitor's behalf AND writes the artist_enrichment cache; refresh
|
||||
# re-spends the shared rate limit. The /page route stays open (all-local
|
||||
# read). Same rationale as /api/enrichment/search above.
|
||||
("GET", re.compile(r"^/api/artist/.+/links$")),
|
||||
("POST", re.compile(r"^/api/artist/.+/links/refresh$")),
|
||||
]
|
||||
|
||||
|
||||
async def _demo_mode_guard(request: Request, call_next):
|
||||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1":
|
||||
path = request.url.path
|
||||
for method, pattern in _DEMO_BLOCKED:
|
||||
if request.method == method and pattern.match(path):
|
||||
return JSONResponse({"error": "demo mode: read-only"}, status_code=403)
|
||||
response = await call_next(request)
|
||||
if request.method == "GET" and path == "/" and "feedBack_demo_session" not in request.cookies:
|
||||
forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip()
|
||||
is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
||||
response.set_cookie(
|
||||
"feedBack_demo_session", str(uuid.uuid4()),
|
||||
max_age=86400, httponly=True, samesite="lax",
|
||||
secure=is_secure,
|
||||
)
|
||||
return response
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def install(app) -> None:
|
||||
"""Attach the demo-mode request guard to `app`.
|
||||
|
||||
Called by server.py, which owns the app. A middleware cannot exist without one, and a
|
||||
module under lib/ should not be reaching for a global to find it.
|
||||
"""
|
||||
app.middleware("http")(_demo_mode_guard)
|
||||
|
||||
|
||||
def demo_mode_enabled() -> bool:
|
||||
"""True when demo mode is on. Read at CALL time, never captured — tests set and unset
|
||||
FEEDBACK_DEMO_MODE with monkeypatch, so a value cached at import pins the wrong one."""
|
||||
return bool(getenv_compat("FEEDBACK_DEMO_MODE"))
|
||||
|
||||
|
||||
def start_janitor() -> None:
|
||||
"""Start the hourly session janitor, at most one at a time. server.py's startup hook.
|
||||
|
||||
━━━ THE GUARD ASKS "IS A HEALTHY JANITOR RUNNING?", AND NOTHING ELSE ━━━
|
||||
|
||||
Three ways to get this wrong, and #902 plus two Codex passes found all three:
|
||||
|
||||
1. NO GUARD (the original #902 bug). The re-entry check lived at the call site as
|
||||
`A or (B and C)`, so it never ran, and a second startup started a SECOND thread,
|
||||
overwrote the handle, and left the first to fire hooks forever, unjoinable.
|
||||
|
||||
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). stop_janitor() deliberately
|
||||
leaves that flag True when a hook outruns its join timeout — so once that hook
|
||||
finishes and the thread exits, the flag is stale and a later startup would refuse to
|
||||
start a replacement. Demo cleanup silently dead for the rest of the process.
|
||||
|
||||
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). A timed-out stop leaves the
|
||||
old thread ALIVE BUT DOOMED — its stop event is set, and it exits the moment its
|
||||
current hook returns. Treating it as a running janitor means the replacement is never
|
||||
started, and we are back at (2) a second later.
|
||||
|
||||
So a janitor counts as running only if its thread is alive AND it has not been told to
|
||||
stop.
|
||||
|
||||
━━━ AND WHY EACH JANITOR OWNS ITS STOP EVENT ━━━
|
||||
|
||||
This used to `_DEMO_JANITOR_STOP.clear()` a single shared Event. If a replacement were
|
||||
started while a doomed thread was still finishing a hook, clearing the shared event would
|
||||
RESURRECT it — it loops back to `stop.wait()`, sees the flag cleared, and carries on.
|
||||
Two janitors, which is the exact bug we started from.
|
||||
|
||||
A fresh Event per janitor makes that impossible: the old thread waits on its OWN event,
|
||||
which stays set forever, so it can only exit.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _DEMO_JANITOR_STOP
|
||||
|
||||
thread = _DEMO_JANITOR_THREAD
|
||||
if thread is not None and thread.is_alive() and not _DEMO_JANITOR_STOP.is_set():
|
||||
return # a healthy janitor is already running
|
||||
|
||||
# Either there is no janitor, or the previous one is dead / dying. Give the new one its
|
||||
# OWN stop event so the old one stays stopped no matter what we do to ours.
|
||||
stop = threading.Event()
|
||||
_DEMO_JANITOR_STOP = stop
|
||||
_DEMO_JANITOR_STARTED = True
|
||||
|
||||
def _janitor():
|
||||
# Closes over `stop`, NOT the module global — a later start_janitor() rebinds
|
||||
# _DEMO_JANITOR_STOP, and this thread must keep watching the event it was born with.
|
||||
while not stop.wait(timeout=3600):
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
hooks = list(_DEMO_JANITOR_HOOKS)
|
||||
for hook in hooks:
|
||||
_run_janitor_hook(hook)
|
||||
|
||||
_DEMO_JANITOR_THREAD = threading.Thread(target=_janitor, daemon=True, name="demo-janitor")
|
||||
_DEMO_JANITOR_THREAD.start()
|
||||
|
||||
|
||||
def janitor_started() -> bool:
|
||||
return _DEMO_JANITOR_STARTED
|
||||
|
||||
|
||||
def stop_janitor(timeout: float = 5) -> bool:
|
||||
"""Signal the janitor to stop, join it, and drop the registered hooks.
|
||||
|
||||
Returns True if it stopped, False if it outlived the join (the caller warns).
|
||||
|
||||
THE ORDER HERE IS LOAD-BEARING and preserved exactly from server.py. When the thread
|
||||
does NOT die within the timeout we return WITHOUT clearing _DEMO_JANITOR_STARTED and
|
||||
WITHOUT dropping the thread handle — deliberately — so a subsequent startup does not
|
||||
spawn a SECOND janitor alongside the one still running. Clearing the flag first (the
|
||||
obvious way to write this) would quietly reintroduce exactly the double-janitor leak
|
||||
the flag exists to prevent.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||||
if not _DEMO_JANITOR_STARTED:
|
||||
return True
|
||||
_DEMO_JANITOR_STOP.set()
|
||||
thread = _DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
if thread.is_alive():
|
||||
# Leave _DEMO_JANITOR_STARTED True so a new janitor is not spawned by a
|
||||
# subsequent startup while the old one is alive.
|
||||
return False
|
||||
_DEMO_JANITOR_THREAD = None
|
||||
_DEMO_JANITOR_STARTED = False
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
_DEMO_JANITOR_HOOKS.clear()
|
||||
return True
|
||||
+9
-1
@@ -14,6 +14,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
from safepath import resolved_root
|
||||
|
||||
|
||||
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
||||
@@ -86,7 +87,14 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
||||
or PureWindowsPath(safe).drive):
|
||||
return None
|
||||
try:
|
||||
root = dlc.resolve()
|
||||
# The library root is fixed for the life of the process, but this
|
||||
# function runs once per song / art fetch / scanned row — and
|
||||
# `.resolve()` lstats every path component. Re-resolving here was
|
||||
# ~23,500 stat calls/sec on a 50,944-song library, which pins a core
|
||||
# when the library sits on a FUSE mount (NTFS-3G, SMB, sshfs) where each
|
||||
# stat is a userspace round trip. Resolve the root once; see
|
||||
# safepath.resolved_root for the caching contract.
|
||||
root = resolved_root(dlc)
|
||||
# normpath collapses `.`/`..`/duplicate separators purely lexically —
|
||||
# it never touches the filesystem, so an in-library junction component
|
||||
# is preserved (allowed) while `..`/absolute segments still escape and
|
||||
|
||||
+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
|
||||
|
||||
+31
-3
@@ -4,9 +4,34 @@ under a server-owned root.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def resolved_root(root: Path) -> Path:
|
||||
"""Canonical (link-resolved) form of a server-owned root directory.
|
||||
|
||||
``Path.resolve()`` is a filesystem call: it lstats every component of the
|
||||
path. The roots we join against — the DLC library, a plugin's asset dir —
|
||||
are fixed for the life of the process, but the containment helpers below
|
||||
(and ``dlc_paths._resolve_dlc_path``) were re-resolving them on EVERY call,
|
||||
and those are called once per song, per art fetch, per scanned row.
|
||||
|
||||
On a real 50,944-song library that cost ~23,500 stat/lstat calls per second,
|
||||
pinning a core. It is brutal when the library lives on a FUSE mount
|
||||
(NTFS-3G, SMB, sshfs), where every stat is a userspace round trip: the same
|
||||
three parent directories were being walked over and over.
|
||||
|
||||
Cached because a root is a constant here, not because resolution is cheap.
|
||||
Consequence: if a root's symlink/junction is re-pointed at a NEW target
|
||||
while the server is running, the old target stays in effect until restart.
|
||||
That is fine for a library path fixed at startup, and the cache is keyed on
|
||||
the Path, so switching to a different library dir is a different key.
|
||||
"""
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def safe_join(root: Path, name: str) -> Path | None:
|
||||
"""Resolve ``name`` under ``root`` and return the resolved Path, or
|
||||
``None`` if it would escape ``root`` or is unrepresentable.
|
||||
@@ -35,9 +60,12 @@ def safe_join(root: Path, name: str) -> Path | None:
|
||||
return None
|
||||
safe = name.replace("\\", "/")
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
candidate = (root_resolved / safe).resolve()
|
||||
if not candidate.is_relative_to(root_resolved):
|
||||
# The ROOT is a constant — resolve it once (see resolved_root). The
|
||||
# CANDIDATE must still be resolved on every call: following its symlinks
|
||||
# is exactly the zip-slip / traversal defence, so it is never cached.
|
||||
root_res = resolved_root(root)
|
||||
candidate = (root_res / safe).resolve()
|
||||
if not candidate.is_relative_to(root_res):
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"""The library scanner: the background scan, its process pool, and the kick/runner
|
||||
plumbing that serialises passes.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b) except the seam reads. Everything shared is read
|
||||
LATE off appstate — the same contract every module in lib/routers/ uses, and it is not
|
||||
cosmetic: tests monkeypatch CONFIG_DIR and swap meta_db, so a value captured at import
|
||||
time would pin the wrong one for the life of the process.
|
||||
|
||||
CONFIG_DIR -> appstate.config_dir
|
||||
meta_db -> appstate.meta_db
|
||||
_default_settings -> appstate.default_settings()
|
||||
_stat_for_cache -> appstate.stat_for_cache()
|
||||
_feedBack_server_root() -> appstate.server_root <- see below
|
||||
|
||||
━━━ THE SCAN STATUS IS REBOUND, NOT MUTATED ━━━
|
||||
|
||||
`_background_scan` does `global _scan_status; _scan_status = {**INIT, ...}` at every stage
|
||||
transition. It REPLACES the dict; it does not update it in place. So nothing may hold the
|
||||
dict by value — a reference captured once goes permanently stale at the first stage change,
|
||||
and would report "listing" forever while the scan ran to completion.
|
||||
|
||||
That is why this module exports `status()`, a getter, and why appstate publishes
|
||||
`scan_status` as a CALLABLE rather than a dict. appstate.py already says so in a comment;
|
||||
this is the code that makes it true.
|
||||
|
||||
━━━ AND WHY THE SERVER ROOT IS READ, NEVER DERIVED ━━━
|
||||
|
||||
`_background_scan` seeds the builtin content, which needs the directory holding server.py.
|
||||
`Path(__file__).resolve().parent` is correct in server.py and silently WRONG here (it
|
||||
yields lib/, which has no docs/ or data/) — and it fails by finding nothing rather than by
|
||||
raising, so the seeds would just quietly never run. server.py publishes the root once, as
|
||||
appstate.server_root. Read it; never re-derive it.
|
||||
"""
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
import builtin_content
|
||||
import enrichment
|
||||
import loosefolder as loosefolder_mod
|
||||
import sloppak as sloppak_mod
|
||||
from appconfig import _load_config
|
||||
from dlc_paths import _get_dlc_dir
|
||||
from env_compat import getenv_compat
|
||||
from scan_worker import _relpath, _scan_one
|
||||
|
||||
log = logging.getLogger("feedBack.scan")
|
||||
|
||||
|
||||
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
|
||||
|
||||
|
||||
_scan_status = dict(_SCAN_STATUS_INIT)
|
||||
|
||||
|
||||
def _make_scan_executor():
|
||||
"""Build the executor for the background metadata scan.
|
||||
|
||||
A `spawn` ProcessPoolExecutor in production. `spawn` (not the platform
|
||||
default) is mandatory: _background_scan runs on a non-main daemon
|
||||
thread, and forking a multithreaded process from a non-main thread can
|
||||
deadlock on locks held by other threads at fork time (the default on
|
||||
Linux). `spawn` boots a clean interpreter that imports only scan_worker
|
||||
(+ its pure lib deps) to unpickle the worker — never this module — so
|
||||
workers don't re-run server.py's import-time side effects (reopening
|
||||
SQLite, attaching a second RotatingFileHandler, re-registering routes).
|
||||
|
||||
Tests monkeypatch this to a ThreadPoolExecutor so the scan runs
|
||||
in-process and metadata extraction can be mocked.
|
||||
"""
|
||||
mp_ctx = multiprocessing.get_context("spawn")
|
||||
# Default to one worker per core so CPU-bound metadata parsing uses the
|
||||
# whole machine (the point of moving to processes).
|
||||
# FEEDBACK_MAX_SCAN_WORKERS (set by the Desktop launcher to cap memory
|
||||
# usage on low-RAM machines — e.g. 8 GB M2 MacBook Air) takes priority;
|
||||
# SCAN_MAX_WORKERS is a legacy override for Docker/bare installs.
|
||||
# A malformed override falls back to the core count rather than crashing.
|
||||
try:
|
||||
max_workers = int(
|
||||
getenv_compat("FEEDBACK_MAX_SCAN_WORKERS")
|
||||
or os.environ.get("SCAN_MAX_WORKERS")
|
||||
or (os.cpu_count() or 1)
|
||||
)
|
||||
except ValueError:
|
||||
max_workers = os.cpu_count() or 1
|
||||
# ProcessPoolExecutor raises ValueError on Windows when max_workers > 61
|
||||
# (the WaitForMultipleObjects handle limit), so clamp there — otherwise
|
||||
# a high-core Windows host can't construct the pool and the scan never
|
||||
# starts.
|
||||
if sys.platform == "win32":
|
||||
max_workers = min(max_workers, 61)
|
||||
return concurrent.futures.ProcessPoolExecutor(
|
||||
max_workers=max(1, max_workers), mp_context=mp_ctx,
|
||||
)
|
||||
|
||||
|
||||
def background_scan():
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
Never sets `_scan_status["running"] = False` — ownership of that flag
|
||||
lives in `_scan_runner` so a `kick_scan()` racing this function's
|
||||
terminal write cannot observe a stale False and start a second runner.
|
||||
"""
|
||||
global _scan_status
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "listing"}
|
||||
|
||||
# Load config once so both the DLC-dir lookup and the platform filter
|
||||
# read from the same snapshot, avoiding a redundant parse of config.json.
|
||||
_cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings()
|
||||
dlc = _get_dlc_dir(_cfg)
|
||||
if not dlc:
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "idle", "error": "DLC folder not configured"}
|
||||
log.warning("Scan: no DLC folder configured")
|
||||
return
|
||||
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
|
||||
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
|
||||
|
||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||
# path isn't shared. Report the failure explicitly rather than silently
|
||||
# appearing to scan nothing.
|
||||
try:
|
||||
# Generated-content sloppaks that the highway WS must resolve by path
|
||||
# but that are NOT library songs. Two conventions share this carve-out:
|
||||
# - tutorials-builtin/ — lesson drills seeded by the tutorials plugin
|
||||
# (see plugins/tutorials/routes.py::_seed_builtin_packs).
|
||||
# - minigames-builtin/ — exercise charts generated on demand by
|
||||
# minigame plugins (e.g. Chord Sprint writes alternating-chord
|
||||
# drills here). Cached/reused per exercise, never browsed.
|
||||
# Both are kept out of the scan; _resolve_dlc_path still loads them by
|
||||
# path for playback.
|
||||
def _is_excluded_from_library(p: Path) -> bool:
|
||||
return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts
|
||||
# Sloppaks: match both file (zip) and directory form, across both the
|
||||
# `.feedpak` and legacy `.sloppak` suffixes.
|
||||
_cands = sorted(p for ext in sloppak_mod.SONG_EXTS for p in dlc.rglob(f"*{ext}"))
|
||||
sloppaks = [f for f in _cands
|
||||
if sloppak_mod.is_sloppak(f)
|
||||
and not _is_excluded_from_library(f)]
|
||||
|
||||
# Loose song folders: any directory containing a non-preview *.wem + *.xml.
|
||||
# Skip directories that are actually sloppak bundles — those are
|
||||
# already in `sloppaks`; the dispatcher's sloppak-first precedence
|
||||
# would route them to the sloppak path anyway, but adding them
|
||||
# here would inflate the scan queue and over-count the total.
|
||||
loose_songs = []
|
||||
seen_loose = set()
|
||||
sloppak_dirs = {p for p in sloppaks if p.is_dir()}
|
||||
for wem in sorted(dlc.rglob("*.wem")):
|
||||
if "preview" in wem.stem.lower():
|
||||
continue
|
||||
if _is_excluded_from_library(wem):
|
||||
continue
|
||||
d = wem.parent
|
||||
if d in sloppak_dirs or d.name.lower().endswith(sloppak_mod.SONG_EXTS):
|
||||
continue
|
||||
if d not in seen_loose and loosefolder_mod.is_loose_song(d):
|
||||
loose_songs.append(d)
|
||||
seen_loose.add(d)
|
||||
except PermissionError as e:
|
||||
msg = (f"Permission denied reading {dlc}. "
|
||||
"On macOS: grant Full Disk Access to the app in System Settings → Privacy & Security. "
|
||||
"With Docker: share this path in Docker Desktop → Settings → Resources → File Sharing.")
|
||||
log.error("Scan failed: %s (%s)", msg, e)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": msg}
|
||||
return
|
||||
except OSError as e:
|
||||
log.error("Scan failed listing %s: %s", dlc, e)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": f"Unable to list {dlc}: {e}"}
|
||||
return
|
||||
|
||||
all_songs = sloppaks + loose_songs
|
||||
log.info("Scan: listed %d sloppaks and %d loose folders in %s",
|
||||
len(sloppaks), len(loose_songs), dlc)
|
||||
|
||||
current_files = {_relpath(f, dlc) for f in all_songs}
|
||||
|
||||
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned
|
||||
# + genuinely-new files) so the scan can surface an added/removed summary.
|
||||
_delta = appstate.meta_db.delete_missing(current_files)
|
||||
removed, added = _delta["removed"], _delta["added"]
|
||||
if removed:
|
||||
log.info("Removed %d stale DB entries", removed)
|
||||
|
||||
# Figure out which need scanning
|
||||
to_scan = []
|
||||
for f in all_songs:
|
||||
# Skip entries that vanish or become unreadable between listing
|
||||
# and stat. Without this, one concurrent move/delete in DLC_DIR
|
||||
# would crash the scan thread and leave `_scan_status["running"]`
|
||||
# stuck true with no path to recover.
|
||||
try:
|
||||
mtime, size = appstate.stat_for_cache(f)
|
||||
except OSError as e:
|
||||
log.debug("scan: skipping %s (%s)", f, e)
|
||||
continue
|
||||
cache_key = _relpath(f, dlc)
|
||||
try:
|
||||
cached = appstate.meta_db.get(cache_key, mtime, size)
|
||||
except Exception as e:
|
||||
# Keep scanning even if a single metadata lookup fails.
|
||||
# The file will be re-scanned and cache repaired by put().
|
||||
log.warning("scan cache lookup failed for %s: %s", cache_key, e)
|
||||
cached = None
|
||||
if not cached:
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
elif cached.get("arrangements") and any(
|
||||
"smart_name" not in a for a in cached["arrangements"]
|
||||
):
|
||||
# Row was scanned before smart naming was introduced — force a
|
||||
# rescan so the DB picks up authoritative path flags from the
|
||||
# manifest JSON and stores correct smart_name values. Don't
|
||||
# re-queue rows where smart_name is explicitly null: the writer
|
||||
# only emits that when compute_smart_names truly can't classify
|
||||
# the arrangement (e.g. a name outside the recognised set with
|
||||
# zero path flags), so rescanning would produce the same null
|
||||
# forever and never converge.
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
|
||||
if not to_scan:
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
|
||||
return
|
||||
|
||||
# Refine: all discovered songs need scanning → treat as first-time import
|
||||
# (covers moved DLC folder / fully-stale DB as well as a genuinely empty DB).
|
||||
is_first_scan = bool(all_songs) and len(to_scan) == len(all_songs)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "scanning", "total": len(to_scan),
|
||||
"is_first_scan": is_first_scan}
|
||||
log.info("Library: %d sloppaks + %d loose folders, %d cached, %d to scan",
|
||||
len(sloppaks), len(loose_songs), len(all_songs) - len(to_scan), len(to_scan))
|
||||
|
||||
with _make_scan_executor() as executor:
|
||||
futures = {executor.submit(_scan_one, item): item[0].name for item in to_scan}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
fname = futures[future]
|
||||
try:
|
||||
name, mtime, size, meta = future.result()
|
||||
appstate.meta_db.put(name, mtime, size, meta)
|
||||
except Exception as e:
|
||||
log.warning("scan failed for %s: %s", fname, e)
|
||||
_scan_status["done"] += 1
|
||||
_scan_status["current"] = fname
|
||||
|
||||
log.info("Scan complete: %d songs cached", len(to_scan))
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
|
||||
|
||||
_scan_kick_lock = threading.Lock()
|
||||
|
||||
|
||||
_scan_rescan_pending = False
|
||||
|
||||
|
||||
# Handles to the running scan / enrichment worker threads. Both use the shared
|
||||
# MetadataDB connection, so teardown/shutdown MUST join them before closing that
|
||||
# connection — a daemon thread mid-query on a closed SQLite conn is a native
|
||||
# use-after-free that segfaults the process (seen flaky in CI). Set by
|
||||
# _kick_scan / _kick_enrich; joined by _join_background_db_threads().
|
||||
_scan_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def kick_scan() -> bool:
|
||||
"""Request a library rescan, single-flight + coalescing.
|
||||
|
||||
Returns True if a new scan thread was started, False if one was already
|
||||
running. In the latter case a follow-up pass is queued and runs as soon
|
||||
as the current scan finishes so files landing mid-scan (e.g. an upload
|
||||
that finalizes after the scan has already listed DLC_DIR) are not lost
|
||||
until the next periodic pass. Multiple late-arriving requests coalesce
|
||||
into a single follow-up.
|
||||
"""
|
||||
global _scan_rescan_pending, _scan_thread
|
||||
with _scan_kick_lock:
|
||||
if _scan_status["running"]:
|
||||
_scan_rescan_pending = True
|
||||
return False
|
||||
# Mark running synchronously so a parallel kick_scan() observes it
|
||||
# before the worker thread has a chance to reassign _scan_status.
|
||||
_scan_status["running"] = True
|
||||
_scan_thread = threading.Thread(target=_scan_runner, daemon=True)
|
||||
_scan_thread.start()
|
||||
return True
|
||||
|
||||
|
||||
def _scan_runner():
|
||||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||||
global _scan_rescan_pending
|
||||
while True:
|
||||
try:
|
||||
background_scan()
|
||||
except Exception:
|
||||
log.exception("background scan failed unexpectedly")
|
||||
|
||||
with _scan_kick_lock:
|
||||
if not _scan_rescan_pending:
|
||||
_scan_status["running"] = False
|
||||
break
|
||||
_scan_rescan_pending = False
|
||||
_scan_status["running"] = True
|
||||
# Enrichment rides scan completion (library-metadata design §6): the scan
|
||||
# pool is a side-effect-free, no-network process pool by design, so
|
||||
# enrichment is a SEPARATE post-scan pass — non-blocking, the library is
|
||||
# usable immediately. The 5-minute periodic rescan re-kicks it, which is
|
||||
# the natural low-priority retry hook.
|
||||
enrichment._kick_enrich()
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""The live scan status.
|
||||
|
||||
A GETTER, deliberately. `_scan_status` is REBOUND on every stage transition, so a
|
||||
caller holding the dict would be reading a snapshot frozen at whatever stage it
|
||||
happened to grab — see the module header.
|
||||
"""
|
||||
return _scan_status
|
||||
|
||||
|
||||
def scan_thread():
|
||||
"""The background scan thread, or None. Read by shutdown to join it."""
|
||||
return _scan_thread
|
||||
+409
-61
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
@@ -34,6 +35,21 @@ FEEDPAK_EXT = ".feedpak"
|
||||
SLOPPAK_EXT = ".sloppak"
|
||||
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
|
||||
|
||||
# ── The full mix ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Spec §5.3 RESERVES the stem id `full` for the song's complete mixdown: the
|
||||
# whole song in one file, as heard before source separation. It is a stem — it
|
||||
# lives in `stems` like every other audio file in a pack — but it is a *mixdown,
|
||||
# not a layer*. A reader that sums stems must never include it in the sum: it
|
||||
# already contains every instrument, so summing it doubles the whole song and
|
||||
# muting `guitar` still leaves guitar audible inside it.
|
||||
#
|
||||
# Keeping it matters because separation is lossy: re-summing guitar+bass+drums+
|
||||
# vocals does NOT reproduce the file they came from. The mixdown is the only
|
||||
# faithful rendering of the song a pack can carry, so we play it whenever every
|
||||
# stem sits at unity and nothing is muted.
|
||||
FULL_MIX_STEM_ID = "full"
|
||||
|
||||
import yaml
|
||||
|
||||
from jsonc import load_json
|
||||
@@ -51,6 +67,97 @@ import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
|
||||
|
||||
def find_full_mix(stems: list[dict]) -> dict | None:
|
||||
"""The RESERVED `full` stem (spec §5.3) — the pack's complete mixdown — or None.
|
||||
|
||||
Answers "what is this pack's master audio", which is what fingerprinting
|
||||
wants. For playback use partition_stems() instead: a pack whose *only* stem
|
||||
is `full` has no mixdown to play *separately from* its stems, and this
|
||||
function still returns it.
|
||||
"""
|
||||
return next(
|
||||
(s for s in stems if str(s.get("id", "")) == FULL_MIX_STEM_ID), None
|
||||
)
|
||||
|
||||
|
||||
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
||||
|
||||
The mixdown is lifted OUT of the stem list because every consumer of `stems`
|
||||
treats that list as layers to sum or to show as mixer channels, and `full` is
|
||||
neither (spec §5.3). Leaving it in is precisely the bug that made the packer
|
||||
invent `original_audio` in the first place: a listed full mix plays on top of
|
||||
the stems.
|
||||
|
||||
A pack whose only stem is `full` is a single-mix pack, not a separated one:
|
||||
there are no instruments to be pristine *against*, so `full` stays the sole
|
||||
playable stem and no mixdown is surfaced. That keeps the freshly-converted
|
||||
single-stem pack — much the most common shape — behaving exactly as before.
|
||||
|
||||
EVERY entry with the reserved id is removed, not just the one we surface. A
|
||||
malformed pack that lists `full` twice would otherwise leave a copy of the
|
||||
whole song behind in the stem list, to be summed with the instruments — the
|
||||
precise failure this function exists to prevent, reintroduced by a duplicate.
|
||||
"""
|
||||
if len(stems) < 2:
|
||||
return None, stems
|
||||
full = find_full_mix(stems)
|
||||
if full is None:
|
||||
return None, stems
|
||||
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
|
||||
|
||||
|
||||
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
|
||||
|
||||
Before feedpak 1.15.0 reserved `full`, §5.3 said the mixdown was "commonly
|
||||
replaced" by the per-instrument stems on splitting — so it had nowhere to
|
||||
live, and this repo invented a top-level key pointing at a parallel
|
||||
`original/` directory (#583) to hold it. That key was never in the spec, and
|
||||
#933 removed our dependence on it: the mixdown is a stem.
|
||||
|
||||
We still READ it, because every pack written before the spec caught up
|
||||
carries `original_audio: original/full.ogg` and would otherwise lose its full
|
||||
mix. We never write it. Delete this once those packs are migrated (#945);
|
||||
`tools/migrate_full_mix_stem.py` is the migration.
|
||||
|
||||
NOTE the string literal below. tools/check_spec_conformance.py AST-scans for
|
||||
`manifest.get("<literal>")` to prove every manifest key core reads is one the
|
||||
spec declares. Hoisting "original_audio" into a named constant would hide
|
||||
this read from that scan — the gate would conclude core no longer touches the
|
||||
key, and the grandfather entry that documents this debt would go stale. The
|
||||
literal is what keeps the deprecation honest and visible to CI. Leave it.
|
||||
|
||||
Same permissive, path-traversal-guarded posture as the optional side-files: a
|
||||
missing / escaping / unreadable file leaves the pack without a full mix (the
|
||||
player falls back to the separated stems) rather than aborting the load.
|
||||
Returns the manifest-relative string, so callers build its URL exactly as
|
||||
they build a stem's.
|
||||
"""
|
||||
rel_raw = manifest.get("original_audio")
|
||||
if not isinstance(rel_raw, str) or not rel_raw.strip():
|
||||
return None
|
||||
rel = rel_raw.strip()
|
||||
try:
|
||||
target = (source_dir / rel).resolve()
|
||||
target.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not target.is_file():
|
||||
return None
|
||||
log.info(
|
||||
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
|
||||
"is a stem (id `full`, feedpak spec §5.3). Re-pack with "
|
||||
"tools/migrate_full_mix_stem.py; support for this key will be removed.",
|
||||
rel,
|
||||
)
|
||||
return rel
|
||||
|
||||
|
||||
# ── Format detection ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_sloppak(path: Path) -> bool:
|
||||
@@ -81,6 +188,116 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
|
||||
_unpack_locks: dict[str, threading.Lock] = {}
|
||||
_unpack_locks_guard = threading.Lock()
|
||||
|
||||
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
|
||||
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
|
||||
# half-written directory and leave that resolver caching an incomplete song.
|
||||
_unpacking: set[Path] = set()
|
||||
_unpacking_guard = threading.Lock()
|
||||
|
||||
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
|
||||
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
|
||||
# every song it holds, and it used to grow without any bound at all. A tester
|
||||
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
|
||||
# one caller looped the library calling load_song(). Nothing ever deleted any of
|
||||
# it — not even when the song itself was deleted.
|
||||
#
|
||||
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
|
||||
# song I'm playing, and the last few I played" that this cache actually exists
|
||||
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
|
||||
def _unpack_cache_cap_bytes() -> int:
|
||||
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
|
||||
try:
|
||||
mb = int(raw) if raw else 4096
|
||||
except ValueError:
|
||||
mb = 4096
|
||||
return max(0, mb) * 1024 * 1024
|
||||
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
total = 0
|
||||
for f in path.rglob("*"):
|
||||
try:
|
||||
if f.is_file():
|
||||
total += f.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _touch(path: Path) -> None:
|
||||
"""Bump mtime so the LRU sweep below treats this song as recently used.
|
||||
|
||||
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
|
||||
so without this the song you are actively playing looks as stale as one you
|
||||
unpacked days ago — and a burst of unpacks could evict it mid-song.
|
||||
"""
|
||||
try:
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
|
||||
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
|
||||
|
||||
`keep` is never evicted — it's the song the caller just resolved, i.e. almost
|
||||
certainly the one about to be played.
|
||||
|
||||
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
|
||||
get_cached_source_dir() keeps handing out a path that no longer exists and
|
||||
the media route 404s on every stem instead of re-unpacking (it only falls
|
||||
back to resolve_source_dir when the cache returns None).
|
||||
"""
|
||||
cap = _unpack_cache_cap_bytes()
|
||||
if cap <= 0:
|
||||
return
|
||||
try:
|
||||
entries = []
|
||||
total = 0
|
||||
for d in root.iterdir():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
try:
|
||||
size = _dir_size(d)
|
||||
mtime = d.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
entries.append((mtime, size, d))
|
||||
total += size
|
||||
if total <= cap:
|
||||
return
|
||||
|
||||
keep_resolved = keep.resolve() if keep else None
|
||||
entries.sort(key=lambda e: e[0]) # oldest first
|
||||
for _mtime, size, d in entries:
|
||||
if total <= cap:
|
||||
break
|
||||
try:
|
||||
if keep_resolved and d.resolve() == keep_resolved:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Check-and-delete under ONE hold of the guard. Releasing between the
|
||||
# two would let a resolver mark this dest in-flight and start writing
|
||||
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
|
||||
# that blocks here simply proceeds afterwards — _unpack_zip recreates
|
||||
# the directory anyway.
|
||||
with _unpacking_guard:
|
||||
if d in _unpacking:
|
||||
continue # another thread is writing this
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
if d.exists():
|
||||
continue # couldn't remove — don't claim the bytes back
|
||||
total -= size
|
||||
with _source_lock:
|
||||
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
|
||||
if cached_dir == d:
|
||||
_source_cache.pop(fn, None)
|
||||
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
|
||||
d.name, size / 1e6)
|
||||
except OSError:
|
||||
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
|
||||
|
||||
|
||||
def _unpack_lock_for(filename: str) -> threading.Lock:
|
||||
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
||||
@@ -145,10 +362,17 @@ def resolve_source_dir(
|
||||
re-unpacks if mtime/size changed, then returns that dir.
|
||||
|
||||
Caches the resolution so subsequent calls are ~free.
|
||||
|
||||
NOTE: this writes the WHOLE pack — every stem — to disk. Only call it for a
|
||||
song you are about to play. To read a *part* of a song (an arrangement, the
|
||||
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
|
||||
KB of JSON is ~45x write amplification, and doing it in a loop over the
|
||||
library fills the disk with a decompressed copy of every song.
|
||||
"""
|
||||
path = dlc_root / filename
|
||||
stat = path.stat()
|
||||
mtime, size = stat.st_mtime, stat.st_size
|
||||
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
|
||||
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
@@ -159,42 +383,76 @@ def resolve_source_dir(
|
||||
and cached_size == size
|
||||
and cached_dir.exists()
|
||||
):
|
||||
# Mark it recently-used before returning — see _touch().
|
||||
if cached_dir != path:
|
||||
_touch(cached_dir)
|
||||
return cached_dir
|
||||
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
try:
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
# Shield `dest` from eviction from the moment we start writing
|
||||
# until it is safely in _source_cache. `keep` only shields it
|
||||
# from OUR OWN sweep — a concurrent resolver sweeping with a
|
||||
# different `keep` would delete it, and we would then cache and
|
||||
# return a path that no longer exists. The `finally` below
|
||||
# releases it on EVERY exit, including a failed unpack: leaving
|
||||
# a dest marked in-flight would make it un-evictable forever.
|
||||
with _unpacking_guard:
|
||||
_unpacking.add(dest)
|
||||
guarded = dest
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
# The only moment this cache grows. Sweep here rather than on a
|
||||
# timer so it can never drift far past the cap.
|
||||
_evict_unpack_cache(unpack_cache_root, keep=dest)
|
||||
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
finally:
|
||||
if guarded is not None:
|
||||
with _unpacking_guard:
|
||||
_unpacking.discard(guarded)
|
||||
|
||||
|
||||
def get_cached_source_dir(filename: str) -> Path | None:
|
||||
"""Return the cached source dir for a sloppak if one is known."""
|
||||
"""Return the cached source dir for a sloppak if one is known AND still there.
|
||||
|
||||
The existence check is load-bearing: callers (media.py) only fall back to
|
||||
resolve_source_dir() when this returns None, so handing back a path that has
|
||||
been evicted — or that the user deleted by hand to reclaim disk — would 404
|
||||
every stem for the rest of the process instead of re-unpacking.
|
||||
"""
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
return cached[0] if cached else None
|
||||
if not cached:
|
||||
return None
|
||||
src = cached[0]
|
||||
if not src.is_dir():
|
||||
_source_cache.pop(filename, None)
|
||||
return None
|
||||
_touch(src)
|
||||
return src
|
||||
|
||||
|
||||
# ── Manifest + song loading ───────────────────────────────────────────────────
|
||||
@@ -233,6 +491,82 @@ def load_manifest(path: Path) -> dict:
|
||||
return _read_manifest_from_zip(path)
|
||||
|
||||
|
||||
_ZIP_ROOT = Path("/_root").resolve()
|
||||
|
||||
|
||||
def _zip_member_key(name: str) -> str | None:
|
||||
"""Canonical lookup key for a zip member name, or None if it escapes the root.
|
||||
|
||||
Collapses './', 'a/../b' and backslash separators — the same normalization
|
||||
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
|
||||
for AND the names the archive actually stores must go through this, or a pack
|
||||
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
|
||||
"""
|
||||
safe = safe_join(_ZIP_ROOT, name or "")
|
||||
# None → escapes the root; == root → a degenerate name like "." or "a/..".
|
||||
if safe is None or safe == _ZIP_ROOT:
|
||||
return None
|
||||
return safe.relative_to(_ZIP_ROOT).as_posix()
|
||||
|
||||
|
||||
def read_member_bytes(path: Path, rel: str) -> bytes | None:
|
||||
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
|
||||
|
||||
For a zipped sloppak this opens that single member instead of unpacking the
|
||||
archive — the same trick read_cover_bytes() uses to keep the library grid
|
||||
from exploding every pack just to show a cover.
|
||||
|
||||
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
|
||||
the lyrics, a tone blob) rather than a song you're about to play. The
|
||||
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
|
||||
pack — every stem — into the unpack cache. That is a ~45x write amplification
|
||||
when all you wanted was a few KB of JSON, and looping the library on it
|
||||
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
|
||||
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
|
||||
zip: the cache becomes a second, decompressed copy of everything it touches.
|
||||
"""
|
||||
rel = (rel or "").strip()
|
||||
if not rel:
|
||||
return None
|
||||
|
||||
if path.is_dir():
|
||||
target = safe_join(path.resolve(), rel)
|
||||
if target is None or not target.is_file():
|
||||
return None
|
||||
try:
|
||||
return target.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
|
||||
# open anything, and both sides of the comparison are normalized, so a
|
||||
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
|
||||
# way it did when we unpacked first.
|
||||
member = _zip_member_key(rel)
|
||||
if member is None:
|
||||
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(str(path), "r") as zf:
|
||||
# Match on the NORMALIZED stored name, and take the LAST match — the
|
||||
# archive may store './x' or a backslash path (Windows tooling), and
|
||||
# if it stores two names that normalize to the same file, _unpack_zip
|
||||
# writes them in order so the last one wins. Reading the raw member by
|
||||
# exact name would miss the first case and return the wrong bytes in
|
||||
# the second. A pack has a handful of members; the scan is free.
|
||||
info = None
|
||||
for cand in zf.infolist():
|
||||
if _zip_member_key(cand.filename) == member:
|
||||
info = cand
|
||||
if info is None or info.is_dir():
|
||||
return None
|
||||
with zf.open(info) as f:
|
||||
return f.read()
|
||||
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
||||
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
|
||||
return None
|
||||
|
||||
|
||||
_COVER_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
@@ -367,14 +701,21 @@ class LoadedSloppak:
|
||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||
# absent so indexing by song.arrangements index is safe.
|
||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||
# Manifest-relative path to the single full-mix audio file, taken from the
|
||||
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
|
||||
# pre-separation mixdown that exists alongside the per-instrument `stems`.
|
||||
# None when the key is absent, points outside source_dir, or the file is
|
||||
# missing on disk. Served to the front-end via the highway WS as
|
||||
# `original_audio_url`; the stems plugin uses it to play the untouched mix
|
||||
# when every stem slider is at unity (and the separate stems otherwise).
|
||||
original_audio: str | None = None
|
||||
# Manifest-relative path to the pack's complete mixdown — the whole song in
|
||||
# one file, as heard before source separation. This is the RESERVED `full`
|
||||
# stem (spec §5.3), lifted out of `stems` above precisely because it is NOT
|
||||
# an instrument layer: summing it with the per-instrument stems it was split
|
||||
# into would double the entire song. See partition_stems().
|
||||
#
|
||||
# None when the pack has no mixdown to offer *separately* from its stems —
|
||||
# which includes the common single-mix pack, whose only stem IS the mixdown
|
||||
# (there is nothing to be pristine against, so it stays in `stems`).
|
||||
#
|
||||
# Served to the front-end via the highway WS as `full_mix_url`; the stems
|
||||
# plugin plays it while every stem slider sits at unity and crosses to the
|
||||
# separated stems the moment one drops below 100% — demucs recombination is
|
||||
# lossy, so the mixdown is strictly the better audio when nothing is muted.
|
||||
full_mix: str | None = None
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -766,6 +1107,13 @@ def load_song(
|
||||
default_on = bool(default_val)
|
||||
stems.append({"id": sid, "file": sfile, "default": default_on})
|
||||
|
||||
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
|
||||
# it out so that no consumer of `stems` — the mixer, the library's stem
|
||||
# chips, the WS payload — sums it with, or lists it beside, the instruments
|
||||
# it was separated into. `full_mix_stem` is None for a single-mix pack,
|
||||
# whose only stem IS the mixdown and stays in the list.
|
||||
full_mix_stem, stems = partition_stems(stems)
|
||||
|
||||
# Optional keys.json — song-level, instrument-independent key/scale track
|
||||
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
|
||||
# missing / unreadable / malformed -> None, never fatal. Stored as a
|
||||
@@ -828,28 +1176,22 @@ def load_song(
|
||||
}
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# Optional full-mix audio — manifest `original_audio:` key. The single
|
||||
# pre-separation mixdown that ships alongside the per-instrument stems.
|
||||
# Same permissive, path-traversal-guarded posture as drum_tab above: a
|
||||
# missing/escaping/absent file simply leaves the full mix unavailable (the
|
||||
# player falls back to the separate stems) rather than aborting the load.
|
||||
# We store the manifest-relative string so server.py can build its URL the
|
||||
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
|
||||
original_audio_data: str | None = None
|
||||
original_audio_rel = manifest.get("original_audio")
|
||||
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
|
||||
rel = original_audio_rel.strip()
|
||||
try:
|
||||
oa_path = (source_dir / rel).resolve()
|
||||
oa_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
oa_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
oa_path = None
|
||||
if oa_path is not None and oa_path.is_file():
|
||||
original_audio_data = rel
|
||||
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
||||
# above (spec §5.3) — no path work needed, it was validated with the other
|
||||
# stems and its URL is built the same way. Only when the pack has no `full`
|
||||
# stem do we fall back to the DEPRECATED `original_audio:` key, which is the
|
||||
# shape every pack written before feedpak 1.15.0 uses.
|
||||
if full_mix_stem is not None:
|
||||
full_mix_data: str | None = full_mix_stem["file"]
|
||||
elif find_full_mix(stems) is not None:
|
||||
# Single-mix pack: its ONE stem is the mixdown, so there is no mixdown to
|
||||
# offer *apart from* the stems. Never fall through to the legacy key here
|
||||
# — a pack that both carries a `full` stem and names the old key would
|
||||
# otherwise surface the mixdown twice (once as the stem the player is
|
||||
# already playing, once as a "pristine" track to cross to).
|
||||
full_mix_data = None
|
||||
else:
|
||||
full_mix_data = _legacy_full_mix(manifest, source_dir)
|
||||
|
||||
return LoadedSloppak(
|
||||
song=song,
|
||||
@@ -864,7 +1206,7 @@ def load_song(
|
||||
keys=keys_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
original_audio=original_audio_data,
|
||||
full_mix=full_mix_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -909,7 +1251,7 @@ def extract_meta(path: Path) -> dict:
|
||||
tuning_offsets = _tuning_for_meta(arr_list)
|
||||
|
||||
stems_list = manifest.get("stems", []) or []
|
||||
stem_ids: list[str] = []
|
||||
valid_stems: list[dict] = []
|
||||
for s in stems_list:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
@@ -923,7 +1265,13 @@ def extract_meta(path: Path) -> dict:
|
||||
isinstance(sid, str) and sid
|
||||
and isinstance(sfile, str) and sfile
|
||||
):
|
||||
stem_ids.append(sid)
|
||||
valid_stems.append({"id": sid, "file": sfile})
|
||||
# Partition exactly as load_song() does, for the same reason the library
|
||||
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
|
||||
# A separated pack that retains it would otherwise offer the user a "full"
|
||||
# stem chip alongside guitar/bass/drums and count it as a seventh stem.
|
||||
_full, instrument_stems = partition_stems(valid_stems)
|
||||
stem_ids = [s["id"] for s in instrument_stems]
|
||||
stem_count = len(stem_ids)
|
||||
|
||||
return {
|
||||
|
||||
+89
-3
@@ -1,4 +1,4 @@
|
||||
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
|
||||
"""Regenerate the runtime stylesheet over the full installed-plugin set.
|
||||
|
||||
Core's committed (and image-baked) stylesheet is built scanning only the
|
||||
in-tree plugins. A plugin installed at runtime — into ``FEEDBACK_PLUGINS_DIR``
|
||||
@@ -15,6 +15,7 @@ on a missing optional engine.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -40,12 +41,84 @@ _lock = threading.Lock()
|
||||
# in-flight build re-runs once more to pick up the newer plugin set instead of
|
||||
# every concurrent trigger stacking its own redundant build.
|
||||
_rerun = threading.Event()
|
||||
_fingerprint_cache: dict = {}
|
||||
|
||||
# lib/ lives at ``<app>/lib``; the app root (static/, tailwind.config.js) is its
|
||||
# grandparent.
|
||||
APP_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _committed_css_fingerprint() -> str:
|
||||
"""Content hash of the SHIPPED stylesheet, cached on (mtime, size).
|
||||
|
||||
This is the marker that says WHICH CORE the runtime sheet was built against. Any change to
|
||||
core's CSS regenerates static/tailwind.min.css, which changes this hash.
|
||||
"""
|
||||
committed = APP_DIR / "static" / "tailwind.min.css"
|
||||
try:
|
||||
st = committed.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
key = (st.st_mtime_ns, st.st_size)
|
||||
cached = _fingerprint_cache.get("k")
|
||||
if cached == key:
|
||||
return _fingerprint_cache["v"]
|
||||
h = hashlib.sha256(committed.read_bytes()).hexdigest()
|
||||
_fingerprint_cache["k"] = key
|
||||
_fingerprint_cache["v"] = h
|
||||
return h
|
||||
|
||||
|
||||
def runtime_meta_path() -> Path:
|
||||
"""Sidecar recording which core the runtime sheet was built against."""
|
||||
return runtime_css_path().with_suffix(".meta.json")
|
||||
|
||||
|
||||
def runtime_css_is_current() -> bool:
|
||||
"""True when the runtime sheet was built against the core we are running NOW.
|
||||
|
||||
WHY NOT mtime. Codex [P2] on the second cut of #911, and it was right: filesystem
|
||||
timestamps are not a freshness signal across install methods. Archives and container images
|
||||
routinely PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER mtime than
|
||||
a runtime sheet a user built days ago. The mtime comparison then reports the stale sheet as
|
||||
fresh and it masks the new core CSS indefinitely — permanently, if no Tailwind toolchain is
|
||||
present to trigger a rebuild.
|
||||
|
||||
Content answers the question timestamps only gesture at: the sidecar records the hash of the
|
||||
committed sheet this runtime build was made from. Core ships new CSS -> that file changes ->
|
||||
the hash changes -> the runtime sheet is correctly judged stale.
|
||||
"""
|
||||
try:
|
||||
meta = json.loads(runtime_meta_path().read_text())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return bool(meta.get("committed_sha256")) and meta["committed_sha256"] == _committed_css_fingerprint()
|
||||
|
||||
|
||||
def runtime_css_path() -> Path:
|
||||
"""Where the RUNTIME-augmented stylesheet is written.
|
||||
|
||||
NOT ``static/tailwind.min.css``. That file is a BUILD ARTEFACT: committed, image-baked,
|
||||
and generated by scanning only the in-tree plugins. This one is PER-INSTALL STATE — it
|
||||
additionally scans whatever the user has installed into FEEDBACK_PLUGINS_DIR, so it differs
|
||||
from machine to machine. They are different things and must not share a path.
|
||||
|
||||
Writing the runtime sheet over the committed one had two costs:
|
||||
|
||||
* IN A GIT CHECKOUT it silently modifies a TRACKED file. `git add -A` then sweeps a
|
||||
100KB reshuffle of minified CSS into the commit and `ci/tailwind-fresh` goes red with a
|
||||
diff that explains nothing. That is issue #911, and it cost a red run on a PR whose
|
||||
real diff touched no Tailwind classes at all.
|
||||
* IN A DEPLOY the app directory may be read-only. Writing app state into it is wrong on
|
||||
principle and fatal in practice.
|
||||
|
||||
CONFIG_DIR is where per-install state already lives.
|
||||
"""
|
||||
cfg = (getenv_compat("CONFIG_DIR", "") or "").strip()
|
||||
base = Path(cfg) if cfg else (Path.home() / ".local" / "share" / "feedback")
|
||||
return base / "tailwind.min.css"
|
||||
|
||||
|
||||
def _user_plugins_dir() -> Path | None:
|
||||
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
|
||||
if not raw:
|
||||
@@ -136,6 +209,14 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
||||
cwd=str(APP_DIR), timeout=120,
|
||||
)
|
||||
os.replace(staged, out)
|
||||
# Stamp WHICH CORE this was built against. Without it, an upgraded app cannot tell a
|
||||
# current runtime sheet from one that predates its new CSS.
|
||||
try:
|
||||
runtime_meta_path().write_text(json.dumps({
|
||||
"committed_sha256": _committed_css_fingerprint(),
|
||||
}))
|
||||
except OSError:
|
||||
log.warning("tailwind: could not write the runtime sheet's meta sidecar")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
stderr = (getattr(e, "stderr", "") or "")[-500:]
|
||||
@@ -153,7 +234,7 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
||||
|
||||
|
||||
def rebuild(reason: str = "") -> bool:
|
||||
"""Regenerate ``static/tailwind.min.css`` over baked-in + user plugins.
|
||||
"""Regenerate the RUNTIME stylesheet (see runtime_css_path) over baked-in + user plugins.
|
||||
|
||||
Returns ``True`` on a successful rebuild, ``False`` on any skip/failure.
|
||||
Never raises — callers treat CSS freshness as best-effort. Concurrent
|
||||
@@ -166,8 +247,13 @@ def rebuild(reason: str = "") -> bool:
|
||||
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
|
||||
return False
|
||||
|
||||
out = APP_DIR / "static" / "tailwind.min.css"
|
||||
out = runtime_css_path()
|
||||
src = APP_DIR / "static" / "_tailwind.src.css"
|
||||
try:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
log.warning("tailwind rebuild skipped — cannot create %s%s", out.parent, tag)
|
||||
return False
|
||||
|
||||
# If a rebuild is already running, flag a rerun and return instead of
|
||||
# queueing a redundant build behind it.
|
||||
|
||||
+4
-2
@@ -101,14 +101,16 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
||||
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
||||
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
||||
non-numeric or non-positive (a provider could hand us anything)."""
|
||||
non-numeric, non-finite, or non-positive (a provider could hand us
|
||||
anything; NaN/Infinity would otherwise raise inside int(round(...)) and
|
||||
500 the /api/tunings endpoint)."""
|
||||
out: list[int] = []
|
||||
for f in freqs:
|
||||
try:
|
||||
f = float(f)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f <= 0:
|
||||
if not math.isfinite(f) or f <= 0:
|
||||
return None
|
||||
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||
return out
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -2384,6 +2385,53 @@ def register_plugin_api(app: FastAPI):
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
# ── Module-graph cache busting (#879) ────────────────────────────────
|
||||
#
|
||||
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||
# <script type="module"> whose src the module map has already seen fires
|
||||
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
|
||||
# and (see below) an upgrade too — silently kept the OLD module live while
|
||||
# the loader recorded success: a no-op that reported it worked.
|
||||
#
|
||||
# Busting the ENTRY url does not help. A module plugin's screen.js is a
|
||||
# one-line `import './src/main.js'`, and a relative specifier resolves
|
||||
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
|
||||
# the graph. Driving a real browser through install -> upgrade -> rollback and
|
||||
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
|
||||
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
|
||||
# same URL; the module map returns the already-evaluated old module.
|
||||
#
|
||||
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
|
||||
# relative import inherits it at every depth — for free, with no
|
||||
# import-specifier rewriting (which could never see `import(expr)` anyway).
|
||||
#
|
||||
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
|
||||
# URL, so EVERYTHING a module resolves relatively moves with it — not just
|
||||
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
|
||||
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
|
||||
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
|
||||
# worklet and wasm file the graph reaches — and would silently break again the
|
||||
# next time someone adds a plugin route. Stripping the segment before routing
|
||||
# makes every plugin route, present and future, work under the prefix.
|
||||
#
|
||||
# The token is opaque: it is never joined into a filesystem path (and is gone
|
||||
# by the time any handler runs), so containment still rests entirely on the
|
||||
# same safe_join the un-prefixed routes use.
|
||||
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
|
||||
|
||||
@app.middleware("http")
|
||||
async def _strip_plugin_generation_prefix(request: Request, call_next):
|
||||
m = _GEN_PREFIX.match(request.scope.get("path", ""))
|
||||
if m:
|
||||
# Starlette routes on scope["path"] alone. raw_path is deliberately left
|
||||
# ALONE: it is informational, and re-encoding the rewritten str back to
|
||||
# bytes would have to guess a codec — `.encode("latin-1")` raises
|
||||
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
|
||||
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
|
||||
# the client actually sent it is also simply more truthful for logs.
|
||||
request.scope["path"] = m.group(1) + m.group(2)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
def plugin_settings_html(plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
|
||||
@@ -0,0 +1,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,8 @@
|
||||
{
|
||||
"venue": "arena",
|
||||
"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": "arena-ambience.mp3"},
|
||||
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
|
||||
}
|
||||
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.
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.
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,8 @@
|
||||
{
|
||||
"venue": "club",
|
||||
"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": "club-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).
|
||||
|
||||
+215
-5802
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;
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -20,7 +20,7 @@
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { host } from './host.js';
|
||||
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.js';
|
||||
import { loopA, loopB, setLoop } from './loops.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
@@ -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
|
||||
@@ -182,7 +182,7 @@ export async function startCountIn(opts = {}) {
|
||||
const gen = _countInGen;
|
||||
const immediate = !!opts.immediate;
|
||||
if (window._juceMode) {
|
||||
await host.jucePlayer().pause().catch((err) => console.error('[app] host.jucePlayer().pause error in count-in:', err));
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -225,7 +225,7 @@ export async function startCountIn(opts = {}) {
|
||||
// Rewind done — set final position and start count.
|
||||
// Await the JUCE seek so the engine has repositioned before
|
||||
// we start the click track (HTML5 path is synchronous).
|
||||
host._audioSeek(loopA, 'loop-wrap').then((r) => {
|
||||
_audioSeek(loopA, 'loop-wrap').then((r) => {
|
||||
if (gen !== _countInGen) return; // teardown during seek
|
||||
// Abort the loop restart in two cases:
|
||||
// 1. Cancelled (player torn down): don't beginCount on a
|
||||
@@ -247,10 +247,10 @@ export async function startCountIn(opts = {}) {
|
||||
_countingIn = false;
|
||||
if (S.isPlaying) {
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', host._songEventPayload());
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -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;
|
||||
|
||||
@@ -282,21 +282,21 @@ export async function startCountIn(opts = {}) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
if (window._juceMode) {
|
||||
host.jucePlayer().play().then((started) => {
|
||||
jucePlayer.play().then((started) => {
|
||||
if (gen !== _countInGen) return; // teardown during play start
|
||||
if (!started) return;
|
||||
S.isPlaying = true;
|
||||
host.setPlayButtonState(true);
|
||||
setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = host._songEventPayload();
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}).catch((err) => console.error('[app] host.jucePlayer().play error:', err));
|
||||
}).catch((err) => console.error('[app] jucePlayer.play error:', err));
|
||||
} else {
|
||||
audio.play().then(() => {
|
||||
if (gen !== _countInGen) return;
|
||||
S.isPlaying = true;
|
||||
host.setPlayButtonState(true);
|
||||
setPlayButtonState(true);
|
||||
}).catch((err) => {
|
||||
if (gen !== _countInGen) return;
|
||||
// An engine reroute's deliberate pause aborts this play()
|
||||
@@ -307,7 +307,7 @@ export async function startCountIn(opts = {}) {
|
||||
// started if the Promise rejected.
|
||||
console.error('[app] audio.play() rejected after count-in:', err);
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
setPlayButtonState(false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -333,13 +333,13 @@ export async function startSongCountIn() {
|
||||
// bumps it and every delayed callback below bails.
|
||||
const gen = _countInGen;
|
||||
if (window._juceMode) {
|
||||
await host.jucePlayer().pause().catch((err) => console.error('[app] host.jucePlayer().pause error in song count-in:', err));
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
const startT = S.lastAudioTime || 0;
|
||||
let bpm = highway.getBPM(startT);
|
||||
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;
|
||||
@@ -352,7 +352,7 @@ export async function startSongCountIn() {
|
||||
_countingIn = false;
|
||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||
// updates the button, and emits song:play/resume for plugins.
|
||||
Promise.resolve(host.togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
|
||||
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Display formatters. A LEAF module: imports nothing.
|
||||
//
|
||||
// WHY THIS EXISTS FOR ONE FUNCTION. formatTime was a HOST HOOK — loops.js and
|
||||
// section-practice.js both reached back through the seam for it. It was also, by pure
|
||||
// accident of who calls it, inside the dependency closure of the library carve. Leaving
|
||||
// it there would have made loops.js and section-practice.js import the LIBRARY to format
|
||||
// a timestamp, which is nonsense, and a cycle waiting to happen.
|
||||
//
|
||||
// A hook is a cycle you agreed to live with. This one has a real owner — it just isn't
|
||||
// app.js, and it certainly isn't the library. Give it a home of its own and both
|
||||
// consumers import it directly.
|
||||
//
|
||||
// It is a leaf on purpose. Anything else that turns out to be a shared pure formatter
|
||||
// belongs here too; nothing does yet, so nothing else is here.
|
||||
|
||||
/** Seconds -> `M:SS`. */
|
||||
export function formatTime(s) { return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; }
|
||||
@@ -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();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
// Shared, MUTABLE library state.
|
||||
//
|
||||
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is READ-ONLY:
|
||||
// `import { _treeStats }; _treeStats = x` throws. Of the library module's 28 outward
|
||||
// bindings, 23 are only ever READ from outside, so they stay plain exports. These five
|
||||
// are genuinely WRITTEN from outside — by showScreen (session teardown bumps the epoch,
|
||||
// resets the page), deleteSongFromModal, and syncLibrarySong, none of which can move into
|
||||
// the library module because they reach the playSong/showScreen core.
|
||||
//
|
||||
// So exactly these five move onto an object, and no more. `L.treeStats = x` is a property
|
||||
// write, which works from any module holding the same `L`. Same shape as ./player-state.js.
|
||||
//
|
||||
// Add to it when a carve actually needs it, not before — a container is a shared mutable
|
||||
// global with better manners, and every field on it is a coupling you have to keep true.
|
||||
export const L = {
|
||||
/** Library tree stats (artist -> counts), cached from /api/library/tree-stats. */
|
||||
treeStats: null,
|
||||
/** Same, for the favourites tree. */
|
||||
favTreeStats: null,
|
||||
/** Tuning names, cached from /api/library/tuning-names. */
|
||||
tuningNames: null,
|
||||
/**
|
||||
* Session generation for the library. Bumped on teardown so an in-flight page fetch
|
||||
* that resolves against a stale library can't render into the new one.
|
||||
*/
|
||||
libEpoch: 0,
|
||||
/** Current grid page (0-based). */
|
||||
currentPage: 0,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+9
-7
@@ -19,6 +19,8 @@
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { esc, uiPrompt } from './dom.js';
|
||||
import { _audioSeek, _audioTime } from './transport.js';
|
||||
import { formatTime } from './format.js';
|
||||
import { host } from './host.js';
|
||||
import {
|
||||
_setSectionPracticeMode,
|
||||
@@ -39,14 +41,14 @@ export let loopB = null;
|
||||
export let _loopMutationGen = 0;
|
||||
|
||||
export function setLoopStart() {
|
||||
loopA = host._audioTime();
|
||||
loopA = _audioTime();
|
||||
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
}
|
||||
|
||||
export function setLoopEnd() {
|
||||
if (loopA === null) return;
|
||||
loopB = host._audioTime();
|
||||
loopB = _audioTime();
|
||||
if (loopB <= loopA) { loopB = null; return; }
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
@@ -72,7 +74,7 @@ export function clearLoop(options) {
|
||||
document.getElementById('loop-label').textContent = '';
|
||||
document.getElementById('saved-loops').value = '';
|
||||
resetSelection();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
|
||||
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
|
||||
requesterId: 'core.loop',
|
||||
@@ -125,7 +127,7 @@ export async function setLoop(a, b, options) {
|
||||
// Don't arm loopA/loopB before the seek lands — the 60Hz tick's wrap
|
||||
// detector (`ct >= loopB`) would trigger startCountIn against
|
||||
// half-applied state.
|
||||
const r = await host._audioSeek(aNum, 'loop-set');
|
||||
const r = await _audioSeek(aNum, 'loop-set');
|
||||
if (!r.completed || Math.abs(r.to - aNum) > 0.05) return false;
|
||||
// Caller-owned staleness gate, re-checked after the awaited seek and before
|
||||
// we commit loopA/loopB. practiceSection() passes this so a superseded retry
|
||||
@@ -166,11 +168,11 @@ export function updateLoopUI() {
|
||||
const label = document.getElementById('loop-label');
|
||||
const hasLoop = loopA !== null && loopB !== null;
|
||||
if (hasLoop) {
|
||||
label.textContent = `${host.formatTime(loopA)} → ${host.formatTime(loopB)}`;
|
||||
label.textContent = `${formatTime(loopA)} → ${formatTime(loopB)}`;
|
||||
document.getElementById('btn-loop-clear').classList.remove('hidden');
|
||||
document.getElementById('btn-loop-save').classList.remove('hidden');
|
||||
} else if (loopA !== null) {
|
||||
label.textContent = `${host.formatTime(loopA)} → ?`;
|
||||
label.textContent = `${formatTime(loopA)} → ?`;
|
||||
document.getElementById('btn-loop-clear').classList.add('hidden');
|
||||
document.getElementById('btn-loop-save').classList.add('hidden');
|
||||
} else {
|
||||
@@ -189,7 +191,7 @@ export async function loadSavedLoops() {
|
||||
|
||||
sel.innerHTML = '<option value="">Saved Loops</option>';
|
||||
for (const l of loops) {
|
||||
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${host.formatTime(l.start)}→${host.formatTime(l.end)})</option>`;
|
||||
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${formatTime(l.start)}→${formatTime(l.end)})</option>`;
|
||||
}
|
||||
if (loops.length > 0) {
|
||||
sel.classList.remove('hidden');
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Player controls — the speed and mastery sliders, and the four playback preference
|
||||
// reads (autoplay-exit, up-next, countdown-before-song, confirm-exit).
|
||||
//
|
||||
// The fourth slice out of app.js's strongly-connected core, and by far the easiest:
|
||||
// ONE hook and NO shared mutable state. It is here because these three groups are the
|
||||
// same surface (the controls under the highway) and all three reach the same helper.
|
||||
//
|
||||
// The preference reads are one-line localStorage lookups that half of app.js consults
|
||||
// before deciding whether to auto-start, show the Up Next pill, run a count-in, or
|
||||
// confirm on exit. They travel with the controls that set them.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { host } from './host.js';
|
||||
|
||||
// ── Autoplay & auto-exit (global option, default ON) ──────────────────
|
||||
// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song
|
||||
// once it's ready and (b) returns to the launching menu when the song
|
||||
// ends. Absence of the key means enabled. The behaviour lives in core
|
||||
// (app.js, shared by the v3 + classic UIs); the end-of-song *score*
|
||||
// screen, when present, is a plugin and hooks the contract below.
|
||||
export function _autoplayExitEnabled() {
|
||||
try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
// ── "Up Next" pill (global option, default ON) ────────────────────────
|
||||
// Gates the v3 player chrome's persistent upcoming-section pill
|
||||
// (#v3-upnext, driven by player-chrome.js's updateUpNext). Client-only
|
||||
// localStorage pref (`showUpNext`); absence of the key means enabled.
|
||||
// player-chrome.js reads window.feedBack.showUpNext each tick and hides
|
||||
// the pill when off.
|
||||
export function _showUpNextEnabled() {
|
||||
try { return localStorage.getItem('showUpNext') !== '0'; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
// "Countdown before song" (Gameplay tab). Mirrored to localStorage by
|
||||
// loadSettings so the song-start path can read it synchronously here — no
|
||||
// async /api/settings fetch on the play hot path. Defaults off.
|
||||
export function _countdownBeforeSongEnabled() {
|
||||
try { return localStorage.getItem('countdownBeforeSong') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
export function _curPlaybackSpeed() {
|
||||
try {
|
||||
return window._juceMode
|
||||
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
|
||||
: (document.getElementById('audio')?.playbackRate || 1);
|
||||
} catch (_) { return 1; }
|
||||
}
|
||||
|
||||
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
||||
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
||||
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
||||
// of leaving immediately. Auto-exit on song-end and a results screen's own
|
||||
// Close never prompt — they call closeCurrentSong() directly, which stays the
|
||||
// unguarded actual-exit.
|
||||
export function _exitConfirmEnabled() {
|
||||
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
|
||||
const SPEED_SNAP_THRESHOLD = 0.02;
|
||||
let _speedPresetsWired = false;
|
||||
|
||||
function _speedPresetPctFromActive(activePctOrRate) {
|
||||
if (!Number.isFinite(activePctOrRate)) return null;
|
||||
const rate = activePctOrRate <= 1.5 ? activePctOrRate : activePctOrRate / 100;
|
||||
for (const pct of SPEED_PRESET_PCTS) {
|
||||
if (Math.abs(rate - pct / 100) <= SPEED_SNAP_THRESHOLD) return pct;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _updateSpeedPresetButtons(activePctOrRate) {
|
||||
const wrap = document.getElementById('speed-presets');
|
||||
if (!wrap) return;
|
||||
const target = _speedPresetPctFromActive(activePctOrRate);
|
||||
for (const btn of wrap.querySelectorAll('[data-speed-preset]')) {
|
||||
const pct = Number(btn.dataset.speedPreset);
|
||||
btn.classList.toggle('v3-speed-preset-active', target !== null && pct === target);
|
||||
}
|
||||
}
|
||||
|
||||
export function applySpeedPreset(percent) {
|
||||
const slider = document.getElementById('speed-slider');
|
||||
if (!slider) return;
|
||||
const pct = Math.max(
|
||||
Number(slider.min) || 15,
|
||||
Math.min(Number(slider.max) || 150, Number(percent)),
|
||||
);
|
||||
if (!Number.isFinite(pct)) return;
|
||||
slider.value = String(pct);
|
||||
host.handleSliderInput(slider);
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function _wireSpeedPresetsOnce() {
|
||||
if (_speedPresetsWired) return;
|
||||
const presets = document.getElementById('speed-presets');
|
||||
if (!presets) return;
|
||||
_speedPresetsWired = true;
|
||||
presets.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-speed-preset]');
|
||||
if (!btn) return;
|
||||
applySpeedPreset(Number(btn.dataset.speedPreset));
|
||||
});
|
||||
}
|
||||
|
||||
export function setSpeed(v) {
|
||||
const speedSlider = document.getElementById('speed-slider');
|
||||
const rate = Number(v);
|
||||
if (!Number.isFinite(rate)) {
|
||||
return;
|
||||
}
|
||||
if (window._juceMode) {
|
||||
window.jucePlayer?.setRate(rate);
|
||||
const juceAudio = window.feedBackDesktop?.audio;
|
||||
Promise.resolve()
|
||||
.then(() => juceAudio?.setBackingSpeed(rate))
|
||||
// Match the HTML5 path: preserve pitch on the JUCE backing track too.
|
||||
// Optional-chained call is a no-op on desktop builds that predate
|
||||
// setBackingPreservePitch, so this is safe to ship unconditionally.
|
||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||
.catch(err => console.warn('[setSpeed] backing speed/preserve-pitch failed:', err));
|
||||
} else {
|
||||
audio.playbackRate = rate;
|
||||
}
|
||||
const speedLabel = document.getElementById('speed-label');
|
||||
if (speedLabel) speedLabel.textContent = rate.toFixed(2) + 'x';
|
||||
host.handleSliderInput(speedSlider);
|
||||
_updateSpeedPresetButtons(rate);
|
||||
}
|
||||
|
||||
export function _resetPlaybackSpeedForNewSong() {
|
||||
// Reset the *actual* playback rate to 1x, not just the visible slider/label
|
||||
// (feedBack#615). The HTML5 <audio> element and the desktop JUCE/backing
|
||||
// engine each retain their own rate, and which one drives the next song
|
||||
// isn't decided until later in the load, so reset all paths unconditionally.
|
||||
// Every setter is idempotent and optional-chained, so this is safe in web
|
||||
// and desktop builds alike — no need to branch on window._juceMode.
|
||||
const speedSlider = document.getElementById('speed-slider');
|
||||
if (speedSlider) speedSlider.value = 100;
|
||||
audio.playbackRate = 1;
|
||||
window.jucePlayer?.setRate?.(1);
|
||||
const juceAudio = window.feedBackDesktop?.audio;
|
||||
Promise.resolve()
|
||||
.then(() => juceAudio?.setBackingSpeed?.(1))
|
||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||
.catch(err => console.warn('[resetSpeed] backing speed/preserve-pitch failed:', err));
|
||||
// Mirror setSpeed's UI side-effects (label text + slider fill styling).
|
||||
const speedLabel = document.getElementById('speed-label');
|
||||
if (speedLabel) speedLabel.textContent = (1).toFixed(2) + 'x';
|
||||
host.handleSliderInput(speedSlider);
|
||||
_updateSpeedPresetButtons(100);
|
||||
}
|
||||
// Master-difficulty slider (feedBack#48). Persists partial via
|
||||
// /api/settings — the POST handler merges only the keys present, so
|
||||
// this fire-and-forget call doesn't clobber dlc_dir or other settings.
|
||||
//
|
||||
// Debounced trailing-edge (300ms) so dragging the slider — which fires
|
||||
// oninput per pixel — doesn't flood the server with concurrent writes
|
||||
// to config.json. window.highway.setMastery() still fires every oninput so
|
||||
// the chart re-filters in real time; only disk persistence waits.
|
||||
let _masteryPersistTimer = null;
|
||||
function _persistMastery(pct) {
|
||||
if (_masteryPersistTimer) clearTimeout(_masteryPersistTimer);
|
||||
_masteryPersistTimer = setTimeout(() => {
|
||||
_masteryPersistTimer = null;
|
||||
fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ master_difficulty: pct }),
|
||||
}).catch(() => { /* best-effort — next setMastery() will retry */ });
|
||||
}, 300);
|
||||
}
|
||||
export function setMastery(v) {
|
||||
_applyMastery(v);
|
||||
}
|
||||
// Shared mastery applier. Master difficulty has two controls that write the
|
||||
// same master_difficulty key: the player-popover slider (#mastery-slider) and
|
||||
// the Gameplay-tab "Note highway speed" slider (#setting-highway-speed). Route
|
||||
// both — and loadSettings' hydration — through here so their positions,
|
||||
// labels, and track fills stay in sync regardless of which the user touches,
|
||||
// plus the live highway re-filter and the debounced persist. All element reads
|
||||
// are null-guarded since either control may be absent (follower window, or the
|
||||
// settings markup not yet rendered).
|
||||
export function _applyMastery(v, opts = {}) {
|
||||
// Guard + clamp: v might be a slider string, a programmatic call from a
|
||||
// plugin, or a restored settings value with a bad shape. Don't let NaN
|
||||
// reach a label (would show "NaN%") or the POST.
|
||||
const parsed = parseInt(v, 10);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const pct = Math.max(0, Math.min(100, parsed));
|
||||
const popLabel = document.getElementById('mastery-label');
|
||||
if (popLabel) popLabel.textContent = pct + '%';
|
||||
const popSlider = document.getElementById('mastery-slider');
|
||||
if (popSlider) {
|
||||
if (String(popSlider.value) !== String(pct)) popSlider.value = pct;
|
||||
host.handleSliderInput(popSlider);
|
||||
}
|
||||
const setSlider = document.getElementById('setting-highway-speed');
|
||||
if (setSlider) {
|
||||
if (String(setSlider.value) !== String(pct)) setSlider.value = pct;
|
||||
host.handleSliderInput(setSlider);
|
||||
}
|
||||
// The Gameplay-tab label markup appends a literal "%" after this span
|
||||
// (matching the av-offset "ms" pattern), so write the number alone here —
|
||||
// unlike #mastery-label above, whose markup carries no trailing unit.
|
||||
const setLabel = document.getElementById('setting-highway-speed-val');
|
||||
if (setLabel) setLabel.textContent = pct;
|
||||
window.highway.setMastery(pct / 100);
|
||||
if (!opts.skipPersist) _persistMastery(pct);
|
||||
}
|
||||
// Reflect phrase-data availability on the slider after every `ready`.
|
||||
// The server omits the `phrases` message entirely for single-level
|
||||
// sources (GP imports, legacy sloppak), so hasPhraseData() is the
|
||||
// right signal to enable/disable the slider.
|
||||
export function _applyMasteryAvailability(hasPhraseData) {
|
||||
const slider = document.getElementById('mastery-slider');
|
||||
if (!slider) return;
|
||||
if (hasPhraseData) {
|
||||
slider.disabled = false;
|
||||
slider.title = 'Master difficulty — low = simpler chart, high = full';
|
||||
} else {
|
||||
slider.disabled = true;
|
||||
slider.title = 'Source chart has a single difficulty level — slider disabled';
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,12 @@ export const S = {
|
||||
* land where it was asked to (JUCE can clamp; HTML5 can round).
|
||||
*/
|
||||
lastAudioTime: 0,
|
||||
|
||||
/**
|
||||
* A resume request armed by playSong({ resume }) and consumed on song:ready.
|
||||
* Written by app.js (playSong, and the song:ready listener that consumes it) and
|
||||
* read by the resume-session module — so, like the two above, it cannot be a plain
|
||||
* export.
|
||||
*/
|
||||
pendingResume: null,
|
||||
};
|
||||
|
||||
@@ -654,7 +654,8 @@ export async function loadPlugins() {
|
||||
// of a cached copy keyed only by path (matches the art
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
const query = v ? `?v=${v}` : '';
|
||||
script.src = _pluginScriptUrl(plugin, wantedVersion, query);
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
@@ -844,6 +845,56 @@ export async function checkPluginUpdates() {
|
||||
btn.textContent = 'Check for Updates';
|
||||
}
|
||||
|
||||
// ── Module re-evaluation (#879) ─────────────────────────────────────────────
|
||||
//
|
||||
// ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||
// <script type="module"> whose src the module map has already seen fires `load` but
|
||||
// does NOT re-run the body. So a ROLLBACK — reloading a version already evaluated
|
||||
// this session — silently kept the OLD module live, while onload fired and
|
||||
// loadedScripts recorded the rollback as applied. A no-op that reported success.
|
||||
// (Upgrades were fine: a new version means a new ?v=, hence a new URL.)
|
||||
//
|
||||
// Busting the ENTRY url alone does NOT fix it. A module plugin's screen.js is a
|
||||
// one-line `import './src/main.js'`, and a relative specifier resolves against the
|
||||
// base URL WITH THE QUERY STRING DROPPED — so ?v= never reaches the graph, and
|
||||
// src/main.js (where the plugin actually lives) stays cached no matter what we hang
|
||||
// off screen.js.
|
||||
//
|
||||
// So the token goes in the PATH. From /api/plugins/x/g/7/screen.js, './src/main.js'
|
||||
// resolves to /api/plugins/x/g/7/src/main.js — every relative import in the graph
|
||||
// inherits it, at every depth, with no import-specifier rewriting (which could not
|
||||
// see `import(expr)` anyway). The server ignores the token and serves identical
|
||||
// bytes.
|
||||
//
|
||||
// ─── AND THE UPGRADE PATH WAS BROKEN TOO ────────────────────────────────────
|
||||
//
|
||||
// #879 says "upgrades are fine — a new version yields a new URL". That is true of
|
||||
// screen.js and FALSE of the plugin. Driving a real browser through
|
||||
// install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0) and counting evaluations of
|
||||
// src/main.js gives ONE. Not two, not three: ONE. The upgrade re-evaluates the
|
||||
// one-line screen.js shim at its new ?v= URL, that shim imports './src/main.js',
|
||||
// that resolves to the same URL as before, and the module map hands back the
|
||||
// ALREADY-EVALUATED v1.0.0 module. The plugin's actual code never re-ran.
|
||||
//
|
||||
// So the generation token is not a rollback special case. EVERY re-load of a module
|
||||
// plugin needs it — the key is the plugin id, NOT id@version. Only the first load of
|
||||
// a given plugin in this document takes the stable URL, which is what keeps the
|
||||
// ETag/304 live-edit contract the R0 rails depend on.
|
||||
const _evaluatedModules = new Set(); // plugin ids whose module graph is live in this document
|
||||
let _moduleReloadSeq = 0;
|
||||
|
||||
function _pluginScriptUrl(plugin, wantedVersion, query) {
|
||||
const base = `/api/plugins/${plugin.id}/screen.js${query}`;
|
||||
if (plugin.script_type !== 'module') return base; // classic scripts always re-run
|
||||
if (!_evaluatedModules.has(plugin.id)) {
|
||||
_evaluatedModules.add(plugin.id);
|
||||
return base; // first load: stable URL, 304-able
|
||||
}
|
||||
// Re-load of a module plugin — upgrade OR rollback. Its graph is already in the
|
||||
// module map, so it needs an entirely fresh path or nothing below screen.js re-runs.
|
||||
return `/api/plugins/${plugin.id}/g/${++_moduleReloadSeq}/screen.js${query}`;
|
||||
}
|
||||
|
||||
export async function updatePlugin(pluginId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Updating...';
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Resume last session — the snapshot taken when you leave a song, and the pill that
|
||||
// offers it back.
|
||||
//
|
||||
// The fifth slice out of app.js's strongly-connected core. Small and self-contained:
|
||||
// ONE hook (playSong) plus a currentFilename getter.
|
||||
//
|
||||
// The armed resume request itself lives on the shared container as S.pendingResume,
|
||||
// not here, because app.js WRITES it — playSong({ resume }) arms it and the song:ready
|
||||
// listener consumes it — while this module reads it. An imported binding is read-only,
|
||||
// so shared mutable state has to live on the container. Same reason isPlaying does.
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { host } from './host.js';
|
||||
import { _curPlaybackSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
// Leaving a song snapshots where you were — song, arrangement, position, and
|
||||
// speed — so an exit (especially an accidental one, now that Escape reliably
|
||||
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
|
||||
// The snapshot is offered back through a non-blocking "Resume" pill; it never
|
||||
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
|
||||
// (This is the player-session slice; the broader nav/state-resume work — e.g.
|
||||
// returning to a song after wandering into Settings → Tone Builder — is a
|
||||
// separate, larger track.)
|
||||
const _RESUME_KEY = 'feedBack.resumeSession';
|
||||
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
|
||||
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
|
||||
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// window.highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
export function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!host.currentFilename()) return;
|
||||
const si = (window.highway && typeof window.highway.getSongInfo === 'function')
|
||||
? (window.highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
// glance at the first seconds, and not one that already basically ended.
|
||||
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
|
||||
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
|
||||
const snap = {
|
||||
f: host.currentFilename(),
|
||||
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
|
||||
? si.arrangement_index : undefined,
|
||||
t: pos,
|
||||
sp: _curPlaybackSpeed(),
|
||||
title: si.title || '',
|
||||
artist: si.artist || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
|
||||
// A fresh snapshot earns one offer — undo any earlier dismissal.
|
||||
_resumePillDismissed = false;
|
||||
} catch (_) { /* storage unavailable — resume is best-effort */ }
|
||||
}
|
||||
|
||||
export function _readResumeSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_RESUME_KEY);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
|
||||
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
|
||||
return snap;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
export function _clearResumeSession() {
|
||||
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-enter the snapshotted song and restore arrangement + position + speed.
|
||||
export async function resumeLastSession() {
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return false; }
|
||||
_hideResumePill();
|
||||
try {
|
||||
await host.playSong(snap.f, snap.a, {
|
||||
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
// A transient load/connect failure must not strand the user: keep the
|
||||
// snapshot so the pill can re-offer it on the next non-player screen,
|
||||
// rather than consuming the only copy before the song actually loaded.
|
||||
console.warn('[app] resume failed to load; keeping snapshot:', err);
|
||||
S.pendingResume = null;
|
||||
return false;
|
||||
}
|
||||
_clearResumeSession(); // consumed only after a successful load
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resume pill (non-blocking "continue where you left off") ────────────────
|
||||
// Self-contained, inline-styled, body-appended so it works identically in the
|
||||
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
|
||||
// the player screen, never blocks, and a dismiss forgets the current snapshot
|
||||
// for the session.
|
||||
export function _hideResumePill() {
|
||||
const el = document.getElementById('fb-resume-pill');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
export function _maybeShowResumePill() {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') { _hideResumePill(); return; }
|
||||
if (_resumePillDismissed) return;
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return; }
|
||||
if (document.getElementById('fb-resume-pill')) return; // already shown
|
||||
|
||||
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
|
||||
const pill = document.createElement('div');
|
||||
pill.id = 'fb-resume-pill';
|
||||
pill.setAttribute('role', 'status');
|
||||
pill.style.cssText = [
|
||||
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
|
||||
'display:flex', 'align-items:center', 'gap:10px',
|
||||
'max-width:min(90vw,360px)', 'padding:10px 12px',
|
||||
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
|
||||
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.style.cssText = 'flex:1;min-width:0';
|
||||
const t1 = document.createElement('div');
|
||||
t1.textContent = 'Resume practice';
|
||||
t1.style.cssText = 'font-weight:600;color:#fff';
|
||||
const t2 = document.createElement('div');
|
||||
t2.textContent = label;
|
||||
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
text.appendChild(t1); text.appendChild(t2);
|
||||
|
||||
const resumeBtn = document.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.textContent = 'Resume ▸';
|
||||
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '✕';
|
||||
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
|
||||
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
|
||||
|
||||
pill.appendChild(text);
|
||||
pill.appendChild(resumeBtn);
|
||||
pill.appendChild(dismissBtn);
|
||||
(document.body || document.documentElement).appendChild(pill);
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
// layer that catches it on the paths a smoke test never runs.
|
||||
import { audio } from './audio-el.js';
|
||||
import { esc } from './dom.js';
|
||||
import { _audioDuration, _audioTime, audioSeekGen } from './transport.js';
|
||||
import { formatTime } from './format.js';
|
||||
import { host } from './host.js';
|
||||
|
||||
export function _sectionPracticeBarContains(el) {
|
||||
@@ -36,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 = [];
|
||||
@@ -85,7 +87,7 @@ export function _setSectionPracticeMode(on, opts = {}) {
|
||||
if (opts.defaultWholeOn) {
|
||||
_sectionPracticeWholeSection = true;
|
||||
}
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (opts.defaultWholeOn) {
|
||||
_syncSectionPracticePieceUi();
|
||||
}
|
||||
@@ -104,7 +106,7 @@ export function _setSectionPracticeMode(on, opts = {}) {
|
||||
_sectionPracticeSelected = -1;
|
||||
_sectionPracticeWholeSection = false;
|
||||
_sectionPracticeSavedPartIndex = 0;
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (!opts.skipClearLoop && (host.loopA() !== null || host.loopB() !== null)) {
|
||||
host.clearLoop();
|
||||
}
|
||||
@@ -125,11 +127,11 @@ export function _resetSectionPracticeLog() {
|
||||
}
|
||||
|
||||
function _sectionPracticeHighway() {
|
||||
return window.highway || (typeof highway !== 'undefined' ? highway : null);
|
||||
return window.highway || null;
|
||||
}
|
||||
|
||||
function _sectionPracticeDuration() {
|
||||
const d = host._audioDuration();
|
||||
const d = _audioDuration();
|
||||
if (d && Number.isFinite(d) && d > 0) return d;
|
||||
const cd = window.feedBack?.currentSong?.duration;
|
||||
return (cd && Number.isFinite(cd) && cd > 0) ? cd : 0;
|
||||
@@ -901,7 +903,7 @@ export function renderSectionPracticeBar() {
|
||||
_showSectionPracticeBar(bar);
|
||||
scroll.innerHTML = parents.map((p, i) => {
|
||||
const label = _formatSectionPracticeName(p.name);
|
||||
const tip = `${label} (${host.formatTime(p.start)}–${host.formatTime(p.end)})`;
|
||||
const tip = `${label} (${formatTime(p.start)}–${formatTime(p.end)})`;
|
||||
const kindClass = _sectionPracticeChipKindClass(p.name, i);
|
||||
return `<button type="button" class="section-practice-chip${kindClass}" data-parent-idx="${i}" title="${esc(tip)}" onclick="onSectionParentClick(${i})">${esc(label)}</button>`;
|
||||
}).join('');
|
||||
@@ -914,7 +916,7 @@ export function renderSectionPracticeBar() {
|
||||
// matching one; run it before the piece UI so that reflects the result.
|
||||
_syncSectionPracticeFromLoop();
|
||||
_syncSectionPracticePieceUi();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
}
|
||||
|
||||
export async function onSectionParentClick(parentIdx) {
|
||||
@@ -927,7 +929,7 @@ export async function onSectionParentClick(parentIdx) {
|
||||
_sectionPracticeSavedPartIndex = 0;
|
||||
_sectionPracticeWholeSection = true;
|
||||
_syncSectionPracticePieceUi();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
if (_sectionPracticeActiveParentRange() || _sectionPracticeRanges.length) {
|
||||
await practiceSection(0, { whole: true });
|
||||
}
|
||||
@@ -1019,7 +1021,7 @@ function _blurSectionPracticeFocusIfNeeded() {
|
||||
|
||||
export async function practiceSection(index, opts = {}) {
|
||||
const requestGen = ++_sectionPracticeRequestGen;
|
||||
const seekGen = host._audioSeekGen();
|
||||
const seekGen = audioSeekGen();
|
||||
const loopGen = host._loopMutationGen();
|
||||
const whole = !!opts.whole;
|
||||
const r = _sectionPracticeResolveLoopTarget(index, opts);
|
||||
@@ -1045,7 +1047,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
let ok = false;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
// A newer click or a song/arrangement change supersedes this retry.
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== host._audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
try {
|
||||
// skipSectionSync: this function owns the section-practice state and
|
||||
// applies it below under the request-gen guard, so a stale retry
|
||||
@@ -1055,7 +1057,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
// after its internal seek await, so a stale loop is never armed.
|
||||
ok = await host.setLoop(start, end, {
|
||||
skipSectionSync: true,
|
||||
commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === host._audioSeekGen() && loopGen === host._loopMutationGen(),
|
||||
commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === audioSeekGen() && loopGen === host._loopMutationGen(),
|
||||
});
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
@@ -1064,7 +1066,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
await new Promise(res => setTimeout(res, 60 + attempt * 90));
|
||||
}
|
||||
// Re-check after the awaited retries before applying any loop/count-in state.
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== host._audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
|
||||
|
||||
if (ok) {
|
||||
_sectionPracticeWholeSection = whole;
|
||||
@@ -1073,7 +1075,7 @@ export async function practiceSection(index, opts = {}) {
|
||||
_sectionPracticeSavedPartIndex = index;
|
||||
}
|
||||
_blurSectionPracticeFocusIfNeeded();
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
host.startCountIn({ immediate: true });
|
||||
} else {
|
||||
_setSectionPracticeMode(false, { skipClearLoop: true });
|
||||
@@ -1120,7 +1122,7 @@ export function _syncSectionPracticeFromLoop() {
|
||||
} else if (_sectionPracticeMode) {
|
||||
_setSectionPracticeMode(false, { skipClearLoop: true });
|
||||
}
|
||||
_updateSectionPracticeHighlight(host._audioTime());
|
||||
_updateSectionPracticeHighlight(_audioTime());
|
||||
}
|
||||
|
||||
function _sectionPracticeIndexAtTime(t) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
// The playback transport — the play/pause/seek core, and the two clocks it reads.
|
||||
//
|
||||
// WHY THIS IS A MODULE AND NOT A HOOK BUNDLE. Every carve before this one ADDED host
|
||||
// hooks: a module pulled out of app.js still had to call back into it. This one SUBTRACTS
|
||||
// them. count-in, juce-audio, loops, and section-practice were all reaching through the
|
||||
// seam for the same handful of names — _audioSeek, _audioTime, setPlayButtonState,
|
||||
// _songEventPayload, jucePlayer. Those names have an owner, and it isn't app.js. Give
|
||||
// them one and the four consumers import them directly:
|
||||
//
|
||||
// count-in.js 5 hooks -> 0 juce-audio.js 4 hooks -> 0
|
||||
// loops.js 6 hooks -> 4 section-practice.js 10 hooks -> 7
|
||||
//
|
||||
// A hook is a cycle you agreed to live with. An import is a dependency you actually have.
|
||||
// Prefer the import whenever the name has a real owner.
|
||||
//
|
||||
// TWO THINGS DELIBERATELY LEFT IN app.js, both for the same reason — they would close a
|
||||
// cycle, and app.js is the root, so it can import from both sides for free:
|
||||
//
|
||||
// * _currentPlaybackSnapshot reads loopA/loopB from ./loops.js, and loops.js imports
|
||||
// this module. The dependency scan MISSED this at first: it
|
||||
// only walked app.js's own top-level decls, and loopA stopped
|
||||
// being one the moment loops.js was carved out. Any scan of a
|
||||
// partly-carved monolith has to resolve the imports too.
|
||||
// * restartCurrentSong calls _cancelCountIn() from ./count-in.js, which imports
|
||||
// this module.
|
||||
//
|
||||
// The seek generation (_audioSeekGen) stays PRIVATE. It has exactly one writer —
|
||||
// _resetAudioSeekState(), right here — so readers get audioSeekGen() and nobody outside
|
||||
// can desync it. That is strictly better than the host hook it replaces, which handed out
|
||||
// a getter and left the writer in app.js.
|
||||
import { audio } from './audio-el.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// Sync the play/pause button's icon and accessible state in one place so
|
||||
// screen readers, tooltips, and aria-pressed stay aligned with playback.
|
||||
// Updates the existing <img> child's src in place rather than rewriting
|
||||
// innerHTML, so any future children (fallback label, loading spinner, …)
|
||||
// survive state changes.
|
||||
export function setPlayButtonState(isPlaying) {
|
||||
const btn = document.getElementById('btn-play');
|
||||
if (!btn) return;
|
||||
const label = isPlaying ? 'Pause' : 'Play';
|
||||
const icon = isPlaying ? 'pause' : 'play';
|
||||
let img = btn.querySelector('img.button-icon-svg');
|
||||
if (!img) {
|
||||
img = document.createElement('img');
|
||||
img.className = 'button-icon-svg';
|
||||
img.alt = '';
|
||||
img.setAttribute('aria-hidden', 'true');
|
||||
btn.appendChild(img);
|
||||
}
|
||||
img.src = `/static/svg/${icon}.svg`;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('aria-pressed', isPlaying ? 'true' : 'false');
|
||||
btn.title = label;
|
||||
}
|
||||
|
||||
// ── Player ───────────────────────────────────────────────────────────────
|
||||
// `audio` now lives in ./js/audio-el.js so carved-out modules can reach the
|
||||
// player without importing app.js back (which would close a cycle). Same
|
||||
// element, same handle, same lookup — just imported instead of declared here.
|
||||
let _lastSongPositionEventAt = 0;
|
||||
|
||||
export function _emitSongPositionChanged(time, duration) {
|
||||
const now = Date.now();
|
||||
if (now - _lastSongPositionEventAt < 250) return;
|
||||
_lastSongPositionEventAt = now;
|
||||
const payload = (typeof _songEventPayload === 'function') ? _songEventPayload() : { time };
|
||||
window.feedBack.emit('song:position-changed', Object.assign(payload, { duration }));
|
||||
}
|
||||
|
||||
export const jucePlayer = {
|
||||
_timer: null,
|
||||
_pos: 0,
|
||||
_dur: 0,
|
||||
_pollAt: 0, // performance.now() when _pos was last set
|
||||
_polling: false,
|
||||
_speed: 1,
|
||||
get currentTime() {
|
||||
if (!this._polling) return this._pos;
|
||||
// Interpolate between IPC polls so highway motion is smooth at 60fps
|
||||
// Scale by _speed so at 0.7x the interpolated clock advances 0.7s/s
|
||||
const elapsed = (performance.now() - this._pollAt) / 1000;
|
||||
return Math.min(this._pos + elapsed * this._speed, this._dur > 0 ? this._dur : Infinity);
|
||||
},
|
||||
get duration() { return this._dur; },
|
||||
async play() {
|
||||
try {
|
||||
await window.feedBackDesktop.audio.startBacking();
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] startBacking failed:', err);
|
||||
return false;
|
||||
}
|
||||
this._startPolling();
|
||||
return true;
|
||||
},
|
||||
async pause() {
|
||||
// Snapshot the interpolated position before stopping the poll so
|
||||
// _pos stays at the visible pause point rather than jumping back
|
||||
// to the last raw IPC sample (which can be up to 100ms behind).
|
||||
this._pos = this.currentTime;
|
||||
this._pollAt = performance.now();
|
||||
this._stopPolling();
|
||||
try {
|
||||
await window.feedBackDesktop.audio.stopBacking();
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] stopBacking failed:', err);
|
||||
}
|
||||
},
|
||||
async seek(s) {
|
||||
const prev = this._pos;
|
||||
this._pos = s;
|
||||
this._pollAt = performance.now();
|
||||
try {
|
||||
await window.feedBackDesktop.audio.seekBacking(s);
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] seekBacking failed:', err);
|
||||
this._pos = prev;
|
||||
this._pollAt = performance.now();
|
||||
}
|
||||
},
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._polling = true;
|
||||
this._pollAt = performance.now();
|
||||
const self = this;
|
||||
function scheduleNext() {
|
||||
self._timer = setTimeout(async () => {
|
||||
if (!self._polling) return;
|
||||
try {
|
||||
self._pos = await window.feedBackDesktop.audio.getBackingPosition();
|
||||
self._pollAt = performance.now();
|
||||
_emitSongPositionChanged(self.currentTime, self.duration || null);
|
||||
} catch (err) {
|
||||
console.warn('[jucePlayer] position poll failed:', err);
|
||||
} finally {
|
||||
if (self._polling) scheduleNext();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
scheduleNext();
|
||||
},
|
||||
_stopPolling() {
|
||||
this._polling = false;
|
||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||
},
|
||||
setRate(rate) {
|
||||
this._pos = this.currentTime;
|
||||
this._pollAt = performance.now();
|
||||
this._speed = rate;
|
||||
},
|
||||
async stop() {
|
||||
await this.pause();
|
||||
this._pos = 0;
|
||||
this._dur = 0;
|
||||
this._pollAt = 0;
|
||||
this._speed = 1;
|
||||
},
|
||||
};
|
||||
|
||||
export function _audioTime() { return window._juceMode ? jucePlayer.currentTime : audio.currentTime; }
|
||||
|
||||
export function _audioDuration() { return window._juceMode ? jucePlayer.duration : audio.duration; }
|
||||
|
||||
// Canonical payload for song:play/song:pause/song:ended. Plugins anchor
|
||||
// their own clocks against `perfNow` (a monotonic timestamp at the same
|
||||
// moment audio reports `audioT`) so they don't have to chase the chart
|
||||
// clock with a follow-up call. `time` is kept as an alias for `audioT`
|
||||
// because pre-existing plugins read e.detail.time.
|
||||
export function _songEventPayload() {
|
||||
const audioT = _audioTime();
|
||||
return {
|
||||
time: audioT,
|
||||
audioT,
|
||||
chartT: window.highway.getTime(),
|
||||
perfNow: performance.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function _markPlaybackPaused() {
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
|
||||
export function _markPlaybackResumed() {
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function _emitPlaybackStopped(time, screen = 'playback-command') {
|
||||
if (window.feedBack) window.feedBack.emit('song:stop', { time: time || 0, screen });
|
||||
}
|
||||
|
||||
export function _waitForSongReady(expectedSeekGen, timeoutMs = 10000) {
|
||||
if (!window.feedBack || typeof window.feedBack.on !== 'function') return Promise.resolve(false);
|
||||
return new Promise(resolve => {
|
||||
let timer = null;
|
||||
const done = value => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
window.feedBack.off('song:ready', onReady);
|
||||
resolve(value);
|
||||
};
|
||||
const onReady = () => done(expectedSeekGen == null || expectedSeekGen === _audioSeekGen);
|
||||
window.feedBack.on('song:ready', onReady);
|
||||
timer = setTimeout(() => done(false), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Serializes seeks so concurrent callers (e.g. user ⏪ during a loop wrap)
|
||||
// don't interleave their from/to reads — each call captures `from` only
|
||||
// once the previous seek + emit have completed. The generation token
|
||||
// lets session teardown invalidate queued seeks so they don't run against
|
||||
// the new player and emit a stale song:seek.
|
||||
let _audioSeekChain = Promise.resolve();
|
||||
|
||||
let _audioSeekGen = 0;
|
||||
|
||||
export function _resetAudioSeekState() {
|
||||
// Bump the generation — in-flight chain callbacks see the mismatch on
|
||||
// their next guard check and short-circuit (no emit, no further state
|
||||
// mutation by us). Don't reset the chain head: new seeks must still
|
||||
// queue behind the in-flight old seek's IPC so two `jucePlayer.seek()`
|
||||
// calls can't race in the JUCE backing engine. The queue drains
|
||||
// quickly because each subsequent old-gen step bails on the first
|
||||
// guard the moment its predecessor resolves.
|
||||
_audioSeekGen++;
|
||||
}
|
||||
|
||||
// Time-box the JUCE IPC so a single hung seek can't block the global
|
||||
// _audioSeekChain forever (which would freeze every subsequent reposition
|
||||
// path: seekBy, loop-wrap, jump-fix, shimmed audio.currentTime).
|
||||
const _JUCE_SEEK_TIMEOUT_MS = 2000;
|
||||
|
||||
function _juceSeekWithTimeout(s) {
|
||||
let timer;
|
||||
const seekP = jucePlayer.seek(s);
|
||||
const timeoutP = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('JUCE seek timed out')), _JUCE_SEEK_TIMEOUT_MS);
|
||||
});
|
||||
// Clear the timer once the race settles either way; without this the
|
||||
// pending timeout keeps the event loop alive (and eventually rejects
|
||||
// an unawaited promise) even after a successful seek.
|
||||
return Promise.race([seekP, timeoutP]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// Resolves to `{ completed, from, to }`:
|
||||
// - completed: true if the seek ran to completion and emitted song:seek;
|
||||
// false if cancelled by a teardown gen bump (or threw).
|
||||
// - from: chart clock just before the seek (NaN on cancel before from-read).
|
||||
// - to: verified post-seek clock (NaN on cancel/throw).
|
||||
// Callers that fire follow-up work after the seek (count-in, arrangement
|
||||
// restore, etc.) should check `completed` so they don't act on a torn-down
|
||||
// session. Callers that need the actual landed position (because JUCE may
|
||||
// clamp or HTML5 may snap to the seekable range) should read `to` rather
|
||||
// than re-using the requested `s`.
|
||||
export async function _audioSeek(s, reason) {
|
||||
// Single funnel for every audio repositioning. Emits song:seek so
|
||||
// plugins (notedetect detection-suppression during seek transients,
|
||||
// practice-journal segment tracking) can react to any chart-time
|
||||
// jump regardless of which UI path triggered it. `reason` is a
|
||||
// free-form short string ('seek-by', 'loop-wrap', 'loop-set',
|
||||
// 'arrangement-restore', 'jump-fix') so subscribers can filter.
|
||||
const gen = _audioSeekGen;
|
||||
_audioSeekChain = _audioSeekChain.then(async () => {
|
||||
if (gen !== _audioSeekGen) return { completed: false, from: NaN, to: NaN };
|
||||
const from = _audioTime();
|
||||
if (window._juceMode) await _juceSeekWithTimeout(s);
|
||||
else audio.currentTime = s;
|
||||
if (gen !== _audioSeekGen) return { completed: false, from, to: NaN };
|
||||
// Read the verified post-seek position rather than the requested `s`
|
||||
// so plugins observe the actual clock — JUCE may clamp or roll back,
|
||||
// and HTML5 may snap to the nearest seekable range.
|
||||
const to = _audioTime();
|
||||
// Sync the jump-fix tracker so the next 60Hz tick doesn't see a
|
||||
// legitimate far seek (e.g. saved-loop jump > 30s) as a browser
|
||||
// bug and revert it.
|
||||
S.lastAudioTime = to;
|
||||
// Sync the chart clock too so any song:* emit fired right after
|
||||
// _audioSeek resolves (e.g. the auto-resume song:play in
|
||||
// changeArrangement) sees an in-sync chartT via _songEventPayload.
|
||||
// Without this, chartT lags by one 60Hz tick after a seek.
|
||||
if (window.highway && typeof window.highway.setTime === 'function') {
|
||||
window.highway.setTime(to);
|
||||
}
|
||||
window.feedBack.emit('song:seek', { from, to, reason: reason || null });
|
||||
return { completed: true, from, to };
|
||||
}).catch((err) => {
|
||||
// Don't let one failed seek poison subsequent ones.
|
||||
console.warn('[_audioSeek]', err);
|
||||
return { completed: false, from: NaN, to: NaN };
|
||||
});
|
||||
return _audioSeekChain;
|
||||
}
|
||||
|
||||
// Per-attempt counter for HTML5 audio.play() invocations. Bumped on
|
||||
// every play branch entry so a slow rejection from attempt N can't
|
||||
// clobber the UI of a newer attempt N+1 within the same session.
|
||||
let _playAttemptGen = 0;
|
||||
|
||||
export async function togglePlay() {
|
||||
if (window._juceMode) {
|
||||
if (S.isPlaying) {
|
||||
await jucePlayer.pause();
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
} else {
|
||||
const started = await jucePlayer.play();
|
||||
if (!started) return; // startBacking() failed — IPC error already logged
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (S.isPlaying) {
|
||||
audio.pause(); S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
} else {
|
||||
// Flip the UI optimistically before awaiting the play() Promise so
|
||||
// a quick second click during a slow start (buffering, device
|
||||
// wake, etc.) still enters the pause branch above. Two stale-
|
||||
// resolution guards:
|
||||
// - _audioSeekGen: bumped in showScreen() teardown and
|
||||
// playSong(), so a rejection from a torn-down session can't
|
||||
// touch new-session UI. Survives same-URL reloads.
|
||||
// - _playAttemptGen: bumped on every play branch entry, so
|
||||
// within a single session a slow rejection from attempt N
|
||||
// can't clobber a faster attempt N+1 (Play → Pause → Play).
|
||||
const sessionGen = _audioSeekGen;
|
||||
const attempt = ++_playAttemptGen;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
try {
|
||||
await audio.play();
|
||||
} catch (err) {
|
||||
if (sessionGen !== _audioSeekGen) return;
|
||||
if (attempt !== _playAttemptGen) return;
|
||||
// An engine reroute (HTML5 -> JUCE) deliberately pauses the <audio>
|
||||
// element mid-migration, which rejects this in-flight play() with an
|
||||
// AbortError even though playback continues on the JUCE transport.
|
||||
// The reroute owns isPlaying / the button while it runs (same guard
|
||||
// the <audio> 'play'/'pause' listeners use); resetting here would
|
||||
// leave the button showing Play while the song keeps playing — the
|
||||
// "two clicks to pause on the first song after a fresh load" bug.
|
||||
if (window._juceRerouteInProgress) return;
|
||||
console.error('[app] audio.play() rejected:', err);
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function seekBy(s) {
|
||||
await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the seek generation. Bumped by _resetAudioSeekState() on session
|
||||
* teardown; callers capture it before an await and compare after, so a resolution from a
|
||||
* torn-down session can't touch new-session state.
|
||||
*/
|
||||
export function audioSeekGen() { return _audioSeekGen; }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user