Compare commits

...
Author SHA1 Message Date
byrongamatos cfc24145fc Merge remote-tracking branch 'origin/feat/career-plugin' into test/career-mode-combined 2026-07-13 13:15:26 +02:00
byrongamatosandClaude Fable 5 a12b459fcc feat(career): crowd-SFX settings toggle + sfx/intro manifest validation
Settings → System → Career panel with the 'Crowd sound reactions' toggle
(writes the localStorage key the crowd layer reads). Pack manifests may
carry sfx {up, down} mp3s, validated like intro files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:15:01 +02:00
byrongamatos 9f91fc46e2 Merge remote-tracking branch 'origin/feat/career-venue-crowd' into test/career-mode-combined 2026-07-13 13:13:53 +02:00
byrongamatosandClaude Fable 5 523feb161e feat(venue-crowd): toggleable crowd sound reactions on mood transitions
Rank up → the venue's cheer sample, rank down → boos, gated behind the
feedBack-venue-crowd-sfx setting (default off), rate-limited by the
existing transition hysteresis and suppressed during the intro. Sounds
ship per-venue in the pack manifest (sfx: {up, down}).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:09:17 +02:00
byrongamatos b7ff77fb6e Merge remote-tracking branch 'origin/main' into test/career-mode-combined
# Conflicts:
#	plugins/career/routes.py
#	plugins/career/screen.js
#	tests/js/career_plugin.test.js
#	tests/plugins/career/test_routes.py
2026-07-12 22:58:51 +02:00
Byron GamatosandGitHub e2215df753 feat(career): bundle dive bar venue pack (#927) 2026-07-12 22:58:04 +02:00
byrongamatos 52e7ffa5f8 Merge remote-tracking branch 'origin/feat/career-plugin' into test/career-mode-combined 2026-07-12 22:39:30 +02:00
ea0ca94742 feat(career): career plugin — stars, venue tiers, pack downloads (career mode 2/3) (#907)
* feat(career): career plugin — stars from song_stats, venue tiers, pack downloads (career mode PR2)

Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★),
cumulative stars unlock bar → club → arena (data-driven venues.json).
Venue packs (UE-rendered crowd loops) download on demand to
CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 +
zip-slip validation, served via FileResponse. Career screen (promoted
sidebar entry) shows progress and pushes the active venue's manifest
into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when
either side is absent. Pack URLs land in venues.json in PR3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): keep manifest cleanup path alive on delete; badge only for installed venues

Codex preflight: nulling _appliedManifestVenue on delete skipped
pushCrowdManifest's setManifest(null) cleanup, leaving the crowd layer on
a deleted pack; and the 'playing here' badge showed for an override venue
whose pack was removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): generation-guard in-flight manifest fetches

Codex preflight: a manifest fetch resolving after a newer refresh (pack
deleted, venue switched) could re-apply a stale pack over the user's
newer selection — fetches now carry a generation token and bail when
superseded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): exclude orphaned song_stats from star totals

Codex preflight: scans hide rather than delete stats of removed songs, so
stars now apply the same existing-song filter other stats surfaces use
(filename IN (SELECT filename FROM songs)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(career): 50/150 star thresholds + star collection overview

Byron's progression tuning: club at 50★, arena at 150★. /state now
returns star_detail rows (title/artist joined from the library, stars,
best accuracy, next-star threshold) sorted closest-to-next-star first,
and the career screen renders a collection panel: tier summary plus a
per-song list with a 'N% to next star' practice hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(career): venue select/unselect UX, intro manifest support, fullmatch guards

- 'Play here' now also defaults the visualization to Venue (remembering
  the prior viz); active venues show 'Leave venue' which restores it and
  sets the '__none__' override so no installed venue silently reapplies.
- Pack manifests may ship an intro block (flyover video + ambience mp3);
  files validate like loops/stingers, .mp3 added to the serving whitelist.
- Codex preflight: whitelist regexes use fullmatch (trailing-newline names
  could validate but 500 on serving).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): let pushCrowdManifest clear the manifest on Leave venue

Codex preflight: nulling _appliedManifestVenue before refresh skipped the
setManifest(null) cleanup branch, leaving the crowd playing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): refresh tailwind output

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:39:19 +02:00
byrongamatos 36d984fffd fix(career): refresh tailwind output 2026-07-12 22:32:44 +02:00
e779c72396 feat(venue): reactive crowd video layer behind the 3D highway (career mode 1/3) (#905)
* feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1)

Two crossfading video backdrop planes in the highway_3d venue background
style, driven by a new venue-crowd.js state machine that maps
v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic)
with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers
on streak milestones and end-of-song accuracy. Inert without a venue pack
manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the
static bg plate behaves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions

Codex preflight P2s: (1) videos created before highway_3d registered its
globals never reached the backdrop planes — binding is now idempotent and
retried from start/perf-event/re-activation paths; (2) a crowd-state
switch committing while a stinger played was dropped because the machine
had already advanced — it is now deferred and played when the stinger ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): per-video load tokens + unbind renderer on stop

Codex preflight round 2: (1) the global load token let a stinger cancel a
committed loop load on the other layer — tokens are now per-element, and a
stinger preempting an in-flight loop on its own layer requeues that loop
for when the stinger ends; (2) setManifest(null)/deactivate left the last
crowd frame bound and visible over the static plate — stop() now unbinds
both layers from the renderer and zeroes the mix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events

Codex preflight round 3: (1) a failed/timed-out stinger left a deferred
loop switch queued forever; the failure path now flushes it. (2)
stats:recorded only carries {filename, arrangement}, so the end-of-song
reaction now uses the accuracyPct from the song's last
v3:live-performance-state event (a real percentage) instead of a field
that never existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap

Codex preflight round 4: (1) idleLayer() still points at the fading-in
layer during a crossfade, so a stinger firing mid-fade overwrote the new
loop with nothing requeued — the fading loop is now tracked and requeued
like an in-flight load; (2) swapping venue packs while active now goes
through stop() so _stopGen invalidates the old manifest's in-flight loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): generation-gate stinger handlers; recrop on video size change

Codex preflight round 5: (1) an ended/timeout handler orphaned by stop()
could fire into a later stinger's lifecycle on the reused element — handlers
now detach unconditionally and carry a generation token; (2) the renderer
only re-applied cover-crop on camera aspect changes, so a src swap with a
different intrinsic size kept stale repeat/offset — it now recrops when
videoWidth/Height change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer

Codex preflight round 6: the loop crossfade's completion callback could
still run between a stinger's start and its canplaythrough, promoting the
stinger's layer to active and pausing the real loop — it now bails when
the fading loop was preempted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): keep rear video layer opaque during crossfades

Two half-transparent layers let the static bg plate bleed through (~25%
at mid-fade) — visible as a flash of the old still image on every state
transition. The crossfade is now always the front layer fading over an
opaque rear layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): reset active layer with mix on stop

Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a
restart flashed layer 0's stale frame until the new loop loaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): reset crowd mood to neutral on song load

Codex preflight: a song ending in ecstatic/bored left the next song's
crowd stuck in that mood until the hysteresis window passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): cancel in-flight fade when a stinger preempts it

Codex preflight: the orphaned ramp kept pushing the mix toward the layer
whose src the stinger had just replaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value

Codex preflight: Number(null) is 0, so idle HUD resets overwrote
_lastAccuracyPct before stats:recorded consumed it, suppressing the
end-of-song stinger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): abort stale stinger state on song load

Codex preflight: a stinger straddling a song change could fade back into
the previous song's layer or flush its pending loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): always detach load listeners, gate only the callback

Codex preflight: superseded loads left canplaythrough/error listeners
attached to the persistent video elements — unbounded growth over a
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(venue-crowd): flyover intro with crowd-ambience ducking

On song:loaded, an optional pack intro plays once: a camera flyover video
(idle layer, one-shot) with bar-crowd ambience audio that ducks out on
song:play, near the flyover's landing, or at handoff — whichever first.
Machine commits and stingers defer during the intro; stop()/song-change
abort it. Packs without an intro behave as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): fall back to the loop when the intro fails to load

Codex preflight: a failed/timed-out intro left the song with no crowd
loop at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:32:04 +02:00
byrongamatos e1562559ba Merge remote-tracking branch 'origin/feat/career-plugin' into test/career-mode-combined 2026-07-12 21:45:34 +02:00
byrongamatos cfc138ab5e Merge remote-tracking branch 'origin/feat/career-venue-crowd' into test/career-mode-combined 2026-07-12 21:45:34 +02:00
byrongamatosandClaude Fable 5 f8fc5e6a5f fix(career): let pushCrowdManifest clear the manifest on Leave venue
Codex preflight: nulling _appliedManifestVenue before refresh skipped the
setManifest(null) cleanup branch, leaving the crowd playing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:42:03 +02:00
byrongamatosandClaude Fable 5 803193046e feat(career): venue select/unselect UX, intro manifest support, fullmatch guards
- 'Play here' now also defaults the visualization to Venue (remembering
  the prior viz); active venues show 'Leave venue' which restores it and
  sets the '__none__' override so no installed venue silently reapplies.
- Pack manifests may ship an intro block (flyover video + ambience mp3);
  files validate like loops/stingers, .mp3 added to the serving whitelist.
- Codex preflight: whitelist regexes use fullmatch (trailing-newline names
  could validate but 500 on serving).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:39:41 +02:00
byrongamatosandClaude Fable 5 4abdfb5f5c fix(venue-crowd): fall back to the loop when the intro fails to load
Codex preflight: a failed/timed-out intro left the song with no crowd
loop at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:25:26 +02:00
byrongamatosandClaude Fable 5 99812b8ed3 feat(venue-crowd): flyover intro with crowd-ambience ducking
On song:loaded, an optional pack intro plays once: a camera flyover video
(idle layer, one-shot) with bar-crowd ambience audio that ducks out on
song:play, near the flyover's landing, or at handoff — whichever first.
Machine commits and stingers defer during the intro; stop()/song-change
abort it. Packs without an intro behave as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:21:22 +02:00
byrongamatosandClaude Fable 5 702a9c6daa feat(career): 50/150 star thresholds + star collection overview
Byron's progression tuning: club at 50★, arena at 150★. /state now
returns star_detail rows (title/artist joined from the library, stars,
best accuracy, next-star threshold) sorted closest-to-next-star first,
and the career screen renders a collection panel: tier summary plus a
per-song list with a 'N% to next star' practice hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:14:00 +02:00
byrongamatosandClaude Fable 5 9d8fda6d46 fix(venue-crowd): always detach load listeners, gate only the callback
Codex preflight: superseded loads left canplaythrough/error listeners
attached to the persistent video elements — unbounded growth over a
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:09:00 +02:00
byrongamatosandClaude Fable 5 663e8ff4a1 fix(venue-crowd): abort stale stinger state on song load
Codex preflight: a stinger straddling a song change could fade back into
the previous song's layer or flush its pending loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:06:04 +02:00
byrongamatosandClaude Fable 5 f362c9a083 fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value
Codex preflight: Number(null) is 0, so idle HUD resets overwrote
_lastAccuracyPct before stats:recorded consumed it, suppressing the
end-of-song stinger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:03:48 +02:00
byrongamatosandClaude Fable 5 be78b4f29e fix(venue-crowd): cancel in-flight fade when a stinger preempts it
Codex preflight: the orphaned ramp kept pushing the mix toward the layer
whose src the stinger had just replaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:01:28 +02:00
byrongamatosandClaude Fable 5 f9a57ba044 fix(venue-crowd): reset crowd mood to neutral on song load
Codex preflight: a song ending in ecstatic/bored left the next song's
crowd stuck in that mood until the hysteresis window passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:58:15 +02:00
byrongamatosandClaude Fable 5 c83fec267e fix(venue-crowd): reset active layer with mix on stop
Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a
restart flashed layer 0's stale frame until the new loop loaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:54:20 +02:00
byrongamatosandClaude Fable 5 c73ff96dfe fix(venue-crowd): keep rear video layer opaque during crossfades
Two half-transparent layers let the static bg plate bleed through (~25%
at mid-fade) — visible as a flash of the old still image on every state
transition. The crossfade is now always the front layer fading over an
opaque rear layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:51:41 +02:00
8d3db5f42c fix(nav): nobody may monkey-patch window.showScreen — add screen:changing, make the shell listen (#924) (#925)
ship-ci / ci (push) Waiting to run
* fix(nav): the library sometimes showed the legacy screen — map 'home' inside showScreen

Testers: "randomly, when moving to the library from another menu option, the library shows the
old interface — never when a song ends."

━━━ WHAT WAS ACTUALLY HAPPENING ━━━

#home is the PRE-V3 library screen. The v3 shell replaced it with #v3-songs, and the mapping DID
exist — but only inside WRAPPERS on window.showScreen, and only for callers that go through
`window`. THREE independent parties monkey-patch it, each capturing whatever happens to be there
at the time:

    app.js publishes the raw function
      -> shell.js wraps it, adding the home -> v3-songs mapping
      -> the stems plugin wraps it AGAIN (src/main.js:1029), capturing the current value

Plugins load ASYNCHRONOUSLY. 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. Hence "randomly".

AND THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees. Reproduced
in a browser: the unwrapped function with 'home' lands on the dead legacy screen EVERY time.

"Never when a song ends" is the tell, and it is what identified the mechanism: closeCurrentSong
resolves its target through _resolvePlayerOrigin(), which ALREADY applies this mapping. That one
path was fine — which is exactly why the bug looked random rather than total.

PRE-EXISTING, not a regression from the module carve: the onclick="showScreen('home')" links and
the wrapper-only mapping both date to 2026-06-22.

━━━ THE FIX ━━━

The guard lives inside showScreen now: ONE place, in the function every caller routes through,
instead of a chain of monkey-patches that must each remember. Wrapper order stops mattering, and
the module-internal callers are covered for the first time.

Verified in a browser: the raw, unwrapped showScreen('home') — which reproduced as #home — now
lands on #v3-songs, and cannot be undone by any wrapper order.

━━━ AND A [P1] I INTRODUCED, WHICH CODEX CAUGHT ━━━

My first cut mapped BOTH 'home' and 'v3-home', copied straight from _resolvePlayerOrigin.

That is correct THERE and wrong HERE. _resolvePlayerOrigin computes where to RETURN TO after a
song, and landing on the Songs list from the dashboard is the right behaviour. But #v3-home is
the v3 DASHBOARD — a real screen that the shell's Home nav, the onboarding tour and the dashboard
re-render listener all target. Redirecting it would have made Home unreachable.

A LEGACY ALIAS IS NOT THE SAME THING AS A RETURN TARGET. Only 'home' is mapped now, and a test
pins that: re-adding 'v3-home' to the guard fails it.

4 tests, bite-tested both ways.

node 1049, pytest 2425, ESLint 0, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(nav): nobody may monkey-patch window.showScreen — add screen:changing, make the shell listen (#924)

window.showScreen was wrapped by THREE independent parties, each capturing whatever happened to be
there at the time:

    app.js publishes the raw function
      -> static/v3/shell.js wrapped it (to call syncActive, and to map home -> v3-songs)
      -> the stems plugin wrapped it AGAIN (to tear down on leaving the player)

Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled. A capture
taken before shell.js installed silently dropped the mapping it carried — and the library opened on
the dead legacy #home screen. Testers saw that as "randomly, the library shows the old interface"
(#923).

#923 fixed the symptom by moving the mapping inside showScreen. This removes the CAUSE: neither
wrapper ever needed to be one.

━━━ TWO EVENTS, AND THE DISTINCTION IS THE WHOLE POINT ━━━

    screen:changing  emitted BEFORE anything happens. "I am leaving `from`." Teardown/cancel here.
    screen:changed   emitted after the DOM and data settle. "I am on `id`." Now carries `from`.

screen:changing is new, and it exists because Codex caught me collapsing the two. The stems plugin
tore down its audio graph BEFORE showScreen did anything; screen:changed fires at the very END,
after core awaits library and provider loads — so moving the plugin onto it would have delayed
teardown behind a slow fetch, or skipped it entirely if that fetch threw, and stems would keep
playing on a non-player screen. A test pins the ordering: screen:changing must precede the first
await.

shell.js is a plain screen:changed listener now, like app.js, audio-mixer.js and tour-engine.js
already were. window.showScreen is an unwrapped function again, and tests/js/
no_showscreen_monkeypatch.test.js fails CI if anything in static/ ever assigns to it again — so the
hazard is structurally impossible rather than merely avoided.

━━━ AND A FALLBACK THAT COULD NEVER FIRE ━━━

My retry-if-the-bus-is-late path listened for `slopsmith:capabilities:ready`. Core dispatches
`feedBack:capabilities:ready` (capabilities.js:1536) — the slopsmith: name is the PRE-DMCA event
and nothing has emitted it since the rename. Codex caught it. A guard that cannot fire is worse
than no guard: it reads as protection and is decoration.

(The same dead-event bug turned out to be sitting in THREE of the stems plugin's fallbacks, where
it has silently disabled its lifecycle wiring whenever the bus was late. Fixed in
feedback-plugin-stems#38.)

VERIFIED. A/B against origin/main: the nav highlight and topbar title follow IDENTICALLY with
shell.js as a listener; screen:changing -> screen:changed fire in order with the right {id, from};
window.showScreen is unwrapped; and showScreen('home') still lands on v3-songs.

node 1053, pytest 2425, ESLint 0, Codex 0.

Closes #924

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:06:28 +02:00
f27d4f623c fix(nav): the library sometimes showed the legacy screen — map 'home' inside showScreen (#923)
Testers: "randomly, when moving to the library from another menu option, the library shows the
old interface — never when a song ends."

━━━ WHAT WAS ACTUALLY HAPPENING ━━━

#home is the PRE-V3 library screen. The v3 shell replaced it with #v3-songs, and the mapping DID
exist — but only inside WRAPPERS on window.showScreen, and only for callers that go through
`window`. THREE independent parties monkey-patch it, each capturing whatever happens to be there
at the time:

    app.js publishes the raw function
      -> shell.js wraps it, adding the home -> v3-songs mapping
      -> the stems plugin wraps it AGAIN (src/main.js:1029), capturing the current value

Plugins load ASYNCHRONOUSLY. 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. Hence "randomly".

AND THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees. Reproduced
in a browser: the unwrapped function with 'home' lands on the dead legacy screen EVERY time.

"Never when a song ends" is the tell, and it is what identified the mechanism: closeCurrentSong
resolves its target through _resolvePlayerOrigin(), which ALREADY applies this mapping. That one
path was fine — which is exactly why the bug looked random rather than total.

PRE-EXISTING, not a regression from the module carve: the onclick="showScreen('home')" links and
the wrapper-only mapping both date to 2026-06-22.

━━━ THE FIX ━━━

The guard lives inside showScreen now: ONE place, in the function every caller routes through,
instead of a chain of monkey-patches that must each remember. Wrapper order stops mattering, and
the module-internal callers are covered for the first time.

Verified in a browser: the raw, unwrapped showScreen('home') — which reproduced as #home — now
lands on #v3-songs, and cannot be undone by any wrapper order.

━━━ AND A [P1] I INTRODUCED, WHICH CODEX CAUGHT ━━━

My first cut mapped BOTH 'home' and 'v3-home', copied straight from _resolvePlayerOrigin.

That is correct THERE and wrong HERE. _resolvePlayerOrigin computes where to RETURN TO after a
song, and landing on the Songs list from the dashboard is the right behaviour. But #v3-home is
the v3 DASHBOARD — a real screen that the shell's Home nav, the onboarding tour and the dashboard
re-render listener all target. Redirecting it would have made Home unreachable.

A LEGACY ALIAS IS NOT THE SAME THING AS A RETURN TARGET. Only 'home' is mapped now, and a test
pins that: re-adding 'v3-home' to the guard fails it.

4 tests, bite-tested both ways.

node 1049, pytest 2425, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:05:50 +02:00
57e7db5c2a refactor(app): carve the keyboard-shortcuts subsystem into static/js/shortcuts.js (R3d) (#922)
19 declarations + 23 TOP-LEVEL STATEMENTS. 922 lines. app.js 3,243 -> 2,325 (-28%).

The panel registry, both global keydown dispatchers, the library arrow-nav, and the whole
plugin-facing shortcut API.

━━━ MOST OF THIS SUBSYSTEM WAS NOT DECLARATIONS ━━━

A declaration-seeded dependency closure reports this cluster as 10 names, 246 lines.
It is 42 statements and 922.

window.registerShortcut, createShortcutPanel, getAllShortcuts, unregisterShortcut,
clearWindowShortcuts, the panel registry, and BOTH global keydown dispatchers are bare TOP-LEVEL
STATEMENTS at app.js's top level. A call-graph scan sees NONE of them.

That blind spot has now cost three times:
  * it nearly shipped a dead library A-Z rail (#896) — 43 of library.js's exports were
    referenced only from app.js's window contract;
  * it threw "Assignment to constant variable" in the session carve (#921), where the autoplay
    gate's top-level statements wrote state that had just become a read-only import;
  * and here it under-reported the slice by 3x.

The extractor takes them by construction now — any top-level statement that TOUCHES a moved
binding comes along — and the SEED is closed to a FIXED POINT, because those statements have
their own dependencies (_modifiersMatch, _isShortcutActive, _handleLibArrowNav, _gridColumns…)
that the declaration closure never walked. Seed -> pull the statements -> the statements need
more names -> re-seed. Iterate until it stops growing.

━━━ syncLibrarySong GOES ACROSS THE SEAM, NOT THROUGH AN IMPORT ━━━

The library arrow-nav calls it on Enter. It cannot be imported: syncLibrarySong reaches
showScreen/playSong, and a module importing app.js closes a cycle. It is the ONE name here that
had to stay behind, so it comes across the host seam — which is exactly what the seam is for.
host.js throws loudly if the wiring is ever dropped, and tests/js/host_contract.test.js fails in
CI if the hook drifts.

VERIFIED. A/B against origin/main in two browsers, IDENTICAL, zero page errors — and driven for
real, not merely present: the plugin API (register / unregister / getAll / panels), THE GLOBAL
KEYDOWN DISPATCHER actually firing a registered shortcut, that same shortcut correctly SUPPRESSED
while typing in a text input, and `?` opening the help modal.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:05:25 +02:00
byrongamatos c78e7e04d3 Merge remote-tracking branch 'origin/feat/career-plugin' into test/career-mode-combined 2026-07-12 15:37:33 +02:00
byrongamatos d539421a03 Merge remote-tracking branch 'origin/feat/career-venue-crowd' into test/career-mode-combined 2026-07-12 15:37:32 +02:00
545e569ad6 refactor(app): carve the song session out of app.js — playSong, showScreen, closeCurrentSong (R3d) (#921)
36 declarations + the 4 autoplay/auto-exit gate statements. 359 lines.
app.js 3,772 -> 3,242. Bodies VERBATIM.

━━━ THIS WAS "THE UNCUTTABLE HEART", AND IT IS 359 LINES ━━━

At the start of this epic, 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. 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, by letting 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 BUG NO SCAN COULD SEE, AND THE A/B DID ━━━

First cut passed every gate — no-undef clean, no-cycle clean, 1045/1045, pytest green — and
THREW IN THE BROWSER: "Assignment to constant variable."

window.feedBack.holdAutoplay / holdAutoExit and their two event handlers are TOP-LEVEL
STATEMENTS, not declarations. They WRITE this cluster's state (_autoplayHeld, _autoExitTimer, …),
and an imported binding is READ-ONLY — so left behind in app.js, every one threw the instant the
module existed.

A dependency scan that walks DECLARATIONS cannot see them. Mine didn't. This 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 a call-graph is blind to every one of them.

The extractor now finds them by construction — any top-level statement that WRITES a moved
binding comes with the carve — and the gate statements live beside the machinery they drive,
which is where they belonged anyway.

━━━ ZERO OUTSIDE WRITES, BY MOVING THE BOUNDARY RATHER THAN BUILDING MACHINERY ━━━

The autoplay scalars and the wake-lock state were written from outside the cluster, which would
have forced a setter or a state 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.

VERIFIED. A/B against origin/main in two browsers, IDENTICAL, zero page errors — including the
autoplay gate driven end to end: a plugin HOLDS autoplay, the song loads but does not start, the
RELEASE fires it, and a stale release is a no-op. That is the exact machinery that was throwing.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:22:19 +02:00
84fe29688c refactor(app): carve settings into static/js/settings.js (R3d) (#920)
22 declarations, 446 lines. app.js 4,218 -> 3,772. Bodies VERBATIM.

Settings load/save, the AV-offset nudge, the default-arrangement pin, the instrument pathway,
and the app-update channel.

━━━ INTERFACE WIDTH 1, AND IT GOT THERE BY DRAWING THE BOUNDARY IN THE RIGHT PLACE ━━━

app.js calls loadSettings() and nothing else.

The first cut was NOT clean: _defaultArrangement was written from OUTSIDE the cluster, and an
imported binding is READ-ONLY, so that one write would have forced a setter or a state
container — as it did for the player (player-state.js) and the library (library-state.js).

But the writers were saveSettings and pinCurrentArrangementDefault, which ARE settings
functions. Widening the slice to include them left ZERO outside writes. Every export is now a
plain read-only import and no container is needed.

Worth naming, because I reached for a container twice before: the fix for "this binding is
written from outside" is sometimes a container, and sometimes it just means the boundary is in
the wrong place. Measure the writers before you build machinery.

━━━ handleSliderInput STAYS A HOST HOOK, DELIBERATELY ━━━

It lives in settings now (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, and
the contract test proves the wiring survived.

VERIFIED. A/B against origin/main in two browsers: the window contract, the settings screen
rendering, the AV-offset and default-arrangement controls present, and a real `input` event
dispatched on a slider — which is the path that goes through the host seam. IDENTICAL, zero
page errors.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:08:45 +02:00
69aac32278 refactor(app): carve the edit-song modal into static/js/edit-modal.js (R3d) (#919)
4 functions, 234 lines. app.js 4,452 -> 4,218. Bodies VERBATIM.

INTERFACE WIDTH ZERO — nothing in app.js calls into this cluster. app.js needs only the 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 the modal
has is a module now: it reads six bindings out of ./library.js (loadLibrary, loadFavorites,
loadTreeView, _removeLibCardsForFilename, libView, _lastLibSelected) plus dom.js and the L
container. Before that carve, extracting this would have dragged the whole library with it.

Checked, and it matters: the modal never WRITES any of those six. An imported binding is
READ-ONLY, so a single write would have forced a setter or a state container. Every use is a
read, so plain imports suffice.

Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back.

VERIFIED. A/B against origin/main in two browsers: the window contract, the modal actually
OPENING off a real library row, its title and year fields rendering, and the data-edit-save
wiring (rather than an inline onclick embedding the filename — the fix this cluster's harness
exists to guard). IDENTICAL, no new page errors.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:43:57 +02:00
0a6e0309e5 fix(tailwind): stop the dev server rewriting a tracked file (#911) (#918)
The runtime stylesheet moves to CONFIG_DIR. static/tailwind.min.css is never written again.

━━━ TWO DIFFERENT THINGS WERE SHARING ONE PATH ━━━

    static/tailwind.min.css   a BUILD ARTEFACT. Committed, image-baked, generated by scanning
                              the in-tree plugins only. CI's tailwind-fresh check verifies it.
    the RUNTIME sheet         PER-INSTALL STATE. Additionally scans whatever the user installed
                              into FEEDBACK_PLUGINS_DIR, so it differs machine to machine.

Writing the second over the first meant that MERELY RUNNING THE DEV SERVER from a git checkout
silently modified a tracked file. `git add -A` then swept a 100KB reshuffle of minified CSS
into the commit and ci/tailwind-fresh went red with a diff that explains nothing — on a PR
whose real change touched no Tailwind classes at all. It also wrote app state into the app
directory, which is read-only in some deploys.

A new route serves the runtime sheet when there is one and falls back to the committed one
otherwise. It is registered BEFORE the /static mount, which would otherwise swallow the path.

━━━ A PERSISTED SHEET MUST NOT OUTLIVE ITS REASON (Codex [P2] x2) ━━━

1. THE USER REMOVES THEIR PLUGINS. Startup only rebuilds when user plugins exist, so nothing
   would ever overwrite the stale sheet — and it still carries classes for plugins that are
   gone. With no user plugins the COMMITTED sheet is complete by definition. Guarded.

2. THE APP IS UPGRADED, and my first guard for this was WRONG. I compared mtimes. Codex: that
   is not a freshness signal across install methods — archives and container images routinely
   PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER timestamp than a
   runtime sheet a user built days ago. The mtime check then calls the stale one FRESH and it
   masks the new core CSS indefinitely — permanently, if no Tailwind toolchain is present to
   trigger a rebuild.

   Freshness is decided by CONTENT now. Each runtime build stamps a sidecar with the sha256 of
   the committed sheet it was made from. Core ships new CSS -> that file changes -> the hash
   changes -> the runtime sheet is correctly judged stale. Timestamps only gesture at the
   question that hashing answers.

Falling back to the committed sheet is always safe: at worst it lacks a just-installed plugin's
classes for the seconds until the async rebuild lands.

VERIFIED END TO END. Ran the real dev server with 3 plugins installed: it rebuilt Tailwind over
them (123,291 bytes), wrote the sheet + sidecar to CONFIG_DIR, still served /static/
tailwind.min.css at 200 — and `git diff` on the tracked file came back CLEAN.

8 tests. Bite-tested: reverting to the shared path fails 3, dropping the staleness guards fails
2 more.

pytest 2425, pyflakes 0, Codex 0.

Closes #911

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:25:16 +02:00
36cf77dc44 refactor(highway): carve the 2D drawing layer into highway-draw.js (R3c) (#917)
18 functions, 1,245 lines. highway.js 3,972 -> 2,727 (-31%). The biggest R3c slice: notes,
sustains, chords, strum groups, unison bends and lyrics — everything the default renderer
paints each frame.

━━━ MUTABILITY, NOT LOCATION, DECIDES WHERE A THING BELONGS ━━━

Three per-instance caches came out with this slice, and they are why it needed care:

    _frameMismatchWarned   a warn-once Set of chord ids     (feedBack#88)
    _chordRenderInfo       a WeakMap of chord -> chain info
    _lyricMeasureCache     Map<fontSize, Map<text, width>>

All three are MUTATED. Left at module scope they would be SHARED ACROSS PANELS — one
highway's lyric widths and chord chains stomping another's, silently, with nothing throwing.
createHighway() is a factory (the constitution publishes window.createHighway so a plugin can
build a second highway), so they are lifted onto hwState, which is exactly what hwState is for.

The shimmer LUT went the OTHER way — to MODULE scope in highway-geometry.js. It is a
deterministic xorshift table, byte-for-byte identical for every instance, so sharing it is not
merely safe but BETTER: built once for the page rather than once per panel.

Same slice, opposite directions, decided entirely by whether the thing mutates.

━━━ MY SCRIPT WAS WRONG TWICE. THE GATES CAUGHT BOTH. ━━━

1. HAND-LISTED THE MOVE SET. I listed 10 functions and missed six that drawChords needs
   (_ensureChordRenderCache, bsearchChords, getChordTemplateInfo, _computeChordBox,
   _updateFretLinePreview, _drawFretLineChordPreview). The no-undef gate named every one. The
   set is now DERIVED from the dependency closure — 18, not 10.

2. JUDGED PURITY TOO EARLY, and this one is subtle. I classified _computeChordBox as pure
   because its ORIGINAL body never mentions hwState. Then the call-site rewriter injected
   `fretX(hwState, …)` INTO it — fretX takes hwState now (#916) — leaving a function that
   references an hwState it was never given. Purity has to be judged from the body AS IT WILL
   BE, so the classifier iterates to a fixed point: a function needs hwState if it mentions it,
   OR calls anything that now takes it. That moved _computeChordBox to the stateful side.

VERIFIED. A/B against origin/main: IDENTICAL, zero page errors. The PLUGIN BUNDLE contract is
byte-identical (b.fretX arity 3, b.getNoteState arity 2, both stable references, both correct
under the old calling convention). PERF GATE PASSES AT 1.92ms against its 12ms budget — and
this is the slice that could really have cost something: the ENTIRE per-frame drawing path is
now cross-module. It costs nothing measurable.

TESTS. highway_teaching_marks follows strumGroupBuckets to the new module. The two source-shape
harnesses now read highway.js AND every static/js/highway-*.js, rather than being re-pinned at
whichever file currently holds a function — re-pinning breaks again next time, and a shape
assertion that silently stops finding its target is indistinguishable from one that passes.

node 1045, pytest 2416, ESLint 0, no-undef 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:00:29 +02:00
12eb73aee9 refactor(highway): carve the STATEFUL primitives, threading hwState explicitly (R3c) (#916)
fretX, fillTextReadable, _noteState, _paintGemGlow -> static/js/highway-state-primitives.js.
50 call sites rewritten. highway.js 4,105 -> 3,965.

The first slice that changes signatures. Each of these four gains hwState as an explicit
FIRST PARAMETER.

━━━ hwState IS A PARAMETER, NOT AN IMPORT ━━━

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. Import hwState as a
module singleton and two panels silently share one clock, one render scale, one string
palette — each driving the other. Nothing throws. The picture is just wrong, in a way no test
would catch.

(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 PLUGIN BUNDLE NEARLY BROKE, SILENTLY ━━━

The renderer bundle hands two of these STRAIGHT TO PLUGINS:

    b.fretX = fretX;
    b.getNoteState = _noteState;   // stable reference

highway_3d calls both EVERY FRAME, with the old arity. Handing out the new 3-arg versions
would have passed `note` where hwState belongs — no throw, no error, just wrong geometry and
wrong judgment state INSIDE A PLUGIN, which no core test would ever see. Green CI, broken 3D
highway.

So hwState is bound ONCE per instance, in the factory, and the bundle hands out those views.
A per-frame arrow would have fixed the arity and reintroduced exactly the per-frame allocation
the bundle's stable-reference contract (feedBack#254) exists to prevent. b.project needs none
of this — project() is pure and its arity never changed.

VERIFIED IN A BROWSER, against the real bundle, on both builds:

    fretX arity                      3    3     (NOT 4 — the bound view preserves it)
    getNoteState arity               2    2
    fretX(5,1,800) in 0..800      True True
    getNoteState null w/o provider True True
    getNoteState honours provider  True True
    fretX is a stable reference    True True

IDENTICAL. Without the bound views fretX would have reported arity 4 and computed garbage.

Also caught on the way: my generated module imported STRING_BRIGHT_FALLBACK, a name
highway-constants.js does not export. ESLint does not flag that — but importing a name a
module does not export is a runtime SyntaxError that kills the WHOLE module. These four need
no constants at all; the import is gone.

TESTS. highway_note_state pins the signature AND the stable-reference contract — it caught the
bundle break. Retargeted at the module and the new arity; both contracts still asserted, and
the "no fresh arrow per frame" rule is now asserted explicitly rather than implied by
`getNoteState: _noteState`.

PERF GATE PASSES AT 1.94ms against its 12ms budget — fretX and _noteState are now CROSS-MODULE
calls, per note, per frame. It costs nothing measurable.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:42:38 +02:00
1a386c272d refactor(highway): carve the PURE geometry primitives into highway-geometry.js (R3c) (#915)
6 functions, 53 lines. highway.js 4,158 -> 4,105. NOT ONE CALL SITE CHANGES.

project, roundRect, bnvNormalizedPoints, teachingFingerLabel, teachingDegreeLabel,
chordHarmonyLabels — the shared primitives every drawing function leans on.

━━━ PURITY IS THE WHOLE POINT OF THIS SLICE ━━━

Every one of these is a pure function of its arguments. None touches hwState. 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 #914.

That matters because createHighway() is a FACTORY: a plugin can build a second highway for its
own panel, so anything holding per-instance state must be PASSED hwState rather than importing
it, or two panels silently share one clock and palette. These six hold no state at all, so
they move VERBATIM — the module boundary is invisible to every caller.

The asserts are mechanical and in the extractor: it REFUSES to move a function whose body
mentions hwState, or that references `ctx` without taking it as a parameter. Purity is
checked, not assumed.

━━━ WHAT IS DELIBERATELY LEFT BEHIND ━━━

The four primitives that DO need hwState — fretX, fillTextReadable, _noteState, _paintGemGlow
— stay in the factory for now. They need an explicit hwState parameter threaded through 53
call sites, which is a real behavioural change and belongs in its own commit rather than
smuggled in beside a provably-identical move. Separating the provable from the risky is the
whole discipline of this epic.

TESTS. Three harnesses brace-match these functions out of the source and run them in a
sandbox; they now read static/js/highway-geometry.js. `export function x` still contains
`function x`, so the extractor needed no change — only the path.

VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors. PERF GATE PASSES AT
1.91ms against its 12ms budget — and this is the one that could plausibly have cost something:
project() runs for every visible note on every frame and is now a CROSS-MODULE call. It costs
nothing measurable. That is the answer #910 was built to give.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:31:56 +02:00
8e89b39ad3 refactor(highway): carve the constants into static/js/highway-constants.js (R3c) (#914)
29 constants, 190 lines. highway.js 4,267 -> 4,158. The first real slice, and the one that
every later one imports.

━━━ WHY ONLY THE CONSTANTS 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 already 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 hwState — all 79 mutable properties — must NEVER become a module-level singleton: two
highways would silently share it, and one panel would drive the other's clock, scale and
colour tables. Extracted functions will take it as an ARGUMENT.

That is the OPPOSITE of the app.js carve, where a single state container (player-state.js,
library-state.js) was exactly right, because there is exactly one app. Same epic, same
language, opposite answer — because one is a singleton and the other is a factory.

These 29 are pure literals: numbers, strings and colour tables, never reassigned, never
mutated. Sharing them across instances is not merely 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, and none of these has one.

ESLint now knows static/highway.js is a module. It could not have known before this commit:
the flip (#913) changed the SCRIPT TAG, but the file had no import/export yet, so it still
parsed as a script and lint stayed green. The first `import` is what makes the config wrong.

TESTS. Four source-shape harnesses asserted `const _AUTO_SCALE_MIN = …` etc. lived in
highway.js. They now read highway.js AND every static/js/highway-*.js — deliberately, rather
than being re-pinned at whichever file currently holds a constant. Re-pinning just breaks
again on the next carve, and a source-shape assertion that silently stops finding its target
is indistinguishable from one that passes. Bite-tested: renaming two constants away fails
them.

VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors. AND THE PERF GATE
PASSES AT 1.97ms against its 12ms budget — which is the point of having built it (#910)
first: these constants moved from closure scope to module scope, and V8 does not treat those
identically. It does here. Now I know rather than hope.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:24:40 +02:00
c6963fdf30 refactor(highway): flip highway.js to an ES module (R3c) (#913)
Two lines. index.html: defer -> type="module". highway.js: one explicit assignment.
highway.js can now `import`, which is the whole point — the carve can begin.

━━━ THE ONE THING THE FLIP ACTUALLY BREAKS: window.createHighway ━━━

A top-level `function createHighway()` in a CLASSIC script IMPLICITLY becomes
window.createHighway. In a module it does not — module declarations are module-scoped, and
the name vanishes from the global object the instant the tag grows type="module".

The constitution names window.createHighway as PUBLIC EXTENSION CONTRACT (alongside
window.playSong / showScreen / feedBack). NOTHING IN-TREE CALLS IT. That is exactly why this
would have shipped: the only consumers are third-party plugins rendering their own highway
panel, and I cannot grep those. Green CI, green tests, and a broken plugin API.

Verified by removing the assignment and reloading:

    flip WITHOUT an explicit assignment:  window.createHighway === undefined   <-- gone
    flip WITH it:                         window.createHighway === function

So it is assigned explicitly now — same object, same behaviour, no longer an accident of how
the file happens to be loaded.

━━━ AND A CORRECTION TO #912 ━━━

#912 (merged) rewrote 73 bare `highway.x` -> `window.highway.x` on the stated grounds that
the flip would turn every one of them into a ReferenceError. HAVING NOW ACTUALLY FLIPPED IT,
THAT WAS WRONG. highway.js already did `window.highway = highway`, which puts the name on the
GLOBAL OBJECT — and bare-identifier resolution falls back to the global object whether or not
a lexical global binding exists. Measured on both builds: bare `highway` resolves either way.

#912 is defensible as hygiene and it does not hurt, but it was not a precondition and it fixed
no latent bug. A correction is posted on the PR so its commit message does not mislead. The
real hazard was the factory, not the instance — same class of breakage, wrong name.

ORDERING is unchanged: classic-defer and non-async type="module" share ONE post-parse
execution queue, in document order, so highway.js keeps its position at index.html:1244.

VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors — window.highway,
window.createHighway, the full API surface, a real song playing, the chart clock advancing,
and the seek->setTime sync. THE PERF GATE PASSES at 1.85ms against its 12ms budget (module
evaluation costs nothing at render time), which is exactly what #910 was built to tell me.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:07:28 +02:00
d9fa6d3f55 refactor(highway): make the highway global explicit before the module flip (R3c) (#912)
73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files.
Provably a NO-OP today. It is the precondition for flipping highway.js to a module.

━━━ WHY THIS HAS TO LAND FIRST ━━━

highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore
creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND
to every ES module. 73 call sites quietly rely on that.

The moment highway.js becomes a module, that binding is gone. `const` in a module is
module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the
flip is impossible until they say what they mean.

`window.highway = highway` is already set, to the same object, on the same line. So this is
an identity rewrite — verified in the browser below.

━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━

1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`.
   Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted
   `const window.highway = window.highway`. Excluded.

2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read
   `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`.
   The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching
   them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE
   and the code behind it silently never runs. transport.js's was the seek->setTime sync:
   the chart clock would have quietly desynced after every seek, with nothing failing.
   All six now test window.highway.

3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114
   and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep
   searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch
   swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0.

Regex missed a shadow, a half-conversion, and two bare reads. The final check is an
AST pass that resolves scopes and reports every `highway` identifier not bound locally.
It now reports ZERO.

VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors:
window.highway is the same object as the bare global, the whole API surface resolves, a real
song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes,
which is the exact guard I nearly broke in (2).

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:56:07 +02:00
23ecddc721 test(highway): the R3c perf gate — measure the render loop before carving it (R3c) (#910)
highway.getPerf() (additive) + tests/browser/highway-perf-baseline.spec.ts.
No behaviour change. This lands BEFORE highway.js is touched, because a perf-gated refactor
without a perf gate is just a refactor.

━━━ FRAME RATE IS THE WRONG THING TO MEASURE ━━━

The highway AUTO-SCALES. When the smoothed draw cost passes _DRAW_BUDGET_HI_MS (12ms) it
LOWERS THE RENDER RESOLUTION to protect the frame rate (#654). Exactly right for players —
and it means a real perf regression does NOT show up as dropped frames. It shows up as a
BLURRIER PICTURE at a perfectly healthy 60fps.

Benchmark fps and you measure the feedback loop, not the renderer, and conclude nothing
changed while the image quietly degrades.

So the gate pins the scale (setRenderScale(1) + setMinRenderScale(1), which clamps autoScale
to [1,1]) and measures drawMs — the renderer's own cost. None of that was reachable before:
neither drawMs nor the effective scale escaped the closure. Hence getPerf().

The threshold is the app's OWN: _DRAW_BUDGET_HI_MS is the cost at which the highway itself
starts sacrificing resolution in production. Exceeding it is not an arbitrary benchmark line
— it is the renderer failing its own budget. Current cost ~2.2ms, so ~5x headroom: far more
than headless-CI variance, far less than any regression worth shipping.

━━━ I WROTE THIS GATE WRONG THREE TIMES. EACH TIME IT PASSED. ━━━

1. VACUOUS ASSERTION. First cut asserted "the auto-scaler wasn't forced to intervene", i.e.
   effectiveScale == 1. I injected a 10x regression (drawMs 2.4 -> 22.4ms, nearly DOUBLE the
   budget) and it PASSED. Of course it did: setMinRenderScale(1) sets the scaler's FLOOR to
   1, so effectiveScale CANNOT drop below it. The very pinning that stops the scaler hiding
   a regression also stops it ever reporting one. A guard that cannot fail.

2. MEASURING AN IDLE RENDERER (Codex [P2]). playSong() takes ~3-4s to actually start — it is
   fetching and decoding stems. My "if not playing after 2s, togglePlay()" fired BEFORE
   autoplay, started playback, and then the app's own autoplay toggled it straight back to
   PAUSED. The renderer idled through the entire measurement. Now it WAITS for playback
   rather than racing it, and asserts the chart clock advanced DURING the sampling window —
   not merely at some point beforehand, which the first fix would have accepted.

3. UNENCODED FILENAME (Codex [P2]). playSong() decodes its argument before building the
   /ws/highway path, so every real caller passes encodeURIComponent(filename)
   (app.js:2879, 4137). Raw, a name containing # ? % or / yields an invalid WebSocket URL,
   the song never loads — and on those libraries the gate would have silently measured an
   idle renderer instead of failing.

Every one of those bugs made the gate PASS. That is the whole hazard of a perf test: it
fails safe in the wrong direction.

BITE-TESTED, and this is the only reason I trust it: a 10x regression injected into the draw
path FAILS the gate under live playback (22.0ms vs the 12ms budget) and the clean build
passes at ~2.1ms with the chart clock advancing 5.6s across the sample.

node 1045, pytest 2412, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:36:11 +02:00
79825af28e fix(demo): the janitor re-entry guard actually works now (#902) (#909)
The guard in startup_events() read:

    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-already-started half
never ran when the env var was truthy — the only case that reaches it at all. A second
startup started a SECOND janitor thread, overwrote the handle, and shutdown then joined
only the last: the first leaked and kept firing registered hooks hourly, forever.

The guard now lives INSIDE start_janitor(). A caller cannot get operator precedence wrong
if there is nothing left for it to get wrong.

━━━ THREE WAYS TO WRITE THIS GUARD WRONG. I HIT ALL THREE. ━━━

1. NO GUARD — the original bug. Double-start, orphaned thread.

2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). Codex [P2]. stop_janitor()
   DELIBERATELY leaves that flag True when a hook outruns its join timeout, so that a later
   startup cannot spawn a janitor beside a live one. But the hook usually finishes a moment
   later: the thread exits and the flag is stale. A flag-keyed guard then refuses to start a
   replacement for the rest of the process — demo cleanup silently dead. (The original bug
   accidentally MASKED this by always starting.)

3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). Codex [P2], second pass. A
   timed-out stop leaves the old thread ALIVE BUT DOOMED — its stop event is set and it
   exits as soon as its current hook returns. Treating that as a running janitor skips the
   replacement, 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 EACH JANITOR NOW OWNS ITS STOP EVENT ━━━

start_janitor() used to `_DEMO_JANITOR_STOP.clear()` a single SHARED Event. Start a
replacement while a doomed thread is still finishing a hook and that clear RESURRECTS it: it
loops back to wait(), sees the flag cleared, and carries on. Two janitors — the exact bug we
started from. A fresh Event per janitor makes it impossible; the old thread waits on its own
event, which stays set, so it can only exit.

Env semantics UNCHANGED, verified across every value ("", "1", "0", "true", "false", "off"):
the old expression and demo_mode_enabled() agree on all of them. The only behavioural change
is the idempotency fix.

FOUR tests, and each of the three wrong guards fails a different subset:

    no guard              -> 2 fail   (double start; orphaned thread)
    guard on the flag     -> 2 fail   (never restarts after a timed-out stop)
    liveness alone        -> 1 fail   (no replacement for a doomed janitor)
    liveness + not-stopping -> all pass

pytest 2416, pyflakes 0, Codex 0.

Closes #902

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:30:53 +02:00
byrongamatosandClaude Fable 5 99b6d3c384 fix(career): exclude orphaned song_stats from star totals
Codex preflight: scans hide rather than delete stats of removed songs, so
stars now apply the same existing-song filter other stats surfaces use
(filename IN (SELECT filename FROM songs)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:56:48 +02:00
byrongamatosandClaude Fable 5 0cc08ebebf fix(career): generation-guard in-flight manifest fetches
Codex preflight: a manifest fetch resolving after a newer refresh (pack
deleted, venue switched) could re-apply a stale pack over the user's
newer selection — fetches now carry a generation token and bail when
superseded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:54:02 +02:00
byrongamatosandClaude Fable 5 a2a48b3912 fix(career): keep manifest cleanup path alive on delete; badge only for installed venues
Codex preflight: nulling _appliedManifestVenue on delete skipped
pushCrowdManifest's setManifest(null) cleanup, leaving the crowd layer on
a deleted pack; and the 'playing here' badge showed for an override venue
whose pack was removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:51:19 +02:00
OmikronApexandGitHub db3ca34fcb Merge pull request #906 from got-feedBack/fix/loopback-raw-audio
ship-ci / ci (push) Waiting to run
fix(static): raw stereo loopback capture — no voice-call DSP (tin-can fix)
2026-07-12 01:51:17 +02:00
byrongamatosandClaude Fable 5 05a9bee38f feat(career): career plugin — stars from song_stats, venue tiers, pack downloads (career mode PR2)
Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★),
cumulative stars unlock bar → club → arena (data-driven venues.json).
Venue packs (UE-rendered crowd loops) download on demand to
CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 +
zip-slip validation, served via FileResponse. Career screen (promoted
sidebar entry) shows progress and pushes the active venue's manifest
into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when
either side is absent. Pack URLs land in venues.json in PR3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:48:02 +02:00
byrongamatosandClaude Fable 5 286a24214f fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer
Codex preflight round 6: the loop crossfade's completion callback could
still run between a stinger's start and its canplaythrough, promoting the
stinger's layer to active and pausing the real loop — it now bails when
the fading loop was preempted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:35:17 +02:00
byrongamatosandClaude Fable 5 9178959dbd fix(venue-crowd): generation-gate stinger handlers; recrop on video size change
Codex preflight round 5: (1) an ended/timeout handler orphaned by stop()
could fire into a later stinger's lifecycle on the reused element — handlers
now detach unconditionally and carry a generation token; (2) the renderer
only re-applied cover-crop on camera aspect changes, so a src swap with a
different intrinsic size kept stale repeat/offset — it now recrops when
videoWidth/Height change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:33:12 +02:00
byrongamatosandClaude Fable 5 de10e81259 fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap
Codex preflight round 4: (1) idleLayer() still points at the fading-in
layer during a crossfade, so a stinger firing mid-fade overwrote the new
loop with nothing requeued — the fading loop is now tracked and requeued
like an in-flight load; (2) swapping venue packs while active now goes
through stop() so _stopGen invalidates the old manifest's in-flight loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:29:38 +02:00
byrongamatosandClaude Fable 5 1172bc30cb fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events
Codex preflight round 3: (1) a failed/timed-out stinger left a deferred
loop switch queued forever; the failure path now flushes it. (2)
stats:recorded only carries {filename, arrangement}, so the end-of-song
reaction now uses the accuracyPct from the song's last
v3:live-performance-state event (a real percentage) instead of a field
that never existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:26:47 +02:00
byrongamatosandClaude Fable 5 5e5d892a63 fix(venue-crowd): per-video load tokens + unbind renderer on stop
Codex preflight round 2: (1) the global load token let a stinger cancel a
committed loop load on the other layer — tokens are now per-element, and a
stinger preempting an in-flight loop on its own layer requeues that loop
for when the stinger ends; (2) setManifest(null)/deactivate left the last
crowd frame bound and visible over the static plate — stop() now unbinds
both layers from the renderer and zeroes the mix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:24:11 +02:00
byrongamatosandClaude Fable 5 cab652d145 fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions
Codex preflight P2s: (1) videos created before highway_3d registered its
globals never reached the backdrop planes — binding is now idempotent and
retried from start/perf-event/re-activation paths; (2) a crowd-state
switch committing while a stinger played was dropped because the machine
had already advanced — it is now deferred and played when the stinger ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:21:19 +02:00
byrongamatosandClaude Fable 5 78dbb039b7 feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1)
Two crossfading video backdrop planes in the highway_3d venue background
style, driven by a new venue-crowd.js state machine that maps
v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic)
with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers
on streak milestones and end-of-song accuracy. Inert without a venue pack
manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the
static bg plate behaves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:17:58 +02:00
73 changed files with 7636 additions and 4013 deletions
+3
View File
@@ -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__/
+1 -1
View File
@@ -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
+44 -6
View File
@@ -288,17 +288,55 @@ def demo_mode_enabled() -> bool:
def start_janitor() -> None:
"""Start the hourly session janitor. Called from server.py's startup hook.
"""Start the hourly session janitor, at most one at a time. server.py's startup hook.
NB the caller's guard is the buggy one described in this module's header (issue #902).
Behaviour is preserved verbatim: this starts a thread every time it is called.
━━━ THE GUARD ASKS "IS A HEALTHY JANITOR RUNNING?", AND NOTHING ELSE ━━━
Three ways to get this wrong, and #902 plus two Codex passes found all three:
1. NO GUARD (the original #902 bug). The re-entry check lived at the call site as
`A or (B and C)`, so it never ran, and a second startup started a SECOND thread,
overwrote the handle, and left the first to fire hooks forever, unjoinable.
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). stop_janitor() deliberately
leaves that flag True when a hook outruns its join timeout — so once that hook
finishes and the thread exits, the flag is stale and a later startup would refuse to
start a replacement. Demo cleanup silently dead for the rest of the process.
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). A timed-out stop leaves the
old thread ALIVE BUT DOOMED — its stop event is set, and it exits the moment its
current hook returns. Treating it as a running janitor means the replacement is never
started, and we are back at (2) a second later.
So a janitor counts as running only if its thread is alive AND it has not been told to
stop.
━━━ AND WHY EACH JANITOR OWNS ITS STOP EVENT ━━━
This used to `_DEMO_JANITOR_STOP.clear()` a single shared Event. If a replacement were
started while a doomed thread was still finishing a hook, clearing the shared event would
RESURRECT it — it loops back to `stop.wait()`, sees the flag cleared, and carries on.
Two janitors, which is the exact bug we started from.
A fresh Event per janitor makes that impossible: the old thread waits on its OWN event,
which stays set forever, so it can only exit.
"""
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _DEMO_JANITOR_STOP
thread = _DEMO_JANITOR_THREAD
if thread is not None and thread.is_alive() and not _DEMO_JANITOR_STOP.is_set():
return # a healthy janitor is already running
# Either there is no janitor, or the previous one is dead / dying. Give the new one its
# OWN stop event so the old one stays stopped no matter what we do to ours.
stop = threading.Event()
_DEMO_JANITOR_STOP = stop
_DEMO_JANITOR_STARTED = True
_DEMO_JANITOR_STOP.clear()
def _janitor():
while not _DEMO_JANITOR_STOP.wait(timeout=3600):
# Closes over `stop`, NOT the module global — a later start_janitor() rebinds
# _DEMO_JANITOR_STOP, and this thread must keep watching the event it was born with.
while not stop.wait(timeout=3600):
with _DEMO_JANITOR_HOOKS_LOCK:
hooks = list(_DEMO_JANITOR_HOOKS)
for hook in hooks:
+89 -3
View File
@@ -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.
+66
View File
@@ -0,0 +1,66 @@
/* Career plugin — only what the prebuilt core Tailwind doesn't ship
(plugin files are outside the core content glob, so responsive grid
variants and cyan button shades live here under plugin-prefixed names). */
.career-venues {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.career-btn {
font-size: 0.75rem;
line-height: 1rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
transition: background-color 0.15s ease;
}
.career-btn-primary { background-color: #0891b2; color: #fff; }
.career-btn-primary:hover { background-color: #06b6d4; }
.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; }
.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); }
.career-bar-track {
height: 0.5rem;
border-radius: 0.25rem;
background-color: rgba(31, 41, 55, 0.9);
overflow: hidden;
}
.career-bar-fill {
height: 100%;
background-color: #06b6d4;
transition: width 0.3s ease;
}
.career-star-list {
display: grid;
gap: 0.375rem;
}
.career-star-row {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
background-color: rgba(31, 41, 55, 0.4);
font-size: 0.8rem;
}
.career-star-row .stars {
color: #facc15;
letter-spacing: 0.1em;
min-width: 3.2em;
}
.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); }
.career-star-row .song {
color: #e5e7eb;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.career-star-row .song .artist { color: #9ca3af; }
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
.career-star-row .hint.close { color: #22d3ee; }
+16
View File
@@ -0,0 +1,16 @@
{
"id": "career",
"name": "Career",
"version": "0.1.0",
"bundled": true,
"private": false,
"description": "Career mode \u2014 gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
"screen": "screen.html",
"script": "screen.js",
"styles": "assets/career.css",
"routes": "routes.py",
"settings": {
"html": "settings.html",
"category": "system"
}
}
+292
View File
@@ -0,0 +1,292 @@
"""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.
Endpoints (all under /api/plugins/career/):
GET /state stars + per-venue unlock/install/download status
POST /packs/{venue_id}/download start background pack download (409 if running)
DELETE /packs/{venue_id} remove an installed pack
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
"""
import hashlib
import json
import logging
import re
import shutil
import tempfile
import threading
import urllib.request
import zipfile
from pathlib import Path
from fastapi import HTTPException
from fastapi.responses import FileResponse
PLUGIN_ID = "career"
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
_lock = threading.Lock()
_state = {
"content": None, # parsed venues.json
"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 = sum(1 for t in thresholds if acc >= t)
if stars:
per_song[filename] = stars
next_at = next((t for t in thresholds if acc < t), None)
detail.append({
"filename": filename,
"title": title or filename,
"artist": artist,
"stars": stars,
"best_accuracy": round(acc, 4),
"next_star_at": next_at,
})
# closest-to-next-star first (a practice worklist), maxed songs last
detail.sort(key=lambda r: (r["next_star_at"] is None,
(r["next_star_at"] or 1.0) - r["best_accuracy"]))
return sum(per_song.values()), per_song, detail
def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json"
if not manifest_path.is_file():
raise ValueError("pack has no manifest.json")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
loops = manifest.get("loops") or {}
for state in REQUIRED_LOOPS:
name = loops.get(state)
if not name or not PACK_FILENAME_RE.fullmatch(name):
raise ValueError(f"manifest is missing the '{state}' loop")
if not (pack_dir / name).is_file():
raise ValueError(f"loop file '{name}' missing from pack")
for name in (manifest.get("stingers") or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"stinger file '{name}' invalid or missing")
for block in ("intro", "sfx"):
for name in (manifest.get(block) or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"{block} file '{name}' invalid or missing")
def _download_pack(venue_id, pack, progress):
"""Worker thread: stream → sha256 verify → extract → validate → swap in."""
log = _state["log"]
final_dir = _venue_dir(venue_id)
staging = Path(tempfile.mkdtemp(prefix=f"career-{venue_id}-",
dir=str(_state["venues_dir"])))
zip_path = staging / "pack.zip"
try:
digest = hashlib.sha256()
req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-career"})
with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out:
total = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0)
progress["bytes_total"] = total
while True:
chunk = resp.read(DOWNLOAD_CHUNK)
if not chunk:
break
digest.update(chunk)
out.write(chunk)
progress["bytes_done"] += len(chunk)
if digest.hexdigest() != pack["sha256"]:
raise ValueError("sha256 mismatch — corrupt or tampered download")
extract_dir = staging / "pack"
extract_dir.mkdir()
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
# Zip-slip guard: only flat, whitelisted names get extracted.
if info.is_dir():
continue
name = Path(info.filename).name
if name != info.filename or not PACK_FILENAME_RE.fullmatch(name):
raise ValueError(f"unexpected file in pack: {info.filename!r}")
with zf.open(info) as src, open(extract_dir / name, "wb") as dst:
shutil.copyfileobj(src, dst)
zip_path.unlink()
_validate_pack_dir(extract_dir)
if final_dir.exists():
shutil.rmtree(final_dir)
extract_dir.rename(final_dir)
progress["status"] = "done"
log.info("career: venue pack '%s' installed", venue_id)
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
progress["status"] = "error"
progress["error"] = str(exc)
log.warning("career: venue pack '%s' download failed: %s", venue_id, exc)
finally:
shutil.rmtree(staging, ignore_errors=True)
def setup(app, context):
plugin_dir = Path(__file__).resolve().parent
_state["plugin_dir"] = plugin_dir
_state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8"))
_state["venues_dir"] = (
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
_state["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"]
for v in _state["content"]["venues"]:
if _bundled(v["id"]):
_validate_pack_dir(_bundled_venue_dir(v["id"]))
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
def get_state():
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.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"},
)
+21
View File
@@ -0,0 +1,21 @@
<div class="max-w-5xl mx-auto px-4 py-6">
<div class="flex items-end justify-between flex-wrap gap-3 mb-1">
<h1 class="text-2xl font-bold text-white">Career</h1>
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
</div>
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
+280
View File
@@ -0,0 +1,280 @@
/*
* Career plugin — venue progression UI + crowd-manifest push.
*
* Reads /api/plugins/career/state (stars from song_stats, per-venue
* unlock/install/download status), renders the career screen, and pushes the
* active venue's pack manifest into the crowd video layer
* (window.v3VenueCrowd, shipped with the venue crowd PR) whenever it changes.
* Everything degrades: no crowd layer → screen still works; no packs → the
* venue scene keeps its static plate.
*/
(function () {
'use strict';
const API = '/api/plugins/career';
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
const NO_VENUE = '__none__';
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
const POLL_MS = 2000;
let _state = null;
let _pollTimer = 0;
let _appliedManifestVenue = null;
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
let _prevUnlockedIds = null;
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function fetchState() {
const res = await fetch(API + '/state');
if (!res.ok) throw new Error('career state ' + res.status);
return res.json();
}
function lastOf(arr) { return arr.length ? arr[arr.length - 1] : null; }
// Active pack = localStorage override when unlocked+installed, else the
// highest unlocked+installed tier; none → clear the crowd manifest.
async function pushCrowdManifest(state) {
const crowd = window.v3VenueCrowd;
if (!crowd || typeof crowd.setManifest !== 'function') return;
// Any newer invocation (delete, venue switch, fresher state) must win
// over a manifest fetch still in flight from this one.
const gen = ++_manifestReqGen;
const unlocked = state.venues.filter((v) => v.unlocked);
let venue = null;
let override = null;
try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ }
if (override !== NO_VENUE) {
venue = unlocked.find((v) => v.id === override && v.installed) || null;
if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
}
if (!venue) {
if (_appliedManifestVenue !== null) {
_appliedManifestVenue = null;
crowd.setManifest(null);
}
return;
}
if (venue.id === _appliedManifestVenue) return;
try {
const res = await fetch(`${API}/venues/${venue.id}/manifest.json`);
if (gen !== _manifestReqGen || !res.ok) return;
const manifest = await res.json();
if (gen !== _manifestReqGen) return;
manifest.base = `${API}/venues/${venue.id}/`;
_appliedManifestVenue = venue.id;
crowd.setManifest(manifest);
} catch (_) { /* pack half-installed; next refresh retries */ }
}
function venueCardHTML(v, state) {
const locked = !v.unlocked;
const dl = v.download || { status: 'idle' };
const pct = dl.bytes_total > 0
? Math.round((dl.bytes_done / dl.bytes_total) * 100) : 0;
let action = '';
if (locked) {
action = `<div class="text-xs text-gray-500">Unlocks at ${v.star_threshold} ★ — ${Math.max(0, v.star_threshold - state.stars_total)} to go</div>`;
} else if (dl.status === 'running') {
action = `<div class="career-bar-track mb-1" style="height:0.375rem"><div class="career-bar-fill" style="width:${pct}%"></div></div>
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
} else if (v.installed) {
const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
const main = active
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
const remove = v.bundled
? ''
: `<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>`;
action = `<div class="flex items-center gap-2">
${main}
${remove}
</div>`;
} else if (v.has_pack) {
const err = dl.status === 'error'
? `<div class="text-xs text-amber-400 mb-1">${esc(dl.error || 'Download failed')} — try again</div>` : '';
action = `${err}<button data-career-download="${esc(v.id)}" class="career-btn career-btn-primary">Download venue pack</button>`;
} else {
action = '<div class="text-xs text-gray-500">Venue pack coming soon — plays with the standard stage for now</div>';
}
// Mirror pushCrowdManifest(): an override only counts while the pack
// is installed — after a removal the badge must not claim a venue the
// crowd layer can't use.
const isActive = !locked && v.installed &&
localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
return `<div class="rounded-xl border ${locked ? 'border-gray-800 opacity-60' : 'border-gray-700'} bg-dark-700/40 p-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-white">${esc(v.name)}${isActive ? ' <span class="text-cyan-400 text-xs">● playing here</span>' : ''}</div>
<div class="text-xs text-gray-400">${v.star_threshold} ★</div>
</div>
<div class="text-xs text-gray-400 flex-1">${esc(v.description)}</div>
${action}
</div>`;
}
function starGlyphs(n) {
let out = '';
for (let i = 0; i < 3; i++) {
out += `<span class="${i < n ? 'on' : 'off'}">★</span>`;
}
return out;
}
function renderStars(state) {
const list = $('career-star-list');
const summary = $('career-star-summary');
if (!list || !summary) return;
const detail = state.star_detail || [];
const tiers = [0, 0, 0, 0];
for (const r of detail) tiers[r.stars]++;
summary.textContent =
`${tiers[3]}× 3★ · ${tiers[2]}× 2★ · ${tiers[1]}× 1★ · ${tiers[0]} unstarred`;
if (!detail.length) {
list.innerHTML = '<div class="text-xs text-gray-500">Play songs to start collecting stars — 60% accuracy earns the first one.</div>';
return;
}
list.innerHTML = detail.map((r) => {
let hint = 'maxed';
let close = '';
if (r.next_star_at != null) {
const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100;
hint = `${gap.toFixed(0)}% to next ★`;
if (gap <= 5) close = ' close';
}
return `<div class="career-star-row">
<span class="stars">${starGlyphs(r.stars)}</span>
<span class="song">${esc(r.title)}${r.artist ? ` <span class="artist">— ${esc(r.artist)}</span>` : ''}</span>
<span class="hint${close}">best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}</span>
</div>`;
}).join('');
}
function render(state) {
const host = $('career-venues');
if (!host) return;
$('career-stars-summary').textContent = `${state.stars_total} total`;
const next = state.venues.find((v) => !v.unlocked);
const bar = $('career-progress-bar');
const label = $('career-progress-label');
if (next) {
const prevThreshold = state.venues
.filter((v) => v.unlocked)
.reduce((m, v) => Math.max(m, v.star_threshold), 0);
const span = Math.max(1, next.star_threshold - prevThreshold);
const into = Math.max(0, state.stars_total - prevThreshold);
bar.style.width = Math.min(100, Math.round((into / span) * 100)) + '%';
label.textContent = `${state.stars_total} / ${next.star_threshold} ★ to unlock ${next.name}`;
} else {
bar.style.width = '100%';
label.textContent = 'All venues unlocked — enjoy the arena.';
}
host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join('');
renderStars(state);
}
function schedulePoll(state) {
clearTimeout(_pollTimer);
if (state.venues.some((v) => (v.download || {}).status === 'running')) {
_pollTimer = setTimeout(refresh, POLL_MS);
}
}
function announceUnlocks(state) {
const unlocked = state.venues.filter((v) => v.unlocked).map((v) => v.id);
if (_prevUnlockedIds) {
for (const v of state.venues) {
if (v.unlocked && !_prevUnlockedIds.includes(v.id)) {
const sm = window.feedBack;
if (sm && typeof sm.emit === 'function') {
sm.emit('career:venue-unlocked', { id: v.id, name: v.name });
}
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
window.fbNotify.show({
big: true, icon: '🎤', accent: '#06B6D4',
title: 'New venue unlocked!',
message: `${v.name} — your crowd just got bigger.`,
});
}
}
}
}
_prevUnlockedIds = unlocked;
}
async function refresh() {
let state;
try {
state = await fetchState();
} catch (_) {
return; // server restarting; next trigger retries
}
_state = state;
announceUnlocks(state);
render(state);
schedulePoll(state);
pushCrowdManifest(state);
}
function onClick(e) {
const dlBtn = e.target.closest('[data-career-download]');
const delBtn = e.target.closest('[data-career-delete]');
const playBtn = e.target.closest('[data-career-play]');
if (dlBtn) {
fetch(`${API}/packs/${dlBtn.dataset.careerDownload}/download`, { method: 'POST' })
.then(refresh);
} else if (delBtn) {
// Do NOT null _appliedManifestVenue here: pushCrowdManifest()
// clears/replaces the crowd manifest precisely by seeing that the
// applied venue is no longer among the installed ones.
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
.then(refresh);
} else if (playBtn) {
try {
localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay);
// Selecting a venue makes the Venue visualization the default;
// remember what the user had so Leave venue can restore it.
const cur = localStorage.getItem('vizSelection');
if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur);
localStorage.setItem('vizSelection', 'venue');
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* ok */ }
_appliedManifestVenue = null; // force manifest re-push
refresh();
} else if (e.target.closest('[data-career-unselect]')) {
try {
localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE);
const prev = localStorage.getItem(PREV_VIZ_KEY);
if (prev) {
localStorage.setItem('vizSelection', prev);
if (typeof window.setViz === 'function') window.setViz(prev);
}
} catch (_) { /* ok */ }
// keep _appliedManifestVenue: pushCrowdManifest clears the crowd
// manifest precisely by seeing it is still set with no venue left
refresh();
}
}
function boot() {
const screen = document.getElementById('plugin-career');
if (screen) screen.addEventListener('click', onClick);
const sm = window.feedBack;
if (sm && typeof sm.on === 'function') {
// New song stats can add stars → thresholds may cross mid-session.
sm.on('stats:recorded', () => refresh());
}
refresh();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
+21
View File
@@ -0,0 +1,21 @@
<div class="space-y-3 text-sm">
<label class="flex items-center justify-between gap-4">
<span>
<span class="text-gray-200 font-medium">Crowd sound reactions</span>
<span class="block text-xs text-gray-500">Cheers when the crowd's mood rises, boos when it drops. Uses each venue's own recordings.</span>
</span>
<input type="checkbox" id="career-sfx-toggle" class="accent-cyan-500 w-4 h-4">
</label>
</div>
<script>
(function () {
'use strict';
var KEY = 'feedBack-venue-crowd-sfx';
var box = document.getElementById('career-sfx-toggle');
if (!box) return;
try { box.checked = localStorage.getItem(KEY) === 'on'; } catch (e) { /* ok */ }
box.addEventListener('change', function () {
try { localStorage.setItem(KEY, box.checked ? 'on' : 'off'); } catch (e) { /* ok */ }
});
}());
</script>
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,18 @@
{
"venue": "bar",
"version": 1,
"loops": {
"bored": "bored.mp4",
"neutral": "neutral.mp4",
"engaged": "engaged.mp4",
"ecstatic": "ecstatic.mp4"
},
"stingers": {
"clap": "clap.mp4",
"cheer": "cheer.mp4"
},
"intro": {
"video": "intro.mp4",
"audio": "bar-ambience.mp3"
}
}
Binary file not shown.
+30
View File
@@ -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
}
]
}
+126
View File
@@ -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).
+70 -7
View File
@@ -18,7 +18,7 @@ configure_logging()
log = logging.getLogger("feedBack.server")
from fastapi import FastAPI, File
from fastapi import FastAPI, File, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
@@ -47,6 +47,7 @@ import appstate
import builtin_content
import demo_mode
import scan
import tailwind_rebuild
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
from routers import tunings as tunings_router
@@ -970,12 +971,11 @@ async def startup_events():
else:
threading.Thread(target=_load_plugins_background, daemon=True).start()
# NB the `or ... == "1" and not started` shape below is PRESERVED VERBATIM: `and` binds
# tighter than `or`, so the re-entry guard is dead whenever the env var is truthy, and a
# second startup leaks a janitor thread. That is issue #902 — not fixed here, because a
# carve whose value is being provably behaviour-neutral is not the place to change
# behaviour.
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" and not demo_mode.janitor_started():
# start_janitor() is idempotent (#902). The re-entry guard used to be spelled out here
# as `... or ... == "1" and not started`, which parses as `A or (B and C)` — so the
# not-already-started half never ran, and a second startup leaked a janitor thread. The
# guard lives inside start_janitor() now, where no caller can get precedence wrong.
if demo_mode.demo_mode_enabled():
demo_mode.start_janitor()
# Start background metadata scan
@@ -1640,6 +1640,69 @@ class _RevalidatedStaticFiles(StaticFiles):
return response
# ── The Tailwind stylesheet: runtime-augmented if there is one, else the committed build ──
#
# Two different things used to share one path (#911):
#
# static/tailwind.min.css a BUILD ARTEFACT. Committed, image-baked, generated from the
# in-tree plugins only. CI (`tailwind-fresh`) verifies it.
# the RUNTIME sheet PER-INSTALL STATE. Additionally scans whatever the user installed
# into FEEDBACK_PLUGINS_DIR, so it differs machine to machine.
#
# Writing the second over the first meant that merely RUNNING THE DEV SERVER from a git
# checkout silently modified a tracked file. `git add -A` then swept a 100KB reshuffle of
# minified CSS into the commit and ci/tailwind-fresh went red with a diff that explained
# nothing — on a PR whose real change touched no Tailwind classes at all. It also meant writing
# app state into the app directory, which is read-only in some deploys.
#
# The runtime sheet lives in CONFIG_DIR now. This route serves it when it exists and otherwise
# falls through to the committed one. It MUST be registered BEFORE the /static mount: routes
# are matched in order, and the mount would otherwise swallow the path.
def _runtime_css_if_usable() -> Path | None:
"""The runtime sheet, but ONLY when it is actually the right answer.
Codex [P2] on the first cut of #911, and it was right: a persisted sheet can outlive the
reason it existed and then MASK newer core CSS indefinitely. Two ways:
* THE USER REMOVED THEIR PLUGINS. Startup only rebuilds when there are user plugins, so
nothing would ever overwrite the old sheet — and it still carries classes for plugins
that are gone, while missing nothing. With no user plugins the COMMITTED sheet is by
definition complete and authoritative.
* THE APP WAS UPGRADED. A new release ships new core classes in static/tailwind.min.css.
The runtime sheet on disk predates them. Serving it hides the new CSS until something
happens to trigger a rebuild — which, if the toolchain is absent (no node), is never.
Freshness is decided by CONTENT, not mtime. Codex [P2] again, and again correct: archives
and container images routinely PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can
carry an older timestamp than a runtime sheet built days ago — and an mtime check would call
the stale one fresh. tailwind_rebuild stamps each runtime build with the hash of the
committed sheet it was made from; a core upgrade changes that file, hence that hash.
Falling back to the committed sheet is always safe: at worst it lacks a just-installed
plugin's classes for the seconds until the async rebuild lands.
"""
runtime = tailwind_rebuild.runtime_css_path()
if not runtime.is_file():
return None
if tailwind_rebuild.user_plugin_count() == 0:
return None
if not tailwind_rebuild.runtime_css_is_current():
return None # built against a different core — see runtime_css_is_current()
return runtime
@app.get("/static/tailwind.min.css")
def tailwind_css(request: Request):
target = _runtime_css_if_usable() or (STATIC_DIR / "tailwind.min.css")
if not target.is_file():
return Response("", status_code=404)
# Same cache contract the /static mount applies (_RevalidatedStaticFiles): no-cache, so the
# browser always revalidates and picks up a rebuild without a hard refresh.
resp = FileResponse(str(target), media_type="text/css")
resp.headers["Cache-Control"] = "no-cache"
return resp
app.mount("/static", _RevalidatedStaticFiles(directory=str(STATIC_DIR)), name="static")
+103 -2229
View File
File diff suppressed because it is too large Load Diff
+147 -1645
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -60,7 +60,7 @@ const _CREDITS_HOLD_MS = 3000;
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
// a count-in handoff that never plays). This hard cap guarantees the credits
// never linger over the highway. Generous enough to outlast a normal count-in.
// never linger over the window.highway. Generous enough to outlast a normal count-in.
const _CREDITS_MAX_MS = 12000;
export function _cancelCountIn() {
_countInGen++;
@@ -107,7 +107,7 @@ function _creditLineLabel(role) {
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
}
// Show the feedpak contributor credits over the highway. `authors` is the
// Show the feedpak contributor credits over the window.highway. `authors` is the
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
// Anchored to the lower third (bottom-center) so it never collides with the
// vertically-centered count-in number, and pointer-events-none so it never
@@ -196,7 +196,7 @@ export async function startCountIn(opts = {}) {
return;
}
S.lastAudioTime = loopA;
highway.setTime(loopA);
window.highway.setTime(loopA);
if (window.feedBack) {
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
}
@@ -217,7 +217,7 @@ export async function startCountIn(opts = {}) {
// Ease out quad
const eased = 1 - (1 - t) * (1 - t);
const currentT = fromTime + (toTime - fromTime) * eased;
highway.setTime(currentT);
window.highway.setTime(currentT);
if (t < 1) {
_countInRaf = requestAnimationFrame(rewindStep);
} else {
@@ -262,7 +262,7 @@ export async function startCountIn(opts = {}) {
// marker for "new iteration starts at A", not the actual
// audio position.
S.lastAudioTime = r.to;
highway.setTime(r.to);
window.highway.setTime(r.to);
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
beginCount();
});
@@ -271,7 +271,7 @@ export async function startCountIn(opts = {}) {
_countInRaf = requestAnimationFrame(rewindStep);
function beginCount() {
const bpm = highway.getBPM(loopA);
const bpm = window.highway.getBPM(loopA);
const beatInterval = 60 / bpm;
let count = 0;
@@ -339,7 +339,7 @@ export async function startSongCountIn() {
}
if (gen !== _countInGen) return; // teardown during pause
const startT = S.lastAudioTime || 0;
let bpm = highway.getBPM(startT);
let bpm = window.highway.getBPM(startT);
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
const beatInterval = 60 / bpm;
+258
View File
@@ -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.
}
+1 -1
View File
@@ -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 (_) {}
+190
View File
@@ -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 05 cover guitar / bass; 67
// 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
+103
View File
@@ -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)];
}
+170
View File
@@ -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();
}
+2 -2
View File
@@ -111,7 +111,7 @@ import { S } from './player-state.js';
return false;
}
}
// highway.js's initial song-load routing consults this for the same
// window.highway.js's initial song-load routing consults this for the same
// feedpak-under-exclusive decision the watcher makes below.
window._juceOutputIsExclusive = _outputIsExclusive;
// Returns true when window._currentSongAudio no longer references the exact
@@ -383,7 +383,7 @@ import { S } from './player-state.js';
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
// are never routable (per-stem mix can't ride a single transport).
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
// Don't race highway.js's own initial song-load routing: it owns
// Don't race window.highway.js's own initial song-load routing: it owns
// _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL.
if (window._highwayJuceRoutingPending) return;
+2 -2
View File
@@ -160,7 +160,7 @@ export function _resetPlaybackSpeedForNewSong() {
//
// Debounced trailing-edge (300ms) so dragging the slider — which fires
// oninput per pixel — doesn't flood the server with concurrent writes
// to config.json. highway.setMastery() still fires every oninput so
// to config.json. window.highway.setMastery() still fires every oninput so
// the chart re-filters in real time; only disk persistence waits.
let _masteryPersistTimer = null;
function _persistMastery(pct) {
@@ -209,7 +209,7 @@ export function _applyMastery(v, opts = {}) {
// unlike #mastery-label above, whose markup carries no trailing unit.
const setLabel = document.getElementById('setting-highway-speed-val');
if (setLabel) setLabel.textContent = pct;
highway.setMastery(pct / 100);
window.highway.setMastery(pct / 100);
if (!opts.skipPersist) _persistMastery(pct);
}
// Reflect phrase-data availability on the slider after every `ready`.
+3 -3
View File
@@ -31,12 +31,12 @@ const _RESUME_END_GUARD_S = 5; // ignore basically-finished
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
// Snapshot the live session. Called from showScreen()'s teardown before
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
// window.highway.stop()/audio unload, while getSongInfo() + position are still valid.
export function _snapshotResumeSession(position) {
try {
if (!host.currentFilename()) return;
const si = (window.highway && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const si = (window.highway && typeof window.highway.getSongInfo === 'function')
? (window.highway.getSongInfo() || {}) : {};
const dur = Number(si.duration) || 0;
const pos = Number(position) || 0;
// Only worth resuming a song you were genuinely mid-way through — not a
+2 -2
View File
@@ -38,7 +38,7 @@ export function _sectionPracticeBarContains(el) {
}
// ── Section Practice Bar ────────────────────────────────────────────────
// One-click looping over song section markers (highway.getSections —
// One-click looping over song section markers (window.highway.getSections —
// same array as 3D highway bundle.sections / "Now / Up Next").
// Reuses setLoop() so manual A/B controls and saved loops stay canonical.
let _sectionPracticeRanges = [];
@@ -127,7 +127,7 @@ export function _resetSectionPracticeLog() {
}
function _sectionPracticeHighway() {
return window.highway || (typeof highway !== 'undefined' ? highway : null);
return window.highway || null;
}
function _sectionPracticeDuration() {
+748
View File
@@ -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');
}
+497
View File
@@ -0,0 +1,497 @@
// 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();
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();
}
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;
}
}
+983
View File
@@ -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();
}
});
+3 -3
View File
@@ -172,7 +172,7 @@ export function _songEventPayload() {
return {
time: audioT,
audioT,
chartT: highway.getTime(),
chartT: window.highway.getTime(),
perfNow: performance.now(),
};
}
@@ -289,8 +289,8 @@ export async function _audioSeek(s, reason) {
// _audioSeek resolves (e.g. the auto-resume song:play in
// changeArrangement) sees an in-sync chartT via _songEventPayload.
// Without this, chartT lags by one 60Hz tick after a seek.
if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
highway.setTime(to);
if (window.highway && typeof window.highway.setTime === 'function') {
window.highway.setTime(to);
}
window.feedBack.emit('song:seek', { from, to, reason: reason || null });
return { completed: true, from, to };
+19 -19
View File
@@ -62,7 +62,7 @@ function _hasPromotedFlag() {
// Pending nag: queued during _populateVizPicker, fired on the first
// `song:ready` (so the toast lands when the user actually opens the
// player, not at page load when they're still in the library).
// `song:ready` is emitted by highway.js via window.feedBack.emit(), so
// `song:ready` is emitted by window.highway.js via window.feedBack.emit(), so
// subscribe through the same EventTarget. window.feedBack is created in
// this same file before _populateVizPicker is reachable, so the global
// is guaranteed to exist by the time this listener registers — but guard
@@ -326,7 +326,7 @@ export async function _populateVizPicker(plugins) {
// plugin options — _autoMatchViz saw no candidates and left the
// default active. Now that plugins are registered, re-evaluate
// against whatever song is currently loaded (a no-op when no song
// has been loaded yet, since highway.getSongInfo() returns {}).
// has been loaded yet, since window.highway.getSongInfo() returns {}).
if (sel.value === 'auto') _autoMatchViz();
}
@@ -356,7 +356,7 @@ function _noteVizAutoMatch(id, matched) {
}
function _installVizRenderer(renderer, id, source = 'user-select') {
highway.setRenderer(_tagVizRenderer(renderer, id));
window.highway.setRenderer(_tagVizRenderer(renderer, id));
// Drop any stale notation-view hint now that we have a resolved renderer id.
// This is also the path used by _autoMatchViz() after it resolves 'auto' to
// a real plugin id, so the null passed at evaluation start is corrected here.
@@ -377,7 +377,7 @@ export function setViz(id) {
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
const sel = document.getElementById('viz-picker');
if (sel) sel.value = 'default';
highway.setRenderer(null);
window.highway.setRenderer(null);
_syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default');
@@ -399,7 +399,7 @@ export function setViz(id) {
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
const _sel = document.getElementById('viz-picker');
if (_sel) _sel.value = 'default';
highway.setRenderer(null);
window.highway.setRenderer(null);
_syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default');
@@ -483,7 +483,7 @@ export function setViz(id) {
fallbackToDefault();
return;
}
// Validate shape — highway.setRenderer will itself fall back to
// Validate shape — window.highway.setRenderer will itself fall back to
// default on a bad renderer, but without this check the UI and
// localStorage would still advertise the broken selection.
if (!renderer || typeof renderer.draw !== 'function') {
@@ -503,7 +503,7 @@ export function setViz(id) {
// Auto mode: evaluate each registered viz factory's static
// `matchesArrangement(songInfo)` predicate and install the first
// matching renderer. No match → fall back to the built-in 2D highway.
// matching renderer. No match → fall back to the built-in 2D window.highway.
//
// vizSelection stays 'auto' across invocations so the next song:ready
// re-evaluates. An explicit picker choice overrides Auto by persisting
@@ -530,10 +530,10 @@ function _setAutoVizLabel(resolvedText) {
let _cancelPendingAutoLabel = null;
// One-shot (per song) hint shown when a notation-only arrangement falls back
// to the built-in 2D highway. Such arrangements carry no wire notes
// to the built-in 2D window.highway. Such arrangements carry no wire notes
// (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so
// the default renderer draws an empty board — without this the user is left
// staring at a silently blank highway. Core ships no notation view; point at
// staring at a silently blank window.highway. Core ships no notation view; point at
// the viz picker instead.
let _notationHintShownFor = null;
function _showNotationViewHint(arrangementIndex, activeVizId) {
@@ -579,8 +579,8 @@ function _dropStaleNotationHint(activeVizId) {
const curFilename = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.filename) || '';
if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (window.highway.getSongInfo() || {}) : {};
const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null;
if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined
&& stale.dataset.arrangementIndex !== curArrIdx) {
@@ -593,8 +593,8 @@ function _dropStaleNotationHint(activeVizId) {
export function _maybeShowNotationViewHint(activeVizId) {
_dropStaleNotationHint(activeVizId);
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (window.highway.getSongInfo() || {}) : {};
const activeArr = Array.isArray(songInfo.arrangements)
? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index)
: null;
@@ -641,8 +641,8 @@ export function _autoMatchViz() {
// Reset label at evaluation start so a stale resolved label never persists
// if the song changes or the picker re-evaluates with a different outcome.
_setAutoVizLabel(null);
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const songInfo = (typeof window.highway?.getSongInfo === 'function')
? (window.highway.getSongInfo() || {}) : {};
// Only update the label when a real song is loaded. Before the first
// song_info frame, getSongInfo() returns {} — leaving the reset state
// ("Auto (match arrangement)") is correct; we haven't evaluated yet.
@@ -714,17 +714,17 @@ export function _autoMatchViz() {
_noteVizAutoMatch(id, true);
return;
}
// No match — restore the built-in 2D highway. setRenderer(null) is
// No match — restore the built-in 2D window.highway. setRenderer(null) is
// a no-op when the default is already active. If the previous Auto
// pick was a WebGL renderer, highway.setRenderer() handles the
// pick was a WebGL renderer, window.highway.setRenderer() handles the
// context-type change by replacing the canvas element (cloneNode +
// replaceWith) so the default 2D renderer's getContext('2d') always
// succeeds — no canvas-lock limitation here.
highway.setRenderer(null);
window.highway.setRenderer(null);
_notifyVizDomain('default', 'auto-match');
_noteVizAutoMatch('default', false);
// Update the label so the user can see Auto resolved to the built-in
// highway. Read from the DOM rather than hard-coding the name so a
// window.highway. Read from the DOM rather than hard-coding the name so a
// future rename of the default entry is automatically reflected.
if (hasSong) {
const defaultOpt = Array.from(sel.options).find(o => o.value === 'default');
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1241,7 +1241,7 @@
</main>
<!-- /#v3-main -->
<script defer src="/static/highway.js"></script>
<script type="module" src="/static/highway.js"></script>
<script defer src="/static/vendor/lottie.min.js"></script>
<script defer src="/static/lottie-api.js"></script>
<script type="module" src="/static/app.js"></script>
@@ -1275,6 +1275,7 @@
saved 'off'/'full' motion preference on first paint. -->
<script defer src="/static/v3/venue-mood-fx.js"></script>
<script defer src="/static/v3/venue-scene-3d.js"></script>
<script defer src="/static/v3/venue-crowd.js"></script>
<script defer src="/static/v3/playlists.js"></script>
<script defer src="/static/v3/audio-routing.js"></script>
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
+7 -7
View File
@@ -6,7 +6,7 @@
* auto-hiding bottom transport, and the speed-level visual (bars + chevrons).
*
* Design contract: the actual controls are the SAME legacy elements/handlers
* (ids unchanged), just relocated into rail popovers so app.js/highway.js
* (ids unchanged), just relocated into rail popovers so app.js/window.highway.js
* keep populating and reacting to them unmodified. This module only adds
* presentation behavior (open/close, reveal/hide, mirror state). It runs only
* while #player is the active screen.
@@ -146,8 +146,8 @@
const rail = $('v3-player-rail');
const lyr = rail && rail.querySelector('[data-rail-action="lyrics"]');
if (!lyr) return;
const on = (window.highway && typeof highway.getLyricsVisible === 'function')
? highway.getLyricsVisible()
const on = (window.highway && typeof window.highway.getLyricsVisible === 'function')
? window.highway.getLyricsVisible()
: lyr.classList.contains('is-active');
lyr.classList.toggle('is-active', !!on);
lyr.setAttribute('aria-pressed', on ? 'true' : 'false');
@@ -159,13 +159,13 @@
rail.querySelectorAll('[data-rail]').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); openPopFor(b); }));
// Mic icon: a direct lyrics toggle (clicks the hidden canonical button so
// highway.toggleLyrics() + any label logic runs), mirroring on/off state.
// window.highway.toggleLyrics() + any label logic runs), mirroring on/off state.
const lyr = rail.querySelector('[data-rail-action="lyrics"]');
if (lyr) lyr.addEventListener('click', (e) => {
e.stopPropagation();
const real = $('btn-lyrics');
if (real) real.click(); // runs highway.toggleLyrics() via its onclick
else if (window.highway && typeof highway.toggleLyrics === 'function') highway.toggleLyrics();
if (real) real.click(); // runs window.highway.toggleLyrics() via its onclick
else if (window.highway && typeof window.highway.toggleLyrics === 'function') window.highway.toggleLyrics();
syncLyricsIcon(); // reflect the ACTUAL toggled state, not click parity
});
// Click-outside + Esc close (bound once; harmless when no popover open).
@@ -288,7 +288,7 @@
if (t - lastUpNext >= UPNEXT_MS) {
lastUpNext = t;
updateUpNext();
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
// Re-sync the lyrics icon so programmatic window.highway.setLyricsVisible()
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
syncLyricsIcon();
// Reconcile the edge-driven hover flag against ground truth at
+40 -11
View File
@@ -48,6 +48,7 @@
// above. Screens are injected async by the plugin loader, so go()'s
// plugin- guard applies.
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
{ key: 'career', screen: 'plugin-career', label: 'Career', group: null, icon: 'trophy' },
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
{ key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' },
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
@@ -60,6 +61,7 @@
// that group. Each is gated on the plugin actually being installed.
const PROMOTED_PLUGINS = [
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
{ navKey: 'career', pluginId: 'career', slotId: 'v3-nav-career', anchorAfter: 'feedbarcade' },
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
@@ -318,22 +320,49 @@
}
}
// ── showScreen wrapper (idempotent rehydration — design/05 §Rehydration) ─
// ── Stay in sync with the active screen (idempotent rehydration — design/05) ─
//
// This USED to monkey-patch window.showScreen. It doesn't any more, and that is the point.
//
// Three parties were wrapping that one global — app.js publishes it, this wrapped it, and the
// stems plugin wrapped it again — each capturing whatever happened to be there at the time.
// Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled, and
// a capture taken before this installed silently dropped the home -> v3-songs mapping this
// wrapper carried. That is why the library intermittently showed the legacy screen (#923).
//
// The mapping lives inside showScreen() now, where no wrapper can lose it. And everything
// left here is just "the screen changed" — which showScreen already EMITS, and which app.js,
// audio-mixer.js and tour-engine.js have always listened for rather than patching.
//
// So: be a listener, like everyone else. window.showScreen is a plain function again.
function installShowScreenHook() {
const hooks = window.__feedBackV3ShellHooks || (window.__feedBackV3ShellHooks = {});
hooks.syncActive = syncActive; // always point at the latest impl
hooks.syncActive = syncActive; // always point at the latest impl
if (hooks.installed) return;
hooks.installed = true;
hooks.baseShowScreen = window.showScreen;
window.showScreen = function (id) {
// Route every "go to the library" navigation to the v3 native Songs
// screen instead of the legacy #home library, so player-close,
// settings-back, the hidden legacy navbar, etc. all stay in v3.
const target = (id === 'home') ? 'v3-songs' : id;
const r = hooks.baseShowScreen ? hooks.baseShowScreen.call(this, target) : undefined;
try { hooks.syncActive && hooks.syncActive(target); } catch (e) { /* non-fatal */ }
return r;
// RETRY IF THE BUS IS LATE. The old wrapper didn't need window.feedBack to exist; a
// listener does. Bailing out when it isn't ready yet would silently leave the sidebar
// highlight and topbar title frozen forever — a dead nav, with nothing thrown. (Codex
// caught the identical hole in the stems plugin's version of this.)
const wire = () => {
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') {
// `feedBack:capabilities:ready` — capabilities.js:1536. NOT the slopsmith: name:
// that was the pre-DMCA event and NOTHING dispatches it any more, so a fallback
// keyed on it can never fire. Codex caught exactly that here. (The old alias is
// kept too, in case an older capabilities build is in play.)
window.addEventListener('feedBack:capabilities:ready', wire, { once: true });
window.addEventListener('slopsmith:capabilities:ready', wire, { once: true });
return;
}
bus.on('screen:changed', (ev) => {
const id = ev && ev.detail && ev.detail.id;
if (!id) return;
try { hooks.syncActive && hooks.syncActive(id); } catch (e) { /* non-fatal */ }
});
};
wire();
}
// ── Boot ────────────────────────────────────────────────────────────────
+637
View File
@@ -0,0 +1,637 @@
/*
* fee[dB]ack Venue crowd video layer (career mode PR1).
*
* Crossfades pre-rendered crowd-state loop videos behind the highway based on
* v3:live-performance-state, plus one-shot reaction stingers. Renders through
* two video backdrop planes owned by the highway_3d venue background style
* (window.h3dVenueBackdropSetVideo / window.h3dVenueBackdropSetMix).
*
* Inert unless a venue pack manifest is set by the career plugin via
* v3VenueCrowd.setManifest(), or (dev only) a JSON manifest in localStorage
* under feedBack-venue-crowd-dev. With no manifest the static bg plate
* behaves exactly as before.
*/
(function (root) {
'use strict';
// live-performance-hud state → crowd state.
const CROWD_OF_PERF = {
smoke: 'bored',
recovery: 'bored',
idle: 'neutral',
steady: 'neutral',
strong: 'engaged',
fire: 'ecstatic',
};
const CROWD_STATES = ['bored', 'neutral', 'engaged', 'ecstatic'];
const CROWD_RANK = { bored: 0, neutral: 1, engaged: 2, ecstatic: 3 };
const STABLE_MS = 3000; // target must hold this long before a switch
const DWELL_MS = 8000; // min time between committed switches
const FADE_MS = 1200; // loop crossfade
const STINGER_FADE_MS = 400; // stinger fade-in/out
const STINGER_MIN_GAP_MS = 20000;
const STREAK_MILESTONES = [25, 50, 100];
const CANPLAY_TIMEOUT_MS = 4000;
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
// ---------------------------------------------------------------------
// Pure, clock-injected decision logic (unit-tested in
// tests/js/venue_crowd.test.js — keep DOM-free).
// ---------------------------------------------------------------------
function crowdStateOfPerf(perfState) {
return CROWD_OF_PERF[String(perfState || '').toLowerCase()] || 'neutral';
}
// Hysteresis: a new target must be observed continuously for STABLE_MS,
// and at least DWELL_MS must have passed since the last committed switch.
function createCrowdMachine() {
let current = 'neutral';
let candidate = null;
let candidateSince = 0;
let lastSwitchAt = -Infinity;
return {
get current() { return current; },
reset() {
current = "neutral";
candidate = null;
lastSwitchAt = -Infinity;
},
// Feed the latest perf state; returns the new crowd state when a
// transition commits, else null.
update(perfState, nowMs) {
const target = crowdStateOfPerf(perfState);
if (target === current) {
candidate = null;
return null;
}
if (target !== candidate) {
candidate = target;
candidateSince = nowMs;
return null;
}
if (nowMs - candidateSince < STABLE_MS) return null;
if (nowMs - lastSwitchAt < DWELL_MS) return null;
current = target;
candidate = null;
lastSwitchAt = nowMs;
return current;
},
};
}
// Cheer when the streak crosses a milestone (rising edge only).
function stingerForStreak(prevStreak, streak) {
for (const m of STREAK_MILESTONES) {
if (prevStreak < m && streak >= m) return 'cheer';
}
return null;
}
// End-of-song reaction from final accuracy.
function stingerForAccuracy(accuracyPct) {
const a = Number(accuracyPct);
if (!Number.isFinite(a)) return null;
if (a >= 90) return 'cheer';
if (a >= 75) return 'clap';
return null;
}
// ---------------------------------------------------------------------
// Video layer controller (browser only).
// ---------------------------------------------------------------------
const machine = createCrowdMachine();
let _manifest = null; // { loops: {state: url}, stingers: {name: url} }
let _venueActive = false;
let _videos = [null, null];
let _activeLayer = 0; // layer currently showing the loop
let _mix = 0; // 0 → layer0 visible, 1 → layer1 visible
let _fadeRaf = 0;
let _stopGen = 0; // bumped by stop(): invalidates ALL in-flight loads
let _boundToRenderer = false;
let _pendingLoop = null; // loop switch deferred by an active stinger
let _loadingLoop = null; // loop currently waiting on canplaythrough
let _fadingLoop = null; // loop currently crossfading in (not yet active)
let _stingerUntilEnded = false;
let _stingerGen = 0; // identity for ended/timeout handlers
let _introActive = false;
let _introGen = 0;
let _audioEl = null; // crowd ambience during the intro flyover
let _audioFadeTimer = 0;
let _lastStingerAt = -Infinity;
let _prevStreak = 0;
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
let _bound = false;
function now() { return Date.now(); }
function h3d(name) {
return root && typeof root[name] === 'function' ? root[name] : null;
}
function normalizeManifest(m) {
if (!m || typeof m !== 'object' || !m.loops) return null;
const base = typeof m.base === 'string' ? m.base : '';
const abs = (u) => (typeof u === 'string' && u ? base + u : '');
const loops = {};
for (const s of CROWD_STATES) loops[s] = abs(m.loops[s]);
if (!CROWD_STATES.every((s) => loops[s])) return null;
const stingers = {};
for (const k of ['clap', 'cheer']) stingers[k] = abs(m.stingers && m.stingers[k]);
const intro = {
video: abs(m.intro && m.intro.video),
audio: abs(m.intro && m.intro.audio),
};
const sfx = {
up: abs(m.sfx && m.sfx.up),
down: abs(m.sfx && m.sfx.down),
};
return { loops, stingers, intro, sfx };
}
function ensureVideos() {
if (!_videos[0] && typeof document !== 'undefined') {
for (let i = 0; i < 2; i++) {
const v = document.createElement('video');
// Same autoplay-safe recipe as the highway_3d video bg style:
// muted + playsInline bypasses gesture requirements; same-origin
// URLs so VideoTexture never taints.
v.muted = true;
v.playsInline = true;
v.preload = 'auto';
v.loop = true;
v.style.display = 'none';
document.body.appendChild(v);
_videos[i] = v;
}
}
bindVideosToRenderer();
}
// The highway_3d plugin (and its globals) can register after the venue
// pack starts — e.g. Venue selected at page load, renderer ready later.
// Idempotent and retried from start() and the perf-event path so a late
// renderer still picks the videos up.
function bindVideosToRenderer() {
if (_boundToRenderer || !_videos[0]) return;
const setVideo = h3d('h3dVenueBackdropSetVideo');
if (!setVideo) return;
setVideo(0, _videos[0]);
setVideo(1, _videos[1]);
_boundToRenderer = true;
setMix(_mix); // re-push mix the renderer missed while unregistered
}
function setMix(v) {
_mix = Math.max(0, Math.min(1, v));
const fn = h3d('h3dVenueBackdropSetMix');
if (fn) fn(_mix);
}
function cancelFade() {
if (_fadeRaf && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(_fadeRaf);
}
_fadeRaf = 0;
}
function fadeMixTo(target, durationMs, done) {
cancelFade();
if (typeof requestAnimationFrame !== 'function') {
setMix(target);
if (done) done();
return;
}
const from = _mix;
const t0 = now();
const step = () => {
const k = Math.min(1, (now() - t0) / durationMs);
setMix(from + (target - from) * k);
if (k < 1) {
_fadeRaf = requestAnimationFrame(step);
} else {
_fadeRaf = 0;
if (done) done();
}
};
_fadeRaf = requestAnimationFrame(step);
}
// Load url into the video, resolve when it can play through (or after a
// timeout — a stalled fetch must not wedge the crowd forever). Tokens are
// per-element: a later load on the SAME video (a stinger preempting the
// idle layer) cancels this one, but loads on the other layer don't.
function loadAndPlay(video, url, loop, cb) {
const token = (video._fbCrowdToken = (video._fbCrowdToken || 0) + 1);
const gen = _stopGen;
let settled = false;
const settle = (ok) => {
if (settled) return;
settled = true;
// Cleanup must run even for superseded loads or stale listeners
// accumulate on the two persistent elements; only the callback
// is gated on still being the current load.
video.removeEventListener('canplaythrough', onReady);
video.removeEventListener('error', onError);
if (token !== video._fbCrowdToken || gen !== _stopGen) return;
cb(ok);
};
const onReady = () => settle(true);
const onError = () => settle(false);
video.addEventListener('canplaythrough', onReady);
video.addEventListener('error', onError);
video.loop = loop;
video.src = url;
video.play().catch(() => { /* browser retries on visibility/gesture */ });
setTimeout(() => settle(video.readyState >= 3), CANPLAY_TIMEOUT_MS);
}
function idleLayer() { return _activeLayer === 0 ? 1 : 0; }
// Crossfade the loop for `state` in on the idle layer.
function showLoop(state, fadeMs) {
if (!_manifest || !_videos[0]) return;
const layer = idleLayer();
const video = _videos[layer];
_loadingLoop = state;
loadAndPlay(video, _manifest.loops[state], true, (ok) => {
if (_loadingLoop === state) _loadingLoop = null;
if (!ok || !_venueActive) return;
_fadingLoop = state;
fadeMixTo(layer === 1 ? 1 : 0, fadeMs, () => {
// Preempted mid-fade (stinger claimed this layer while we
// were still ramping): the layer no longer holds this loop —
// promoting it would pause the real loop and hand fade-back
// the wrong target.
if (_fadingLoop !== state) return;
_fadingLoop = null;
const old = _videos[_activeLayer];
_activeLayer = layer;
if (old && !old.paused) old.pause();
});
});
}
function playStinger(name) {
if (!_manifest || !_manifest.stingers[name] || !_videos[0]) return;
if (_stingerUntilEnded) return;
const t = now();
if (t - _lastStingerAt < STINGER_MIN_GAP_MS) return;
_lastStingerAt = t;
_stingerUntilEnded = true;
const layer = idleLayer();
const video = _videos[layer];
// The stinger reuses the idle layer's element, cancelling any loop
// load still in flight there — and idleLayer() is still the fading-in
// layer while a crossfade runs (_activeLayer flips on completion), so
// a mid-fade loop gets overwritten too. Requeue either for when the
// stinger ends (the machine already advanced, nothing re-fires it).
const interrupted = _loadingLoop || _fadingLoop;
if (interrupted) {
// Freeze any in-flight crossfade: its ramp would keep pushing the
// mix toward this layer while the stinger replaces the src (loop
// vanishing / stinger popping in at full opacity).
cancelFade();
_pendingLoop = interrupted;
_loadingLoop = null;
_fadingLoop = null;
}
// A loop switch deferred (or preempted) by this stinger must play
// once the stinger is done OR failed — the machine already advanced,
// so nothing re-triggers it later.
const flushPending = () => {
if (!_pendingLoop || !_venueActive) return;
const pending = _pendingLoop;
_pendingLoop = null;
showLoop(pending, FADE_MS);
};
const myGen = ++_stingerGen;
const back = () => {
// Always detach: a handler left behind by a stop()/manifest swap
// must not fire into a LATER stinger's lifecycle on this reused
// element (the gen check below guards that; the boolean alone
// would pass once a new stinger is active).
video.removeEventListener('ended', back);
if (_stingerGen !== myGen || !_stingerUntilEnded) return;
_stingerUntilEnded = false;
// Fade back to the loop layer (which kept playing underneath).
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
flushPending();
};
loadAndPlay(video, _manifest.stingers[name], false, (ok) => {
if (!ok || !_venueActive) {
_stingerUntilEnded = false;
flushPending();
return;
}
video.addEventListener('ended', back);
fadeMixTo(layer === 1 ? 1 : 0, STINGER_FADE_MS);
// Safety: an `ended` that never fires (decode stall) must not
// freeze the crowd on a stinger frame.
setTimeout(back, 15000);
});
}
function ensureAudio() {
if (_audioEl || typeof document === 'undefined') return;
_audioEl = document.createElement('audio');
_audioEl.preload = 'auto';
_audioEl.style.display = 'none';
document.body.appendChild(_audioEl);
}
function fadeAudioOut(durationMs) {
if (!_audioEl || _audioEl.paused) return;
if (_audioFadeTimer) return; // already fading
const from = _audioEl.volume;
const t0 = now();
_audioFadeTimer = setInterval(() => {
const k = Math.min(1, (now() - t0) / durationMs);
_audioEl.volume = from * (1 - k);
if (k >= 1) {
clearInterval(_audioFadeTimer);
_audioFadeTimer = 0;
_audioEl.pause();
}
}, 50);
}
function stopAudio() {
if (_audioFadeTimer) { clearInterval(_audioFadeTimer); _audioFadeTimer = 0; }
if (_audioEl && !_audioEl.paused) _audioEl.pause();
}
// One-shot flyover intro on song load: video flies from the back of the
// room onto the stage, crowd ambience plays and ducks out as the song
// starts (song:play) or as the flyover lands, whichever comes first.
function playIntro() {
if (!_manifest || !_manifest.intro || !_manifest.intro.video || !_videos[0]) {
return false;
}
const myGen = ++_introGen;
_introActive = true;
const layer = idleLayer();
const video = _videos[layer];
const land = () => {
if (_introGen !== myGen || !_introActive) return;
_introActive = false;
video.removeEventListener('ended', land);
fadeAudioOut(1200);
const pending = _pendingLoop;
_pendingLoop = null;
showLoop(pending || machine.current, 400);
};
loadAndPlay(video, _manifest.intro.video, false, (ok) => {
if (_introGen !== myGen) return;
if (!ok || !_venueActive) {
// Failed intro must not leave the song loop-less: fall back
// to the normal loop exactly like the no-intro path.
_introActive = false;
if (_venueActive) showLoop(machine.current, FADE_MS);
return;
}
fadeMixTo(layer === 1 ? 1 : 0, 300);
video.addEventListener('ended', land);
setTimeout(land, 15000); // decode-stall safety
if (_manifest.intro.audio) {
ensureAudio();
_audioEl.src = _manifest.intro.audio;
_audioEl.volume = 1;
// The user's play gesture precedes song:loaded, so autoplay
// with sound is normally allowed; degrade silently if not.
_audioEl.play().catch(() => { /* no gesture yet */ });
// start ducking shortly before the flyover lands
video.addEventListener('timeupdate', function duck() {
if (video.duration && video.duration - video.currentTime < 1.5) {
video.removeEventListener('timeupdate', duck);
fadeAudioOut(1400);
}
});
}
});
return true;
}
let _sfxEl = null;
function sfxEnabled() {
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
}
// One-shot crowd reaction on committed mood transitions (toggleable):
// up the ladder → cheer, down → boos. Committed transitions are already
// hysteresis-limited, so this can't spam.
function playMoodSfx(direction) {
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
if (!url || typeof document === 'undefined') return;
if (!_sfxEl) {
_sfxEl = document.createElement('audio');
_sfxEl.preload = 'auto';
_sfxEl.style.display = 'none';
document.body.appendChild(_sfxEl);
}
_sfxEl.src = url;
_sfxEl.volume = 0.6;
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
}
function onSongPlay() {
// Song audio starting is the hard cue: the ambience must yield.
fadeAudioOut(1000);
}
function onPerformanceState(e) {
if (!_venueActive || !_manifest) return;
bindVideosToRenderer();
const d = (e && e.detail) || {};
// Number(null) === 0: HUD reset events (accuracyPct: null) must not
// wipe the value the end-of-song stinger reads via stats:recorded.
if (d.accuracyPct != null && Number.isFinite(Number(d.accuracyPct))) {
_lastAccuracyPct = Number(d.accuracyPct);
}
const streak = Number(d.streak) || 0;
const sting = stingerForStreak(_prevStreak, streak);
_prevStreak = streak;
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
playStinger(sting);
}
const prevRank = CROWD_RANK[machine.current];
const next = machine.update(d.state, now());
if (next) {
playMoodSfx(CROWD_RANK[next] - prevRank);
// A stinger or the intro owns the idle layer; defer the switch.
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
else showLoop(next, FADE_MS);
}
}
function onSongLoaded() {
machine.reset();
_prevStreak = 0;
_lastAccuracyPct = null;
// Abort any stinger/pending state from the previous song: its ended
// handler must not fade back into the old song's layers.
cancelFade();
_stingerGen++;
_introGen++;
_stingerUntilEnded = false;
_introActive = false;
stopAudio();
_pendingLoop = null;
_loadingLoop = null;
_fadingLoop = null;
if (_venueActive && _manifest) {
if (!playIntro()) showLoop(machine.current, FADE_MS);
}
}
function onStatsRecorded() {
if (!_venueActive || !_manifest) return;
// stats:recorded carries only {filename, arrangement} — the accuracy
// comes from the last v3:live-performance-state of the finished song.
const sting = stingerForAccuracy(_lastAccuracyPct);
_lastAccuracyPct = null; // one reaction per song
if (sting) {
_lastStingerAt = -Infinity; // end-of-song reaction always allowed
playStinger(sting);
}
}
function start() {
ensureVideos();
if (!_videos[0]) return;
_prevStreak = 0;
// Boot straight into the current machine state on the active layer.
const video = _videos[_activeLayer];
loadAndPlay(video, _manifest.loops[machine.current], true, (ok) => {
if (!ok || !_venueActive) return;
setMix(_activeLayer === 1 ? 1 : 0);
});
}
function stop() {
cancelFade();
_stopGen++;
_stingerGen++;
_introGen++;
_introActive = false;
stopAudio();
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
_stingerUntilEnded = false;
_pendingLoop = null;
_loadingLoop = null;
_fadingLoop = null;
for (const v of _videos) {
if (v && !v.paused) v.pause();
}
// Unbind from the renderer: a paused video still holds its last
// frame, and the venue style keeps a bound plane visible whenever
// videoWidth > 0 — without this a removed pack would leave a frozen
// crowd frame over the static plate. start() re-binds.
const setVideo = h3d('h3dVenueBackdropSetVideo');
if (_boundToRenderer && setVideo) {
setVideo(0, null);
setVideo(1, null);
}
_boundToRenderer = false;
// Mix and active layer must reset together: mix 0 shows layer 0, so a
// restart that left _activeLayer at 1 would flash layer 0's stale
// frame until the new loop loads.
_activeLayer = 0;
setMix(0);
}
function setVenueActive(on) {
const next = !!on;
if (next === _venueActive) {
// Re-activation (e.g. viz:renderer:ready after a late plugin
// load): don't restart the loop, but do retry renderer binding.
if (next && _manifest) bindVideosToRenderer();
return;
}
_venueActive = next;
if (_venueActive && _manifest) start();
else stop();
}
function setManifest(m) {
const norm = normalizeManifest(m);
_manifest = norm;
if (_venueActive) {
// Full stop first even when replacing pack-for-pack: it bumps
// _stopGen so an in-flight load from the OLD manifest can't
// settle and fade a stale URL in after the new pack starts.
stop();
if (norm) start();
}
}
function readDevManifest() {
try {
const raw = localStorage.getItem(DEV_FLAG_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (_) {
return null;
}
}
function bindRuntime() {
if (_bound) return;
_bound = true;
const sm = root && root.feedBack;
if (sm && typeof sm.on === 'function') {
sm.on('v3:live-performance-state', onPerformanceState);
sm.on('stats:recorded', onStatsRecorded);
// A new song must not inherit the previous song's crowd mood
// through the hysteresis/dwell window.
sm.on('song:loaded', onSongLoaded);
sm.on('song:play', onSongPlay);
}
const dev = readDevManifest();
if (dev && !_manifest) setManifest(dev);
}
function getState() {
return {
venueActive: _venueActive,
hasManifest: !!_manifest,
crowdState: machine.current,
activeLayer: _activeLayer,
mix: _mix,
stingerActive: _stingerUntilEnded,
introActive: _introActive,
};
}
const api = {
CROWD_STATES,
STABLE_MS,
DWELL_MS,
crowdStateOfPerf,
createCrowdMachine,
stingerForStreak,
stingerForAccuracy,
normalizeManifest,
setManifest,
setVenueActive,
bindRuntime,
getState,
};
if (root) root.v3VenueCrowd = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document !== 'undefined') {
// Same defer/DOMContentLoaded dance as venue-scene-3d.js.
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', bindRuntime);
} else {
bindRuntime();
}
}
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
+14 -1
View File
@@ -73,7 +73,7 @@
function readArrangementSignal() {
// Intentional karaoke/vocals signal: active arrangement name from the
// highway WS (user selected Vocals in #arr-select). Do NOT use
// highway.getLyricsVisible() — lyrics overlay stays on during normal
// window.highway.getLyricsVisible() — lyrics overlay stays on during normal
// guitar practice and must not force vocals POV.
try {
const si = root.highway && typeof root.highway.getSongInfo === 'function'
@@ -96,6 +96,7 @@
if (_active) {
syncInstrumentPov();
syncVenueMotion();
syncCrowd(true);
return;
}
_active = true;
@@ -105,6 +106,17 @@
setH3dMood(_lastMood);
syncInstrumentPov();
syncVenueMotion();
syncCrowd(true);
}
function syncCrowd(on) {
// Reactive crowd video layer (career mode) — inert without a pack.
try {
if (root && root.v3VenueCrowd &&
typeof root.v3VenueCrowd.setVenueActive === 'function') {
root.v3VenueCrowd.setVenueActive(!!on);
}
} catch (_) { /* visual-only */ }
}
function syncVenueMotion() {
@@ -128,6 +140,7 @@
_assetsLoaded = false;
_loadFailed = false;
setH3dActive(false);
syncCrowd(false);
syncPlaceholderVisibility();
}
+168
View File
@@ -0,0 +1,168 @@
import { test, expect } from '@playwright/test';
/**
* The R3c perf gate: highway.js's render loop must not get more expensive.
*
* WHY FRAME RATE IS THE WRONG THING TO MEASURE
*
* The highway AUTO-SCALES. When the smoothed draw cost climbs past its budget
* (_DRAW_BUDGET_HI_MS = 12ms) it LOWERS THE RENDER RESOLUTION to protect the frame rate
* (#654). That is exactly right for players. It also means a real performance regression
* does not show up as dropped frames it shows up as a BLURRIER PICTURE at a perfectly
* healthy 60fps.
*
* Benchmark fps and you measure the feedback loop, not the renderer, and cheerfully
* conclude that nothing changed while the picture quietly got worse.
*
* So this pins the scale setRenderScale(1) + setMinRenderScale(1), which clamps
* autoScale to [1, 1] and measures `drawMs`, the renderer's own cost, straight from
* highway.getPerf(). With the adaptive loop held still, drawMs is the signal.
*
* WHAT IT ASSERTS, AND THE TRAP I FELL INTO FIRST
*
* My first cut asserted "the auto-scaler was not forced to intervene" i.e. effectiveScale
* still == 1. That gate is VACUOUS, and the bite test proved it: I injected a 10x
* regression (drawMs 2.4 -> 22.4ms, nearly double the 12ms budget) and the test PASSED.
*
* Of course it did. setMinRenderScale(1) sets the auto-scaler's FLOOR to 1, so
* effectiveScale CANNOT drop below 1 the very pinning that stops the scaler from hiding a
* regression also stops it from ever reporting one. A guard that cannot fail.
*
* So with the scale pinned, drawMs IS the signal, and the threshold is the app's own:
* _DRAW_BUDGET_HI_MS (12ms) is the cost at which the highway itself decides it is too
* expensive and starts dropping resolution in production. Exceeding it is not an arbitrary
* line in a benchmark it is the renderer failing its own budget.
*
* That is a real gate and not a flaky one: the current cost is ~2.4ms, so there is ~5x
* headroom before it trips, which is far more than headless-CI variance and far less than
* any regression worth shipping.
*/
test('highway draw cost stays within its own render budget', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const perf = await page.evaluate(async () => {
const w = window as any;
const hw = w.highway;
if (!hw || typeof hw.getPerf !== 'function') {
return { error: 'highway.getPerf() missing — the perf gate is blind' };
}
// Pin the adaptive loop so it cannot mask a regression by dropping resolution.
hw.setRenderScale(1);
hw.setMinRenderScale(1);
// Load a real chart and get the transport ACTUALLY RUNNING.
//
// Codex [P2] on the first cut of this, and it was right: headless Chromium may block
// autoplay, in which case playSong() only LOADS the chart. The audio clock never
// advances, the draw loop treats the session as paused, and _drawMsEMA keeps whatever
// stale value it had at startup. The gate would then sample an IDLE renderer and
// cheerfully report a healthy 2ms — while measuring nothing at all, on exactly the path
// it exists to protect.
const d = await (await fetch('/api/library?limit=1')).json();
const f = d.songs && d.songs[0] && (d.songs[0].filename || d.songs[0].id);
if (!f) return { error: 'no song in the library — the perf gate has nothing to render' };
const audio = document.getElementById('audio') as HTMLAudioElement | null;
if (audio) audio.muted = true; // so autoplay policy cannot refuse us
// ENCODE. playSong() decodes its argument before interpolating it into the /ws/highway
// path, so every real caller hands it encodeURIComponent(filename) (app.js:2879, 4137).
// A raw filename containing #, ?, % or / builds an invalid WebSocket URL and the song
// never loads — on which libraries this gate would silently measure an idle renderer
// rather than fail. Codex [P2], and correct.
await w.playSong(encodeURIComponent(f));
// WAIT for playback to start; do NOT force it on a fixed timer.
//
// playSong() autoplays, but it takes ~3-4s to get there — it is fetching and decoding
// stems. An earlier version of this test called togglePlay() after a flat 2s "if not
// playing yet", which fired BEFORE autoplay, started playback, and then had the app's
// own autoplay toggle it straight back to PAUSED. The renderer then idled through the
// whole measurement and the gate happily reported 2ms of nothing.
const playDeadline = Date.now() + 12000;
while (!w.feedBack.isPlaying && Date.now() < playDeadline) {
await new Promise((r) => setTimeout(r, 250));
}
// Only intervene if it truly never started (a stricter autoplay policy than we expect).
if (!w.feedBack.isPlaying) await w.togglePlay();
// Wait for the CHART CLOCK to actually move. That is the proof the render loop is doing
// real per-frame work, not sitting paused.
const t0 = hw.getTime();
const deadline = Date.now() + 8000;
while (hw.getTime() - t0 < 0.5 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
}
const advanced = hw.getTime() - t0;
// Let the EMAs settle under load (they are 0.9/0.1, so they need a few dozen frames).
await new Promise((r) => setTimeout(r, 2500));
// Sample — and measure the clock ACROSS the sampling window, not just before it.
// "It advanced at some point earlier" is not good enough: if playback stopped before we
// started sampling (short song, ended track, autoplay revoked), the EMAs decay toward
// idle and we would be reading the cost of drawing nothing.
const sampleStart = hw.getTime();
const samples: number[] = [];
for (let i = 0; i < 30; i++) {
await new Promise((r) => requestAnimationFrame(() => r(null)));
samples.push(hw.getPerf().drawMs);
}
const advancedDuringSampling = hw.getTime() - sampleStart;
return {
...hw.getPerf(),
samples,
advanced,
advancedDuringSampling,
isPlaying: !!w.feedBack.isPlaying,
};
});
expect(perf.error, String(perf.error)).toBeUndefined();
console.log(
`[highway perf] drawMs=${(perf.drawMs ?? 0).toFixed(2)} frameMs=${(perf.frameMs ?? 0).toFixed(2)} ` +
`renderScale=${perf.renderScale} autoScale=${perf.autoScale} effectiveScale=${perf.effectiveScale} ` +
`budget=${perf.drawBudgetLoMs}..${perf.drawBudgetHiMs}ms ` +
`playing=${perf.isPlaying} advancedBefore=${(perf.advanced ?? 0).toFixed(2)}s ` +
`advancedDuringSampling=${(perf.advancedDuringSampling ?? 0).toFixed(3)}s`,
);
// 0. THE GATE MUST NOT BE MEASURING AN IDLE RENDERER. If the transport never started, the
// draw loop is paused, _drawMsEMA is a stale startup value, and every assertion below
// passes while testing nothing. Assert the chart clock actually MOVED.
expect(
perf.advanced,
'the chart clock never advanced — playback did not start, so drawMs is a stale idle ' +
'value and this gate is measuring nothing',
).toBeGreaterThan(0.5);
// …and it must STILL have been advancing while we sampled. "It moved at some point
// earlier" is not good enough: if playback stopped before the sampling window, the EMAs
// decay toward idle and we would be measuring the cost of drawing nothing.
expect(
perf.advancedDuringSampling,
'the chart clock was not advancing DURING the sampling window — playback stopped, so ' +
'these drawMs samples are the cost of an idle renderer, not a rendering one',
).toBeGreaterThan(0);
// 1. the renderer actually ran and reports a sane cost
expect(Number.isFinite(perf.drawMs)).toBe(true);
expect(perf.drawMs).toBeGreaterThan(0);
// 2. THE REGRESSION SIGNAL. With the scale pinned, drawMs is the renderer's true cost.
// _DRAW_BUDGET_HI_MS is the app's OWN definition of "too expensive" — the cost at
// which it starts sacrificing resolution for players in production. Blow through it
// and the renderer has failed its own budget.
//
// Do NOT be tempted to assert on effectiveScale instead: pinning the scale makes that
// number a constant, so it can never report anything. See the note above.
expect(
perf.drawMs,
`highway draw cost ${perf.drawMs.toFixed(2)}ms exceeds its own budget of ` +
`${perf.drawBudgetHiMs}ms — in production this is the point where the highway starts ` +
`dropping render resolution to keep up`,
).toBeLessThan(perf.drawBudgetHiMs);
});
+8 -1
View File
@@ -18,7 +18,14 @@ const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// auto-exit machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin)
// stayed in app.js.
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
// R3d: the song session (showScreen / playSong / closeCurrentSong, and the autoplay hold and
// auto-exit timer they own) was carved out of app.js into static/js/session.js. This file slices
// functions from BOTH — `_resultsOverlayVisible` is still in app.js; `_releaseAutoplay` and
// `_resolvePlayerOrigin` moved. Read both and strip `export`, exactly as CONTROLS_SRC already
// does, rather than re-pinning each extraction at whichever file currently holds it.
const SESSION_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js');
const SRC = fs.readFileSync(APP_JS, 'utf8')
+ '\n' + fs.readFileSync(SESSION_JS, 'utf8').replace(/^export /gm, '');
// the module is ESM; these sandboxes evaluate plain script text
const CONTROLS_SRC = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
+68
View File
@@ -0,0 +1,68 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career');
const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js');
test('career plugin manifest is complete and bundled', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8'));
assert.equal(manifest.id, 'career');
assert.equal(manifest.bundled, true);
assert.equal(manifest.screen, 'screen.html');
assert.equal(manifest.script, 'screen.js');
assert.equal(manifest.routes, 'routes.py');
for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) {
assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`);
}
});
test('venues.json defines the 3 ascending tiers with star thresholds', () => {
const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8'));
assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]);
const venues = content.venues;
assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']);
assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked');
for (let i = 1; i < venues.length; i++) {
assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold,
'thresholds must ascend');
}
});
test('bar venue pack ships with intro media in the plugin checkout', () => {
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'bar');
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
assert.deepEqual(Object.keys(manifest.loops).sort(),
['bored', 'ecstatic', 'engaged', 'neutral']);
assert.equal(manifest.intro.video, 'intro.mp4');
assert.equal(manifest.intro.audio, 'bar-ambience.mp3');
for (const f of [
...Object.values(manifest.loops),
...Object.values(manifest.stingers),
manifest.intro.video,
manifest.intro.audio,
]) {
const stat = fs.statSync(path.join(packDir, f));
assert.ok(stat.size > 0, `${f} must be present`);
}
});
test('shell promotes the career plugin into the sidebar', () => {
const src = fs.readFileSync(SHELL_JS, 'utf8');
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/);
});
test('career screen pushes the crowd manifest with a base URL', () => {
const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8');
assert.match(src, /v3VenueCrowd/);
assert.match(src, /setManifest\(manifest\)/);
assert.match(src, /manifest\.base = /);
assert.match(src, /feedBack-career-venue/);
// Degrades without the crowd layer (PR1 not merged / older desktop).
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
});
+6 -3
View File
@@ -1,4 +1,4 @@
// Regression guards for two Edit-Metadata modal fixes (static/app.js):
// Regression guards for two Edit-Metadata modal fixes (static/js/edit-modal.js):
//
// 1. Year is editable — the modal renders an `edit-year` field and
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
@@ -19,8 +19,11 @@ const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const readApp = () => fs.readFileSync(APP_JS, 'utf8');
// R3d: the edit modal was carved out of app.js into its own module. Bodies unchanged — only
// the file moved. (It could go cleanly because the LIBRARY came out first: every dependency the
// modal has is a module now, and it reads six library bindings without writing any.)
const EDIT_MODAL_JS = path.join(__dirname, '..', '..', 'static', 'js', 'edit-modal.js');
const readApp = () => fs.readFileSync(EDIT_MODAL_JS, 'utf8');
function loadFn(signature, sandbox, exportAs) {
const fnSrc = extractFunction(readApp(), signature);
+30 -13
View File
@@ -27,16 +27,33 @@ function extractBlock(src, signature) {
return src.slice(start, i);
}
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
// means they break again on the next carve — and worse, a source-shape assertion that silently
// stops finding its target is indistinguishable from one that passes.
function highwaySources() {
const root = path.join(__dirname, '..', '..');
const jsDir = path.join(root, 'static', 'js');
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.startsWith('highway-') && f.endsWith('.js')) {
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
}
return parts.join('\n');
}
test('highway declares adaptive-scale state with a floor', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
assert.match(src, /(?:export\s+)?const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
});
test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function _effectiveRenderScale()');
// Derives from the (sanitized) user ceiling and auto factor.
assert.match(fn, /_renderScale/, 'effective scale must derive from the user _renderScale');
@@ -47,7 +64,7 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
});
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
// Hard floor constant kept; configurable floor read from localStorage.
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
@@ -65,7 +82,7 @@ test('min render scale floor is user-configurable + exposed on the api (#654)',
});
test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resize', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function _adaptRenderScale(');
assert.match(fn, /_DRAW_BUDGET_HI_MS/, 'must scale down past the high budget');
assert.match(fn, /_DRAW_BUDGET_LO_MS/, 'must scale up below the low budget');
@@ -74,27 +91,27 @@ test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resiz
});
test('draw() only adapts during active playback and feeds the HUD', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function draw()');
assert.match(fn, /if\s*\(\s*!_paused\s*\)\s*_adaptRenderScale/, 'must skip adaptation while paused');
assert.match(fn, /_updatePerfHud\(\)/, 'must update the perf HUD each drawn frame');
});
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
});
test('api exposes effective scale + perf stats', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /getEffectiveRenderScale\(\)\s*\{\s*return\s+_effectiveRenderScale\(\)/, 'api.getEffectiveRenderScale missing');
assert.match(src, /getPerfStats\(\)\s*\{/, 'api.getPerfStats missing');
});
// Robustness fixes from the #655 Copilot review.
test('render scale is sanitized on load and effective scale guards non-finite', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /parseFloat\(localStorage\.getItem\('renderScale'\)[\s\S]{0,160}?Number\.isFinite/,
'render scale load must validate via Number.isFinite + clamp');
const eff = extractBlock(src, 'function _effectiveRenderScale()');
@@ -102,7 +119,7 @@ test('render scale is sanitized on load and effective scale guards non-finite',
});
test('stop() tears down the perf HUD and resets per-session accumulators', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /stop\(\)\s*\{[\s\S]{0,400}?_perfHud\.remove\(\)/,
'stop() must remove the perf HUD so it cannot strand in the DOM');
assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_autoScale\s*=\s*1/,
@@ -112,7 +129,7 @@ test('stop() tears down the perf HUD and resets per-session accumulators', () =>
});
test('perf HUD throttles its localStorage flag read off the hot path', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function _updatePerfHud()');
assert.match(fn, /_hudFlagAt/, 'HUD must cache the flag and re-read on an interval, not every frame');
});
+3 -1
View File
@@ -25,7 +25,9 @@ function loadFn(file, name) {
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
}
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
// R3c: the PURE geometry/label primitives were carved out of highway.js into
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
const bnvNormalizedPoints = loadFn('static/js/highway-geometry.js', 'bnvNormalizedPoints');
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
+3 -1
View File
@@ -25,7 +25,9 @@ function loadFn(file, name) {
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
}
const labels2D = loadFn('static/highway.js', 'chordHarmonyLabels');
// R3c: the PURE geometry/label primitives were carved out of highway.js into
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
const labels2D = loadFn('static/js/highway-geometry.js', 'chordHarmonyLabels');
const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels');
for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) {
+17 -2
View File
@@ -19,8 +19,23 @@ const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
// whole set rather than re-pinning at whichever file currently holds a function — re-pinning
// just breaks again next time, and a source-shape assertion that silently stops finding its
// target is indistinguishable from one that passes.
function highwaySources() {
const root = path.join(__dirname, '..', '..');
const jsDir = path.join(root, 'static', 'js');
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
return parts.join('\n');
}
test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
// The cache key triple must include chordTemplates — without it, a
// late-arriving `chord_templates` WS message leaves cached
// nonZeroNotes / nonZeroFrets stale until the next chord transition.
@@ -41,7 +56,7 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
});
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
// The cache-invalidation block must clear both _chordFretLineNotes
// (so _updateFretLinePreview re-publishes with corrected isOpen
// classification) and _frameMismatchWarned (so a chord ID warned
+24 -7
View File
@@ -60,7 +60,7 @@ function buildClockSandbox(perfNowImpl) {
performance: { now: perfNowImpl },
};
vm.createContext(sandbox);
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const src = highwaySources();
const setTimeBody = extractBlock(src, 'setTime(t) {');
const getTimeBody = extractBlock(src, 'getTime() {');
// Strip trailing comma if present (object-literal method declarations).
@@ -72,8 +72,25 @@ function buildClockSandbox(perfNowImpl) {
return sandbox;
}
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
// means they break again on the next carve — and worse, a source-shape assertion that silently
// stops finding its target is indistinguishable from one that passes.
function highwaySources() {
const root = path.join(__dirname, '..', '..');
const jsDir = path.join(root, 'static', 'js');
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.startsWith('highway-') && f.endsWith('.js')) {
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
}
return parts.join('\n');
}
test('highway declares chart anchor + stall-detect + rate state', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const src = highwaySources();
// Both anchor fields use NaN sentinels — _chartAnchorAudioT in
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
@@ -82,11 +99,11 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
assert.match(src, /(?:export\s+)?const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
});
test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const src = highwaySources();
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
assert.ok(m, 'getTime() body not found');
const slice = m[0];
@@ -98,7 +115,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
});
test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually changes', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const src = highwaySources();
// Repeated setTime calls with the same value must not refresh the
// anchor (else interpolation stutters); they also must not refresh
// _chartLastAdvanceAt (else getTime would never detect a stalled
@@ -115,7 +132,7 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
});
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const src = highwaySources();
// Find the actual getTime body. Match the whole brace-balanced
// method (using a generous greedy slice to ensure we capture both
// the stall check and the interpolation expression below it).
@@ -139,7 +156,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
});
test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const src = highwaySources();
// Use the brace-balanced extractor so the assertions are scoped to
// the actual stop() body — a fixed-size slice would falsely match
// resets that landed in an adjacent method.
+50 -11
View File
@@ -12,6 +12,10 @@ const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
// R3c: _noteState moved to static/js/highway-state-primitives.js and gained an explicit
// hwState first parameter — it has to, because createHighway() is a factory and a module
// cannot import per-instance state without two panels sharing it.
const primitivesJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-state-primitives.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// Brace-balanced extraction (same helper shape as highway_visibility.test.js).
@@ -32,13 +36,28 @@ function extractBlock(src, signature) {
return src.slice(start, i);
}
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
// whole set rather than re-pinning at whichever file currently holds a function — re-pinning
// just breaks again next time, and a source-shape assertion that silently stops finding its
// target is indistinguishable from one that passes.
function highwaySources() {
const root = path.join(__dirname, '..', '..');
const jsDir = path.join(root, 'static', 'js');
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
return parts.join('\n');
}
test('highway declares the note-state provider slot', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
});
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
@@ -47,15 +66,23 @@ test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteSt
});
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function _makeBundle()');
// The bundle field must point straight at _noteState — not a fresh
// arrow each frame (the per-frame allocation the review flagged).
assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
// R3c: _noteState now takes hwState first, so the bundle hands out a per-INSTANCE bound
// view created ONCE in the factory (boundNoteState) rather than the raw function. The
// contract that matters is unchanged and still asserted: ONE stable reference, never a
// fresh arrow per frame (feedBack#254). Assert it is a bare identifier, not an inline
// function expression.
assert.match(fn, /getNoteState\s*[:=]\s*(?:boundNoteState|_noteState)\b/,
'bundle.getNoteState must be a stable reference (a name), not a per-frame arrow');
assert.doesNotMatch(fn, /getNoteState\s*[:=]\s*(?:\(|function)/,
'bundle.getNoteState must NOT be a fresh function per frame');
});
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function _makeBundle()');
// Same allocation discipline as getNoteState: highway_3d uses this
// bundle field to tell "provider attached" from "no provider but
@@ -78,8 +105,8 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
});
test('_noteState normalizes provider output as documented', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
const src = highwaySources();
const fn = extractBlock(fs.readFileSync(primitivesJs, 'utf8'), 'function _noteState(hwState, note, chartTime)');
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
@@ -90,12 +117,24 @@ test('_noteState normalizes provider output as documented', () => {
});
test('default 2D renderer threads note state into drawNote / drawSustains / chord path', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
// drawNote takes the trailing `ns` param.
assert.match(src, /function\s+drawNote\(\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, 'drawNote must accept the trailing ns param');
// R3c: drawNote moved to ./static/js/highway-draw.js and gained hwState as its FIRST arg
// (createHighway is a factory — a module cannot import per-instance state without two
// panels sharing it). The contract asserted here is unchanged: `ns` is still the TRAILING
// parameter, which is what the note-state threading depends on.
assert.match(src, /function\s+drawNote\(\s*hwState\s*,\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/,
'drawNote must take hwState first and keep ns as the trailing param');
// drawNotes / drawSustains / drawChords gate the lookup on the provider.
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*n\s*,\s*n\.t\s*\)\s*:\s*null/, 'visible-note paths must skip the lookup when no provider is set');
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/, 'chord-note path must key the lookup by the chord time and gate on the provider');
// R3c: _noteState gained an explicit hwState first arg (it lives in a module now, and
// createHighway is a factory). The CONTRACT here is unchanged and still the point: skip
// the lookup entirely when no provider is set — a per-visible-note call on every frame.
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*n\s*,\s*n\.t\s*\)\s*:\s*null/,
'visible-note paths must skip the lookup when no provider is set');
// Same, for the chord path: keyed by the CHORD's time (ch.t), not the note's, and still
// gated on the provider. Only the hwState arg is new.
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/,
'chord-note path must key the lookup by the chord time and gate on the provider');
});
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
+21 -4
View File
@@ -29,14 +29,31 @@ function extractBlock(src, signature) {
return src.slice(start, i);
}
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
// means they break again on the next carve — and worse, a source-shape assertion that silently
// stops finding its target is indistinguishable from one that passes.
function highwaySources() {
const root = path.join(__dirname, '..', '..');
const jsDir = path.join(root, 'static', 'js');
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.startsWith('highway-') && f.endsWith('.js')) {
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
}
return parts.join('\n');
}
test('highway declares the paused-render throttle state', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
const src = highwaySources();
assert.match(src, /(?:export\s+)?const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
});
test('draw() throttles full renders while the audio clock is stalled', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function draw()');
// Reuse getTime()'s pause signal rather than inventing a parallel one.
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
@@ -46,7 +63,7 @@ test('draw() throttles full renders while the audio clock is stalled', () => {
});
test('throttle runs after the ready gate, before bundle/draw', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const fn = extractBlock(src, 'function draw()');
// Regex landmarks (not exact-string indexOf) so harmless spacing /
// semicolon changes don't break the ordering guard — matches the
+23 -6
View File
@@ -37,18 +37,35 @@ function extractBlock(src, signature) {
// ── 2D highway (static/highway.js) ────────────────────────────────────────
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
// means they break again on the next carve — and worse, a source-shape assertion that silently
// stops finding its target is indistinguishable from one that passes.
function highwaySources() {
const root = path.join(__dirname, '..', '..');
const jsDir = path.join(root, 'static', 'js');
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.startsWith('highway-') && f.endsWith('.js')) {
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
}
return parts.join('\n');
}
test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
const src = highwaySources();
assert.match(src, /(?:export\s+)?const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
assert.match(src, /(?:export\s+)?const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
assert.match(src, /(?:export\s+)?const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
assert.match(src, /hwState\.STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
assert.match(src, /hwState\.STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
assert.match(src, /hwState\.STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
});
test('2D public API exposes getStringColors / setStringColors', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+hwState\.STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
const fn = extractBlock(src, 'setStringColors(arr)');
// Each provided index sets base + derived dim/bright; missing → default.
@@ -110,7 +127,7 @@ test('app.js color manager name-maps to both highways, with identity no-op + bui
// ── Executable: dim/bright derivation math ────────────────────────────────
function loadColorMath() {
const src = fs.readFileSync(highwayJs, 'utf8');
const src = highwaySources();
const snippet = [
extractBlock(src, 'function _clampByte(n)'),
extractBlock(src, 'function _parseHex(hex)'),
+5 -3
View File
@@ -26,11 +26,13 @@ function loadFn(file, name) {
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
}
const fingerLabel2D = loadFn('static/highway.js', 'teachingFingerLabel');
const degreeLabel2D = loadFn('static/highway.js', 'teachingDegreeLabel');
// R3c: the PURE geometry/label primitives were carved out of highway.js into
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
const fingerLabel2D = loadFn('static/js/highway-geometry.js', 'teachingFingerLabel');
const degreeLabel2D = loadFn('static/js/highway-geometry.js', 'teachingDegreeLabel');
const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel');
const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel');
const strumGroupBuckets = loadFn('static/highway.js', 'strumGroupBuckets');
const strumGroupBuckets = loadFn('static/js/highway-draw.js', 'strumGroupBuckets');
// ── teachingFingerLabel (fg) ─────────────────────────────────────────────────
+5
View File
@@ -124,6 +124,11 @@ function buildSandbox() {
jucePlayer: () => sandbox.jucePlayer,
};
vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
return sandbox;
}
@@ -0,0 +1,95 @@
// Nobody may monkey-patch window.showScreen. (#924)
//
// It used to be wrapped by THREE independent parties, each capturing whatever happened to be
// there at the time:
//
// app.js publishes the raw function
// -> static/v3/shell.js wrapped it (to call syncActive, and to map home -> v3-songs)
// -> the stems plugin wrapped it AGAIN (to tear down on leaving the player)
//
// Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled. A
// capture taken before shell.js installed silently dropped the mapping it carried — and the
// library opened on the dead legacy #home screen. Testers saw that as "randomly, the library
// shows the old interface" (#923).
//
// Neither wrapper ever needed to be one. showScreen already EMITS screen:changed, and that is
// already how app.js, audio-mixer.js and tour-engine.js do it. Both are listeners now, and
// window.showScreen is a plain function again — so the ordering hazard is structurally
// impossible rather than merely avoided.
//
// This test is the thing that keeps it that way. A wrapper reintroduced anywhere in static/
// fails CI.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
function jsFiles(dir) {
const out = [];
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...jsFiles(p));
else if (e.name.endsWith('.js')) out.push(p);
}
return out;
}
// strip comments so the prose above (and in shell.js) isn't read as an assignment
const scrub = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/[^\n]*$/gm, '');
test('nothing in static/ assigns window.showScreen', () => {
const offenders = [];
for (const f of jsFiles(path.join(ROOT, 'static'))) {
const src = scrub(fs.readFileSync(f, 'utf8'));
// `window.showScreen = ...` — an assignment, not a call or a typeof guard
if (/window\.showScreen\s*=(?!=)/.test(src)) offenders.push(path.relative(ROOT, f));
}
assert.deepEqual(
offenders, [],
'these files monkey-patch window.showScreen. Do not: three wrappers racing over one '
+ 'global is what made the library open on the legacy screen (#923). Listen to '
+ 'screen:changed instead — showScreen already emits it, with { id, from }.',
);
});
test('showScreen emits screen:changed with the screen it LEFT', () => {
const src = fs.readFileSync(path.join(ROOT, 'static', 'js', 'session.js'), 'utf8');
assert.match(
src,
/emit\('screen:changed',\s*\{\s*id,\s*from:/,
"screen:changed must carry `from` — without it, \"I am leaving the player\" is not "
+ 'expressible from an event, and the only way to say it is to wrap showScreen, which is '
+ 'the bug this exists to prevent',
);
});
test('screen:changing fires BEFORE the navigation work, screen:changed after', () => {
// The distinction is the whole point, and Codex caught me collapsing it.
//
// The stems plugin's wrapper tore down its audio graph BEFORE showScreen did anything.
// screen:changed fires at the very END — after core awaits library and provider loads — so
// moving the plugin onto it would delay teardown behind a slow fetch, or skip it if that
// fetch threw, and stems would keep playing on a non-player screen.
//
// screen:changing before anything happens. "I am leaving `from`." Cancel/teardown here.
// screen:changed after the DOM and data settle. "I am on `id`."
const src = fs.readFileSync(path.join(ROOT, 'static', 'js', 'session.js'), 'utf8');
const changing = src.indexOf("emit('screen:changing'");
const changed = src.indexOf("emit('screen:changed'");
assert.ok(changing !== -1, 'screen:changing must be emitted');
assert.ok(changed !== -1, 'screen:changed must be emitted');
assert.ok(changing < changed, 'screen:changing must come first');
// and `changing` must precede the first await, or it is no earlier than `changed` in practice
const firstAwait = src.indexOf('await ', changing);
assert.ok(firstAwait === -1 || changing < firstAwait,
'screen:changing must fire before showScreen awaits anything — that is its entire purpose');
});
test('the v3 shell reacts to screen:changed rather than wrapping showScreen', () => {
const src = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'shell.js'), 'utf8');
assert.match(scrub(src), /on\('screen:changed'/, 'shell.js must listen, not patch');
});
+8 -3
View File
@@ -1,4 +1,4 @@
// Verify the Settings-dropdown autosave path in static/app.js:
// Verify the Settings-dropdown autosave path in static/js/settings.js:
// persistSetting() must funnel one-field POSTs through a single chain so
// they hit the server one at a time, in call order, and a failed save
// must not poison the chain for later saves.
@@ -13,11 +13,16 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// R3d: settings was carved out of app.js into its own module. Bodies unchanged — only the file
// moved. It went cleanly because the WRITERS came with it: _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 no state container was needed.
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'settings.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in settings.js`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
+85
View File
@@ -0,0 +1,85 @@
// showScreen('home') must never land on the LEGACY library screen when v3 is present.
//
// Testers: "randomly, when moving to the library from another menu option, the library shows the
// old interface — never when a song ends."
//
// #home is the pre-v3 library screen. The v3 shell replaced it with #v3-songs, and the mapping
// DID exist — but only inside wrappers on `window.showScreen`, which fail two ways:
//
// 1. ORDER. THREE independent parties monkey-patch window.showScreen, each capturing whatever
// is there at the time: app.js publishes the raw function, shell.js wraps it to add the
// mapping, and the stems plugin wraps it again. Plugins load ASYNCHRONOUSLY, so the chain
// links up in whatever order the race settles. A capture taken before shell.js installs —
// or any re-assignment after it — silently drops the mapping. Hence "randomly".
//
// 2. THE INTERNAL CALLERS BYPASS window.showScreen ENTIRELY. closeCurrentSong and the
// Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees.
// Verified in a browser: the unwrapped function with 'home' lands on #home, always.
//
// "Never when a song ends" is the tell: closeCurrentSong resolves its target through
// _resolvePlayerOrigin(), which already applied the mapping — so that one path was fine.
//
// The guard now lives inside showScreen itself: one place every caller routes through.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SESSION_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js');
const src = () => fs.readFileSync(SESSION_JS, 'utf8');
function bodyOf(name) {
const s = src();
const at = s.indexOf(`export async function ${name}(`);
assert.notEqual(at, -1, `${name} not found`);
let depth = 0;
for (let i = s.indexOf('{', at); i < s.length; i++) {
if (s[i] === '{') depth++;
else if (s[i] === '}' && --depth === 0) return s.slice(at, i + 1);
}
throw new Error('unbalanced');
}
test('showScreen maps the legacy #home library to #v3-songs', () => {
const fn = bodyOf('showScreen');
assert.match(
fn,
/id\s*===\s*'home'[\s\S]{0,80}getElementById\('v3-songs'\)[\s\S]{0,60}id\s*=\s*'v3-songs'/,
"showScreen must route 'home' to 'v3-songs' ITSELF — relying on a wrapper over "
+ 'window.showScreen loses the mapping whenever a plugin wraps it first, and misses the '
+ 'module-internal callers (closeCurrentSong, Esc-from-settings) altogether',
);
});
test('the guard runs BEFORE the screen is activated', () => {
const fn = bodyOf('showScreen');
const guard = fn.search(/id\s*=\s*'v3-songs'/);
const activate = fn.indexOf('classList.add(\'active\')');
assert.ok(guard !== -1 && activate !== -1);
assert.ok(guard < activate,
'the mapping must be applied before the screen is activated, or #home is shown first');
});
test('the guard is conditional on v3 actually being present', () => {
const fn = bodyOf('showScreen');
assert.match(fn, /getElementById\('v3-songs'\)/,
'the mapping must check #v3-songs exists — without it there is nowhere to route to');
});
test('it does NOT redirect v3-home — the dashboard is a real screen', () => {
// Codex [P1] on the first cut. _resolvePlayerOrigin() maps BOTH 'home' and 'v3-home' —
// correctly, because it computes where to RETURN TO after a song, and landing on the Songs
// list from the dashboard is right. Copying that condition into showScreen is NOT: #v3-home
// is the v3 DASHBOARD, which the shell's Home nav, the onboarding tour and the dashboard
// re-render listener all target. Redirecting it makes Home unreachable.
//
// A legacy alias is not the same thing as a return target.
const fn = bodyOf('showScreen');
// the condition, i.e. everything between `if (` and the `{` that opens `id = 'v3-songs'`
const m = fn.match(/if \(([\s\S]*?)\)\s*\{\s*id = 'v3-songs';/);
assert.ok(m, 'the legacy-home guard was not found');
assert.doesNotMatch(m[1], /v3-home/,
"showScreen must NOT redirect 'v3-home' — that is the dashboard, not the legacy library");
assert.match(m[1], /id === 'home'/, "it must still redirect the legacy 'home'");
});
+9 -4
View File
@@ -12,6 +12,11 @@ const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
// R3d: closeCurrentSong (and showScreen and playSong, the mutual recursion they form) moved to
// static/js/session.js. Bodies unchanged — only the file. The WINDOW CONTRACT stays in app.js,
// which is the whole point of it: app.js is the only place that publishes names for the markup's
// onclick= handlers to resolve against.
const SESSION_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function buildSandbox({ playerOriginScreen = 'home' } = {}) {
@@ -66,13 +71,13 @@ function loadClose(sandbox, src) {
}
test('closeCurrentSong is exported on window and window.feedBack', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(APP_JS, 'utf8'); // the contract lives in app.js
assert.match(src, /window\.closeCurrentSong\s*=\s*closeCurrentSong/);
assert.match(src, /window\.feedBack\.closeCurrentSong\s*=\s*closeCurrentSong/);
});
test('closeCurrentSong uses _playerOriginScreen when set', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(SESSION_JS, 'utf8');
const sandbox = buildSandbox({ playerOriginScreen: 'favorites' });
loadClose(sandbox, src);
await sandbox.__closeCurrentSong();
@@ -87,7 +92,7 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
});
test('closeCurrentSong falls back to home when origin missing', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(SESSION_JS, 'utf8');
const sandbox = buildSandbox({ playerOriginScreen: null });
loadClose(sandbox, src);
await sandbox.__closeCurrentSong();
@@ -96,7 +101,7 @@ test('closeCurrentSong falls back to home when origin missing', async () => {
});
test('closeCurrentSong falls back to home when origin is empty string', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(SESSION_JS, 'utf8');
const sandbox = buildSandbox({ playerOriginScreen: '' });
loadClose(sandbox, src);
await sandbox.__closeCurrentSong();
+10
View File
@@ -45,6 +45,11 @@ function buildSandbox({ juceMode = false, audioT = 12.5, chartT = 11.8, juceT }
performance: { now: () => 1000.123 },
};
vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
return sandbox;
}
@@ -97,6 +102,11 @@ test('time and audioT are the same number (not duplicated computation)', () => {
performance: { now: () => 1000 },
};
vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
loadFunctions(sandbox, src);
const p = sandbox.__payload();
assert.equal(p.time, p.audioT, 'time must equal audioT');
+7 -1
View File
@@ -4,7 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// R3d: playSong moved to static/js/session.js with showScreen and closeCurrentSong.
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js');
// The speed controls were carved out into static/js/player-controls.js (R3a); playSong,
// which resets them on a new song, stayed in app.js. This test spans both.
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
@@ -122,6 +123,11 @@ function buildSandbox({ juceMode = false } = {}) {
if (el) sliderInputs.push(el.id);
};
vm.createContext(sandbox);
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
// creates a global lexical binding that app.js and the modules could reach as a bare
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
return sandbox;
}
+8 -2
View File
@@ -488,8 +488,14 @@ test('gate: does not claim a hold when the feature is off', async () => {
assert.equal(holds, 0);
});
test('the autoplay gate is a generic core hook with a fail-open backstop (app.js)', () => {
const appSrc = fs.readFileSync(APP_JS, 'utf8');
test('the autoplay gate is a generic core hook with a fail-open backstop', () => {
// R3d: the gate SPANS two files now. window.feedBack.holdAutoplay is the public hook and
// stays on app.js's window contract; the machinery it drives (_autoplayHeld,
// _clearAutoplayHold, the backstop) moved to static/js/session.js with playSong. Read both —
// re-pinning at one would silently stop checking half the gate.
const SESSION_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js');
const appSrc = fs.readFileSync(APP_JS, 'utf8')
+ '\n' + fs.readFileSync(SESSION_JS, 'utf8').replace(/^export /gm, '');
assert.match(appSrc, /window\.feedBack\.holdAutoplay = function/);
assert.match(appSrc, /AUTOPLAY_HOLD_BACKSTOP_MS/); // fail-open: never strand the song
assert.match(appSrc, /if \(_autoplayHeld\) \{ _autoplayStart = start;/); // a gated start is stashed
+130
View File
@@ -0,0 +1,130 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crowd = require('../../static/v3/venue-crowd.js');
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const SCENE_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'venue-scene-3d.js');
test('perf state → crowd state mapping', () => {
assert.equal(crowd.crowdStateOfPerf('smoke'), 'bored');
assert.equal(crowd.crowdStateOfPerf('recovery'), 'bored');
assert.equal(crowd.crowdStateOfPerf('idle'), 'neutral');
assert.equal(crowd.crowdStateOfPerf('steady'), 'neutral');
assert.equal(crowd.crowdStateOfPerf('strong'), 'engaged');
assert.equal(crowd.crowdStateOfPerf('fire'), 'ecstatic');
assert.equal(crowd.crowdStateOfPerf('FIRE'), 'ecstatic');
assert.equal(crowd.crowdStateOfPerf('bogus'), 'neutral');
assert.equal(crowd.crowdStateOfPerf(undefined), 'neutral');
});
test('machine: target must be stable for STABLE_MS before committing', () => {
const m = crowd.createCrowdMachine();
assert.equal(m.current, 'neutral');
assert.equal(m.update('fire', 0), null);
assert.equal(m.update('fire', crowd.STABLE_MS - 1), null);
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic');
assert.equal(m.current, 'ecstatic');
});
test('machine: flapping target restarts the stability window', () => {
const m = crowd.createCrowdMachine();
m.update('fire', 0);
// Target changes → candidate resets.
m.update('strong', 1000);
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS - 1), null);
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS), 'engaged');
});
test('machine: returning to current state clears the candidate', () => {
const m = crowd.createCrowdMachine();
m.update('fire', 0);
m.update('steady', 1000); // back to neutral (current) — candidate dropped
// 'fire' again must wait a full stability window from scratch.
assert.equal(m.update('fire', 2000), null);
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS - 1), null);
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS), 'ecstatic');
});
test('machine: DWELL_MS enforced between switches', () => {
const m = crowd.createCrowdMachine();
m.update('fire', 0);
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic'); // switch at t=3000
const t = crowd.STABLE_MS;
// Immediately drop to smoke: stable window passes but dwell hasn't.
m.update('smoke', t + 1);
assert.equal(m.update('smoke', t + 1 + crowd.STABLE_MS), null);
// After the dwell expires the pending candidate commits.
assert.equal(m.update('smoke', t + crowd.DWELL_MS), 'bored');
});
test('machine: multi-step jumps allowed (bored → ecstatic)', () => {
const m = crowd.createCrowdMachine();
m.update('smoke', 0);
assert.equal(m.update('smoke', crowd.STABLE_MS), 'bored');
const t = crowd.DWELL_MS + 1000;
m.update('fire', t);
assert.equal(m.update('fire', t + crowd.STABLE_MS), 'ecstatic');
});
test('stingerForStreak fires on rising milestone crossings only', () => {
assert.equal(crowd.stingerForStreak(24, 25), 'cheer');
assert.equal(crowd.stingerForStreak(0, 100), 'cheer');
assert.equal(crowd.stingerForStreak(25, 26), null);
assert.equal(crowd.stingerForStreak(50, 50), null);
assert.equal(crowd.stingerForStreak(30, 0), null); // streak reset
});
test('stingerForAccuracy thresholds', () => {
assert.equal(crowd.stingerForAccuracy(95), 'cheer');
assert.equal(crowd.stingerForAccuracy(90), 'cheer');
assert.equal(crowd.stingerForAccuracy(80), 'clap');
assert.equal(crowd.stingerForAccuracy(75), 'clap');
assert.equal(crowd.stingerForAccuracy(60), null);
assert.equal(crowd.stingerForAccuracy('nope'), null);
assert.equal(crowd.stingerForAccuracy(undefined), null);
});
test('normalizeManifest requires all four loops, resolves base', () => {
assert.equal(crowd.normalizeManifest(null), null);
assert.equal(crowd.normalizeManifest({}), null);
assert.equal(crowd.normalizeManifest({ loops: { bored: 'b.mp4' } }), null);
const m = crowd.normalizeManifest({
base: '/api/plugins/career/venues/bar/',
loops: { bored: 'bored.mp4', neutral: 'neutral.mp4', engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4' },
stingers: { cheer: 'cheer.mp4' },
});
assert.equal(m.loops.ecstatic, '/api/plugins/career/venues/bar/ecstatic.mp4');
assert.equal(m.stingers.cheer, '/api/plugins/career/venues/bar/cheer.mp4');
assert.equal(m.stingers.clap, '');
});
test('highway_3d exposes the crowd backdrop globals', () => {
const src = fs.readFileSync(H3D_JS, 'utf8');
assert.match(src, /window\.h3dVenueBackdropSetVideo\s*=/);
assert.match(src, /window\.h3dVenueBackdropSetMix\s*=/);
// The venue style must own crowd plane teardown (VideoTexture dispose).
assert.match(src, /_venueCrowdVideos/);
assert.match(src, /_venueCrowdMix/);
});
test('index.html loads venue-crowd.js deferred, after venue-scene-3d.js', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
const crowdIdx = html.indexOf('/static/v3/venue-crowd.js');
const sceneIdx = html.indexOf('/static/v3/venue-scene-3d.js');
assert.ok(crowdIdx > 0, 'venue-crowd.js script tag missing');
assert.ok(crowdIdx > sceneIdx, 'venue-crowd.js must load after venue-scene-3d.js');
assert.match(html, /<script defer src="\/static\/v3\/venue-crowd\.js"><\/script>/);
});
test('venue-scene-3d activates/deactivates the crowd layer', () => {
const src = fs.readFileSync(SCENE_JS, 'utf8');
assert.match(src, /syncCrowd\(true\)/);
assert.match(src, /syncCrowd\(false\)/);
assert.match(src, /v3VenueCrowd/);
});
View File
+70
View File
@@ -0,0 +1,70 @@
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'career'))
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
sys.modules.pop('routes', None)
import routes as career_routes
class FakeMetaDb:
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
def __init__(self):
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
self.conn.execute(
"""CREATE TABLE song_stats (
filename TEXT, arrangement TEXT, best_accuracy REAL
)"""
)
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
def add(self, filename, arrangement, best_accuracy, in_library=True):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
(filename, arrangement, best_accuracy))
if in_library:
self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
"(SELECT 1 FROM songs WHERE filename = ?)",
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
self.conn.commit()
@pytest.fixture(autouse=True)
def _bind_career_routes():
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
prev = sys.modules.get('routes')
sys.modules['routes'] = career_routes
try:
yield
finally:
if prev is not None:
sys.modules['routes'] = prev
else:
sys.modules.pop('routes', None)
@pytest.fixture(autouse=True)
def _reset_state():
# Module state outlives tests when the module stays imported — reset the
# mutable bits so ordering can't leak downloads/content between tests.
career_routes._state["downloads"] = {}
yield
@pytest.fixture
def meta_db():
return FakeMetaDb()
@pytest.fixture
def client(tmp_path, meta_db):
app = FastAPI()
career_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
return TestClient(app)
+171
View File
@@ -0,0 +1,171 @@
"""HTTP-level tests for the career plugin: stars, unlocks, packs."""
import hashlib
import json
import zipfile
import routes as career_routes
def _install_fake_pack(venue_id, files=None):
"""Drop a valid installed pack into the plugin's venues dir."""
pack_dir = career_routes._venue_dir(venue_id)
pack_dir.mkdir(parents=True, exist_ok=True)
loops = {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS}
(pack_dir / "manifest.json").write_text(json.dumps(
{"venue": venue_id, "version": 1, "loops": loops,
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"}}))
for name in list(loops.values()) + ["clap.mp4", "cheer.mp4"]:
(pack_dir / name).write_bytes((files or {}).get(name, b"\x00video"))
def test_stars_from_best_accuracy_across_arrangements(client, meta_db):
# Thresholds 0.6/0.75/0.85 → 1/2/3 stars; best arrangement wins.
meta_db.add("a.feedpak", "guitar", 0.5) # 0 stars
meta_db.add("b.feedpak", "guitar", 0.62) # 1 star
meta_db.add("c.feedpak", "guitar", 0.70)
meta_db.add("c.feedpak", "bass", 0.80) # 2 stars (max across arrangements)
meta_db.add("d.feedpak", "guitar", 0.99) # 3 stars
state = client.get("/api/plugins/career/state").json()
assert state["stars_total"] == 6
assert state["stars_per_song"] == {"b.feedpak": 1, "c.feedpak": 2, "d.feedpak": 3}
def test_unlock_flags_follow_thresholds(client, meta_db):
# 6 stars: bar (0) unlocked, club (50) and arena (150) locked.
for i in range(2):
meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each
state = client.get("/api/plugins/career/state").json()
by_id = {v["id"]: v for v in state["venues"]}
assert by_id["bar"]["unlocked"] is True
assert by_id["club"]["unlocked"] is False
assert by_id["arena"]["unlocked"] is False
def test_orphaned_stats_do_not_count(client, meta_db):
# A song removed from the library (stats row survives the scan) must not
# keep contributing stars.
meta_db.add("gone.feedpak", "guitar", 0.99, in_library=False)
meta_db.add("here.feedpak", "guitar", 0.99)
state = client.get("/api/plugins/career/state").json()
assert state["stars_total"] == 3
assert "gone.feedpak" not in state["stars_per_song"]
def test_star_detail_rows_sorted_by_next_star_gap(client, meta_db):
meta_db.add("far.feedpak", "guitar", 0.61) # 1★, 14% from next
meta_db.add("close.feedpak", "guitar", 0.84) # 2★, 1% from next
meta_db.add("maxed.feedpak", "guitar", 0.99) # 3★, maxed
detail = client.get("/api/plugins/career/state").json()["star_detail"]
assert [r["filename"] for r in detail] == \
["close.feedpak", "far.feedpak", "maxed.feedpak"]
close = detail[0]
assert close["stars"] == 2 and close["next_star_at"] == 0.85
assert detail[2]["next_star_at"] is None
def test_no_stats_still_serves_state(client):
state = client.get("/api/plugins/career/state").json()
assert state["stars_total"] == 0
assert state["venues"][0]["unlocked"] is True # bar is always open
def test_download_unknown_venue_404s(client):
assert client.post("/api/plugins/career/packs/nope/download").status_code == 404
assert client.post("/api/plugins/career/packs/../etc/download").status_code == 404
def test_download_without_published_pack_404s(client):
# Bundled packs are already installed; download still requires a published
# remote pack entry.
assert client.post("/api/plugins/career/packs/bar/download").status_code == 404
def test_download_locked_venue_403s(client, monkeypatch):
club = career_routes._venue("club")
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
def test_bundled_bar_pack_is_installed_and_served(client):
state = client.get("/api/plugins/career/state").json()
bar = {v["id"]: v for v in state["venues"]}["bar"]
assert bar["installed"] is True
assert bar["bundled"] is True
assert bar["has_pack"] is True
ok = client.get("/api/plugins/career/venues/bar/manifest.json")
assert ok.status_code == 200
manifest = ok.json()
assert manifest["loops"]["ecstatic"] == "ecstatic.mp4"
assert manifest["intro"] == {"video": "intro.mp4", "audio": "bar-ambience.mp3"}
assert client.get("/api/plugins/career/venues/bar/intro.mp4").status_code == 200
audio = client.get("/api/plugins/career/venues/bar/bar-ambience.mp3")
assert audio.status_code == 200
assert audio.headers["content-type"].startswith("audio/mpeg")
def test_pack_file_serving_and_traversal_guard(client):
_install_fake_pack("club")
ok = client.get("/api/plugins/career/venues/club/manifest.json")
assert ok.status_code == 200
assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4"
video = client.get("/api/plugins/career/venues/club/bored.mp4")
assert video.status_code == 200
assert video.headers["content-type"].startswith("video/mp4")
assert video.headers["x-content-type-options"] == "nosniff"
# Traversal / junk shapes never resolve.
for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"):
assert client.get(f"/api/plugins/career/venues/club/{bad}").status_code == 404
assert client.get("/api/plugins/career/venues/../club/manifest.json").status_code == 404
def test_state_reports_installed_and_delete_removes(client):
_install_fake_pack("club")
state = client.get("/api/plugins/career/state").json()
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
assert client.delete("/api/plugins/career/packs/club").status_code == 200
state = client.get("/api/plugins/career/state").json()
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is False
def test_download_worker_end_to_end(client, tmp_path):
# Build a real pack zip, serve it via file://, verify the full worker path:
# stream → sha256 → extract (flat names only) → validate → swap in.
src = tmp_path / "src"
src.mkdir()
names = [f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS] + ["cheer.mp4"]
for name in names:
(src / name).write_bytes(b"fake-video-" + name.encode())
(src / "manifest.json").write_text(json.dumps({
"venue": "bar", "version": 1,
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
"stingers": {"cheer": "cheer.mp4"},
}))
zip_path = tmp_path / "bar-pack.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for p in src.iterdir():
zf.write(p, p.name)
sha = hashlib.sha256(zip_path.read_bytes()).hexdigest()
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
career_routes._download_pack(
"bar", {"url": zip_path.as_uri(), "sha256": sha}, progress)
assert progress["status"] == "done", progress["error"]
assert career_routes._installed("bar")
assert progress["bytes_done"] == zip_path.stat().st_size
# Corrupt hash → error status, nothing installed over the good pack.
bad = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
career_routes._download_pack("bar", {"url": zip_path.as_uri(), "sha256": "0" * 64}, bad)
assert bad["status"] == "error"
assert "sha256" in bad["error"]
def test_double_download_409s(client, monkeypatch):
bar = career_routes._venue("bar")
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
# Pretend one is already running.
career_routes._state["downloads"]["bar"] = {"status": "running"}
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
+144
View File
@@ -15,6 +15,7 @@ Covers:
import importlib
import sys
import threading
import demo_mode
import pytest
@@ -616,3 +617,146 @@ def test_diag_cap_console_enforces_byte_cap(tmp_path, monkeypatch):
finally:
_cleanup(server, client)
# ── #902: the janitor re-entry guard ────────────────────────────────────────────
#
# The guard in startup_events() read:
#
# 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)` — and the
# not-already-started half never runs when the env var is truthy, which is the only case
# that reaches it at all. A second startup started a SECOND janitor thread, overwrote the
# handle, and shutdown then joined only the last one: the first leaked and kept firing
# registered hooks hourly, forever.
#
# The guard now lives INSIDE start_janitor(), not at the call site — a caller cannot get
# operator precedence wrong if there is nothing for it to get wrong.
def _live_janitors():
return [t for t in threading.enumerate() if t.name == "demo-janitor" and t.is_alive()]
def test_start_janitor_is_idempotent(monkeypatch):
"""Two starts must not produce two threads. This is the #902 regression."""
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_STARTED", False)
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_THREAD", None)
before = len(_live_janitors())
try:
demo_mode.start_janitor()
first = demo_mode._DEMO_JANITOR_THREAD
demo_mode.start_janitor() # <-- the second startup
second = demo_mode._DEMO_JANITOR_THREAD
assert first is second, (
"a second start_janitor() replaced the thread handle — the first thread is now "
"unreachable, will never be joined, and keeps running hooks forever (#902)"
)
assert len(_live_janitors()) == before + 1, (
f"expected exactly one janitor thread, found {len(_live_janitors()) - before}"
)
finally:
demo_mode.stop_janitor(timeout=2)
def test_a_second_startup_does_not_leak_a_janitor(monkeypatch):
"""The real shape of the bug: startup runs twice in one process."""
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_STARTED", False)
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_THREAD", None)
before = len(_live_janitors())
try:
for _ in range(3):
if demo_mode.demo_mode_enabled():
demo_mode.start_janitor()
assert len(_live_janitors()) == before + 1, "a repeated startup leaked janitor threads"
finally:
demo_mode.stop_janitor(timeout=2)
assert len(_live_janitors()) == before, "stop_janitor() did not join the thread"
def test_a_timed_out_stop_does_not_disable_the_janitor_forever(monkeypatch):
"""Codex [P2] on the first cut of the #902 fix.
stop_janitor() deliberately leaves _DEMO_JANITOR_STARTED True when a hook outruns the
join timeout, so a later startup can't spawn a second janitor beside a live one. But
that hook usually finishes a moment later: the thread exits, and the flag stays true.
A guard keyed on the FLAG would then refuse to start a replacement for the rest of the
process demo-mode cleanup silently dead. Guarding on the thread's LIVENESS is what
makes both the double-start and the never-restart impossible.
"""
before = len(_live_janitors())
# Simulate the aftermath of a timed-out stop: flag still set, thread already gone.
dead = threading.Thread(target=lambda: None, name="demo-janitor")
dead.start()
dead.join()
assert not dead.is_alive()
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_STARTED", True)
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_THREAD", dead)
try:
demo_mode.start_janitor()
assert len(_live_janitors()) == before + 1, (
"no replacement janitor was started — a stale STARTED flag from a timed-out "
"stop disabled demo-mode cleanup for the rest of the process"
)
assert demo_mode._DEMO_JANITOR_THREAD is not dead
finally:
demo_mode.stop_janitor(timeout=2)
def test_a_replacement_starts_while_a_doomed_janitor_is_still_finishing_a_hook(monkeypatch):
"""Codex [P2], second pass — the sharp window.
stop_janitor() times out while a hook is still running. The old thread is ALIVE but
DOOMED: its stop event is set, and it will exit the moment the hook returns. A guard
that keys on liveness alone treats it as a running janitor, skips the replacement, and
a second later there is no janitor at all.
It also pins the reason each janitor owns its OWN stop event: the old code cleared a
single SHARED Event on start, which would have RESURRECTED the doomed thread it loops
back to wait(), sees the flag cleared, and carries on. Two janitors, which is the bug we
started from.
"""
before = len(_live_janitors())
# A janitor mid-hook: alive, and already told to stop.
release = threading.Event()
old_stop = threading.Event()
def _stuck():
release.wait(timeout=5) # pretend we're inside a slow hook
old = threading.Thread(target=_stuck, daemon=True, name="demo-janitor")
old.start()
old_stop.set() # stop_janitor() timed out and left this set
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_STARTED", True)
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_THREAD", old)
monkeypatch.setattr(demo_mode, "_DEMO_JANITOR_STOP", old_stop)
try:
demo_mode.start_janitor()
new = demo_mode._DEMO_JANITOR_THREAD
assert new is not old, (
"no replacement was started for a doomed janitor — once its hook returns the "
"process is left with no janitor at all"
)
assert new.is_alive()
# the old thread's own stop event must STILL be set: starting a replacement must not
# resurrect it
assert old_stop.is_set(), (
"the doomed janitor's stop event was cleared — it would loop back around and "
"keep running alongside the replacement. Two janitors."
)
assert demo_mode._DEMO_JANITOR_STOP is not old_stop, "the new janitor must own a fresh event"
finally:
release.set()
old.join(timeout=5)
demo_mode.stop_janitor(timeout=2)
assert len(_live_janitors()) == before
+149
View File
@@ -0,0 +1,149 @@
"""The runtime stylesheet must NOT be written over the committed one. (#911)
Two different things used to share `static/tailwind.min.css`:
the committed file a BUILD ARTEFACT image-baked, generated from the in-tree plugins
only, and verified by CI's `tailwind-fresh` check.
the runtime sheet PER-INSTALL STATE additionally scans whatever the user installed
into FEEDBACK_PLUGINS_DIR, so it differs machine to machine.
Writing the second over the first meant that merely RUNNING THE DEV SERVER from a git checkout
silently modified a tracked file. `git add -A` then swept a 100KB reshuffle of minified CSS
into the commit and `ci/tailwind-fresh` went red with a diff that explained nothing on a PR
whose real change touched no Tailwind classes at all. It also meant writing app state into the
app directory, which is read-only in some deploys.
These tests pin the separation. The first is the one that matters: it is the exact failure that
shipped.
"""
import importlib
import sys
from pathlib import Path
import pytest
@pytest.fixture()
def tw(monkeypatch, tmp_path):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("tailwind_rebuild", None)
return importlib.import_module("tailwind_rebuild")
def test_the_runtime_sheet_is_not_the_committed_one(tw, tmp_path):
"""THE REGRESSION. The runtime build must never target the tracked file."""
runtime = tw.runtime_css_path()
committed = tw.APP_DIR / "static" / "tailwind.min.css"
assert runtime != committed, (
"the runtime stylesheet is being written over the COMMITTED one — running the dev "
"server in a checkout will silently dirty a tracked file and red-light ci/tailwind-fresh"
)
assert committed not in runtime.parents
assert runtime.parent == tmp_path, "the runtime sheet belongs in CONFIG_DIR"
def test_runtime_path_follows_CONFIG_DIR(monkeypatch, tmp_path):
"""It is per-install state, so it lives wherever this install keeps its state."""
other = tmp_path / "elsewhere"
monkeypatch.setenv("CONFIG_DIR", str(other))
sys.modules.pop("tailwind_rebuild", None)
tw = importlib.import_module("tailwind_rebuild")
assert tw.runtime_css_path() == other / "tailwind.min.css"
def test_runtime_path_falls_back_when_CONFIG_DIR_is_unset(monkeypatch):
monkeypatch.delenv("CONFIG_DIR", raising=False)
monkeypatch.delenv("SLOPSMITH_CONFIG_DIR", raising=False)
sys.modules.pop("tailwind_rebuild", None)
tw = importlib.import_module("tailwind_rebuild")
p = tw.runtime_css_path()
assert p.name == "tailwind.min.css"
assert "static" not in p.parts, "must not fall back into the app's static/ dir"
def test_rebuild_never_touches_the_committed_file(tw, tmp_path, monkeypatch):
"""Belt and braces: drive rebuild() and assert the tracked file is byte-identical.
This is the assertion that would actually have caught #911 in CI.
"""
committed = tw.APP_DIR / "static" / "tailwind.min.css"
before = committed.read_bytes() if committed.is_file() else None
tw.rebuild("test") # best-effort; may skip if node/tailwind is absent — that is fine
after = committed.read_bytes() if committed.is_file() else None
assert after == before, (
"rebuild() modified the COMMITTED static/tailwind.min.css — this is #911: it dirties a "
"tracked file in any git checkout and red-lights ci/tailwind-fresh"
)
# ── Codex [P2]: a persisted sheet must not outlive its reason ──────────────────
#
# A runtime sheet can survive the thing that justified it and then MASK newer core CSS —
# possibly forever, because startup only rebuilds when user plugins exist and skips entirely
# when the toolchain is absent.
def _server(monkeypatch, tmp_path):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
for m in ("server", "tailwind_rebuild"):
sys.modules.pop(m, None)
return importlib.import_module("server")
def test_runtime_sheet_is_ignored_when_the_user_has_no_plugins(monkeypatch, tmp_path):
"""Remove your plugins and the committed sheet is authoritative again — it is complete by
definition. A leftover runtime sheet would carry classes for plugins that are gone."""
srv = _server(monkeypatch, tmp_path)
(tmp_path / "tailwind.min.css").write_text("/* stale runtime sheet */")
monkeypatch.setattr(srv.tailwind_rebuild, "user_plugin_count", lambda: 0)
assert srv._runtime_css_if_usable() is None
def _stamp(srv, tmp_path, *, matching: bool):
"""Write the sidecar that records WHICH CORE the runtime sheet was built against."""
import json
h = srv.tailwind_rebuild._committed_css_fingerprint() if matching else "0" * 64
# ask for the real path rather than hardcoding it — with_suffix('.meta.json') on
# tailwind.min.css yields tailwind.min.meta.json, not tailwind.meta.json
srv.tailwind_rebuild.runtime_meta_path().write_text(json.dumps({"committed_sha256": h}))
def test_runtime_sheet_is_ignored_when_it_was_built_against_a_DIFFERENT_core(monkeypatch, tmp_path):
"""An upgrade ships new core classes. A runtime sheet built against the OLD core would hide
them and with no Tailwind toolchain present, nothing would ever rebuild it.
Freshness is decided by CONTENT, not mtime. Codex [P2] on the mtime version, and correct:
archives and container images routinely PRESERVE SOURCE MTIMES, so a just-shipped stylesheet
can carry an OLDER timestamp than a runtime sheet built days ago and an mtime check would
then call the stale one fresh, masking the new CSS forever.
"""
srv = _server(monkeypatch, tmp_path)
(tmp_path / "tailwind.min.css").write_text("/* built against the old core */")
monkeypatch.setattr(srv.tailwind_rebuild, "user_plugin_count", lambda: 1)
_stamp(srv, tmp_path, matching=False)
assert srv._runtime_css_if_usable() is None, (
"a runtime sheet built against a different core must not mask the shipped CSS"
)
def test_runtime_sheet_is_ignored_when_it_has_no_stamp_at_all(monkeypatch, tmp_path):
"""A sheet from before this mechanism existed. Unknown provenance -> do not trust it."""
srv = _server(monkeypatch, tmp_path)
(tmp_path / "tailwind.min.css").write_text("/* no sidecar */")
monkeypatch.setattr(srv.tailwind_rebuild, "user_plugin_count", lambda: 1)
assert srv._runtime_css_if_usable() is None
def test_runtime_sheet_IS_used_when_it_matches_this_core_and_plugins_exist(monkeypatch, tmp_path):
"""The case it exists for."""
srv = _server(monkeypatch, tmp_path)
runtime = tmp_path / "tailwind.min.css"
runtime.write_text("/* fresh, with plugin classes */")
monkeypatch.setattr(srv.tailwind_rebuild, "user_plugin_count", lambda: 2)
_stamp(srv, tmp_path, matching=True)
assert srv._runtime_css_if_usable() == runtime