mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-14 00:40:08 +00:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc24145fc | ||
|
|
a12b459fcc | ||
|
|
9f91fc46e2 | ||
|
|
523feb161e | ||
|
|
b7ff77fb6e | ||
|
|
e2215df753 | ||
|
|
52e7ffa5f8 | ||
|
|
ea0ca94742 | ||
|
|
36d984fffd | ||
|
|
e779c72396 | ||
|
|
e1562559ba | ||
|
|
cfc138ab5e | ||
|
|
f8fc5e6a5f | ||
|
|
803193046e | ||
|
|
4abdfb5f5c | ||
|
|
99812b8ed3 | ||
|
|
702a9c6daa | ||
|
|
9d8fda6d46 | ||
|
|
663e8ff4a1 | ||
|
|
f362c9a083 | ||
|
|
be78b4f29e | ||
|
|
f9a57ba044 | ||
|
|
c83fec267e | ||
|
|
c73ff96dfe | ||
|
|
8d3db5f42c | ||
|
|
f27d4f623c | ||
|
|
57e7db5c2a | ||
|
|
c78e7e04d3 | ||
|
|
d539421a03 | ||
|
|
545e569ad6 | ||
|
|
84fe29688c | ||
|
|
69aac32278 | ||
|
|
0a6e0309e5 | ||
|
|
36cf77dc44 | ||
|
|
12eb73aee9 | ||
|
|
1a386c272d | ||
|
|
8e89b39ad3 | ||
|
|
c6963fdf30 | ||
|
|
d9fa6d3f55 | ||
|
|
23ecddc721 | ||
|
|
79825af28e | ||
|
|
99b6d3c384 | ||
|
|
0cc08ebebf | ||
|
|
a2a48b3912 | ||
|
|
db3ca34fcb | ||
|
|
05a9bee38f | ||
|
|
286a24214f | ||
|
|
9178959dbd | ||
|
|
de10e81259 | ||
|
|
1172bc30cb | ||
|
|
5e5d892a63 | ||
|
|
cab652d145 | ||
|
|
78dbb039b7 |
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Career plugin — only what the prebuilt core Tailwind doesn't ship
|
||||
(plugin files are outside the core content glob, so responsive grid
|
||||
variants and cyan button shades live here under plugin-prefixed names). */
|
||||
|
||||
.career-venues {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.career-btn {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.career-btn-primary { background-color: #0891b2; color: #fff; }
|
||||
.career-btn-primary:hover { background-color: #06b6d4; }
|
||||
.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; }
|
||||
.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||
|
||||
.career-bar-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(31, 41, 55, 0.9);
|
||||
overflow: hidden;
|
||||
}
|
||||
.career-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #06b6d4;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.career-star-list {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.career-star-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.career-star-row .stars {
|
||||
color: #facc15;
|
||||
letter-spacing: 0.1em;
|
||||
min-width: 3.2em;
|
||||
}
|
||||
.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); }
|
||||
.career-star-row .song {
|
||||
color: #e5e7eb;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.career-star-row .song .artist { color: #9ca3af; }
|
||||
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
||||
.career-star-row .hint.close { color: #22d3ee; }
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "career",
|
||||
"name": "Career",
|
||||
"version": "0.1.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"description": "Career mode \u2014 gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/career.css",
|
||||
"routes": "routes.py",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "system"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"},
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="max-w-5xl mx-auto px-4 py-6">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-1">
|
||||
<h1 class="text-2xl font-bold text-white">Career</h1>
|
||||
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
||||
<div id="career-progress-wrap" class="mb-6">
|
||||
<div class="career-bar-track">
|
||||
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
|
||||
</div>
|
||||
<div id="career-venues" class="career-venues"></div>
|
||||
<div class="mt-8">
|
||||
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
|
||||
<div id="career-star-summary" class="text-xs text-gray-400"></div>
|
||||
</div>
|
||||
<div id="career-star-list" class="career-star-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
}
|
||||
}());
|
||||
@@ -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.
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"star_accuracy_thresholds": [
|
||||
0.6,
|
||||
0.75,
|
||||
0.85
|
||||
],
|
||||
"venues": [
|
||||
{
|
||||
"id": "bar",
|
||||
"name": "The Dive Bar",
|
||||
"description": "Sticky floors, a dozen regulars, and a PA that has seen better decades.",
|
||||
"star_threshold": 0,
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "club",
|
||||
"name": "Velvet Room",
|
||||
"description": "A proper club stage. People actually came to hear you.",
|
||||
"star_threshold": 50,
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "arena",
|
||||
"name": "Feedback Arena",
|
||||
"description": "Ten thousand seats. Try not to think about it.",
|
||||
"star_threshold": 150,
|
||||
"pack": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2418,6 +2418,13 @@
|
||||
let _venueSceneAssetsLoaded = false;
|
||||
let _venueSceneLoadFailed = false;
|
||||
const _venueTextureCache = new Map();
|
||||
// Crowd video layers (career mode). venue-crowd.js owns the <video>
|
||||
// elements and the crossfade timing; the renderer only maps them onto
|
||||
// two planes in front of the static plate. _venueCrowdRev bumps on any
|
||||
// element (re)assignment so update() knows to rebind textures.
|
||||
const _venueCrowdVideos = [null, null];
|
||||
let _venueCrowdMix = 0;
|
||||
let _venueCrowdRev = 0;
|
||||
|
||||
function _bgVenueMoodCoeffs(state) {
|
||||
const s = String(state || 'idle').toLowerCase();
|
||||
@@ -2909,6 +2916,20 @@
|
||||
window.h3dVenueSceneSetMood = (state) => {
|
||||
_venueMoodState = String(state || 'idle').toLowerCase();
|
||||
};
|
||||
// Crowd video layers (career mode) — see venue-crowd.js. Layer 0/1 are
|
||||
// two coplanar backdrop planes; mix selects between them (0 → layer 0,
|
||||
// 1 → layer 1) so the caller can crossfade loop videos.
|
||||
window.h3dVenueBackdropSetVideo = (layer, videoEl) => {
|
||||
const i = layer ? 1 : 0;
|
||||
const el = videoEl || null;
|
||||
if (_venueCrowdVideos[i] === el) return;
|
||||
_venueCrowdVideos[i] = el;
|
||||
_venueCrowdRev++;
|
||||
};
|
||||
window.h3dVenueBackdropSetMix = (mix) => {
|
||||
const v = Number(mix);
|
||||
_venueCrowdMix = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
|
||||
};
|
||||
window.h3dVenueSceneSetInstrumentPov = (input) => {
|
||||
const next = _venueResolvePovFromInput(input);
|
||||
if (_venueInstrumentPov === next) return;
|
||||
@@ -3371,6 +3392,40 @@
|
||||
() => _venueMarkFailed('failed to load small-club bg plate'),
|
||||
);
|
||||
|
||||
// Crowd video planes (career mode): two crossfading layers
|
||||
// just in front of the static plate (which stays mounted as
|
||||
// the no-pack / load-failure fallback). Textures bind lazily
|
||||
// in update() when venue-crowd.js assigns video elements.
|
||||
state.crowd = { layers: [], rev: -1 };
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const geo = new T.PlaneGeometry(1, 1);
|
||||
const mat = new T.MeshBasicMaterial({
|
||||
color: 0xffffff, transparent: true, opacity: 0,
|
||||
depthWrite: false, fog: false,
|
||||
});
|
||||
const mesh = new T.Mesh(geo, mat);
|
||||
mesh.visible = false;
|
||||
// Layer 1 sits nearest so three.js's back-to-front
|
||||
// transparent sort draws it after layer 0.
|
||||
const layer = {
|
||||
mesh, geo, mat, tex: null, videoEl: null,
|
||||
cam: settings.cam,
|
||||
distance: BG_BACKDROP_DISTANCE * (i === 0 ? 1.04 : 1.03),
|
||||
lastAspect: 0, lastVisibleHeight: 0,
|
||||
};
|
||||
layer.applyCoverCrop = function () {
|
||||
if (!layer.videoEl || !layer.tex) return;
|
||||
_bgCoverCrop(
|
||||
layer.tex,
|
||||
layer.videoEl.videoWidth || 0,
|
||||
layer.videoEl.videoHeight || 0,
|
||||
layer.cam.aspect,
|
||||
);
|
||||
};
|
||||
scene.add(mesh);
|
||||
state.crowd.layers.push(layer);
|
||||
}
|
||||
|
||||
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
|
||||
const hazeMat = new T.MeshBasicMaterial({
|
||||
color: 0x101820, transparent: true, opacity: coeffs.haze,
|
||||
@@ -3402,6 +3457,64 @@
|
||||
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
|
||||
* (coeffs.haze / VENUE_HAZE_STEADY);
|
||||
}
|
||||
if (s.crowd) {
|
||||
// Rebind VideoTextures when venue-crowd.js (re)assigns
|
||||
// elements. VideoTexture samples the element every frame,
|
||||
// so a src change on the same element needs no rebind.
|
||||
if (s.crowd.rev !== _venueCrowdRev) {
|
||||
s.crowd.rev = _venueCrowdRev;
|
||||
s.crowd.layers.forEach((layer, i) => {
|
||||
const el = _venueCrowdVideos[i];
|
||||
if (layer.videoEl === el) return;
|
||||
if (layer.tex) { layer.mat.map = null; layer.tex.dispose(); layer.tex = null; }
|
||||
layer.videoEl = el;
|
||||
layer.lastAspect = 0; // force refit + recrop
|
||||
if (el) {
|
||||
const tex = new T.VideoTexture(el);
|
||||
tex.colorSpace = T.SRGBColorSpace;
|
||||
tex.wrapS = T.ClampToEdgeWrapping;
|
||||
tex.wrapT = T.ClampToEdgeWrapping;
|
||||
tex.minFilter = T.LinearFilter;
|
||||
tex.magFilter = T.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
layer.tex = tex;
|
||||
layer.mat.map = tex;
|
||||
}
|
||||
layer.mat.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
const warm = coeffs.warmth;
|
||||
s.crowd.layers.forEach((layer, i) => {
|
||||
const el = layer.videoEl;
|
||||
// videoWidth === 0 until metadata lands — showing the
|
||||
// plane before that paints a black flash over the plate.
|
||||
const ready = !!el && el.videoWidth > 0;
|
||||
// venue-crowd.js swaps src on the same element (loop ↔
|
||||
// stinger); a new intrinsic size needs a fresh
|
||||
// cover-crop, which _bgFitBackdropPlane only reapplies
|
||||
// on camera aspect changes.
|
||||
if (ready && (layer.lastVidW !== el.videoWidth ||
|
||||
layer.lastVidH !== el.videoHeight)) {
|
||||
layer.lastVidW = el.videoWidth;
|
||||
layer.lastVidH = el.videoHeight;
|
||||
layer.applyCoverCrop();
|
||||
}
|
||||
// Layer 0 (rear) stays fully opaque whenever any of the
|
||||
// fade involves it: two half-transparent layers would
|
||||
// let the static plate behind bleed through (~25% at
|
||||
// mid-fade). The crossfade is therefore layer 1 (front)
|
||||
// fading over an opaque layer 0 — in both directions.
|
||||
const opacity = i === 0
|
||||
? (_venueCrowdMix < 0.999 ? 1 : 0)
|
||||
: _venueCrowdMix;
|
||||
layer.mat.opacity = opacity;
|
||||
layer.mesh.visible = ready && opacity > 0.01;
|
||||
if (layer.mesh.visible) {
|
||||
layer.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
|
||||
_bgFitBackdropPlane(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
teardown(s) {
|
||||
if (!s) return;
|
||||
@@ -3416,6 +3529,19 @@
|
||||
p.mat.dispose?.();
|
||||
}
|
||||
}
|
||||
// Crowd planes: this style owns the VideoTextures; the
|
||||
// <video> elements belong to venue-crowd.js and survive.
|
||||
if (s.crowd) {
|
||||
for (const layer of s.crowd.layers) {
|
||||
layer.mesh?.parent?.remove(layer.mesh);
|
||||
layer.geo?.dispose?.();
|
||||
if (layer.mat) {
|
||||
layer.mat.map = null;
|
||||
layer.mat.dispose?.();
|
||||
}
|
||||
layer.tex?.dispose?.();
|
||||
}
|
||||
}
|
||||
// Dispose the cached plate textures too — the module-level cache
|
||||
// otherwise keeps every loaded POV plate GPU-resident for the
|
||||
// page lifetime (steady VRAM growth across POV/arrangement swaps).
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+147
-1645
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// The library's edit-song modal: open, validate, save, delete.
|
||||
//
|
||||
// Interface width ZERO — nothing in app.js calls into this cluster; app.js only needs the four
|
||||
// names on the window contract so the markup's onclick= handlers resolve. That is what makes it
|
||||
// the cleanest slice left, and it only became clean because the LIBRARY came out first (#896):
|
||||
// every dependency this modal has is now a module.
|
||||
//
|
||||
// It reads six bindings out of ./library.js (loadLibrary, loadFavorites, loadTreeView,
|
||||
// _removeLibCardsForFilename, libView, _lastLibSelected) and never writes one — checked, which
|
||||
// matters: an imported binding is READ-ONLY, so a single write would have forced a setter or a
|
||||
// container. Every use is a read, so plain imports suffice.
|
||||
//
|
||||
// Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back.
|
||||
import { _confirmDialog, _escAttr, _trapFocusInModal } from './dom.js';
|
||||
import { L } from './library-state.js';
|
||||
import {
|
||||
_lastLibSelected, _removeLibCardsForFilename, libView, loadFavorites, loadLibrary, loadTreeView,
|
||||
} from './library.js';
|
||||
|
||||
// ── Edit metadata modal ─────────────────────────────────────────────────
|
||||
export function openEditModal(songData, openerEl) {
|
||||
const artUrl = `/api/song/${encodeURIComponent(songData.f)}/art?t=${Date.now()}`;
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'edit-modal';
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
// role=dialog: assistive tech announces it as a modal; also lets
|
||||
// the global keyboard listener's `_isInsideInteractiveControl`
|
||||
// bail when typing inside the modal so Library shortcuts don't
|
||||
// hijack keys from the edit form.
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', 'Edit song metadata');
|
||||
// Record the element that triggered the modal so Esc / Cancel can
|
||||
// return focus to the exact entry the user was on, even if
|
||||
// _lastLibSelected changes before the modal closes.
|
||||
// Prefer the explicitly-passed openerEl (from the edit-btn click
|
||||
// handler, which has the exact [data-play] parent) over
|
||||
// _lastLibSelected, which may not have been updated when the
|
||||
// click's stopPropagation() prevented the card-click handler.
|
||||
const _emActive = document.querySelector('.screen.active');
|
||||
const _emLast = (_lastLibSelected && document.body.contains(_lastLibSelected)
|
||||
&& _emActive && _emActive.contains(_lastLibSelected)) ? _lastLibSelected : null;
|
||||
modal._opener = (openerEl && document.body.contains(openerEl)) ? openerEl : _emLast;
|
||||
modal.innerHTML = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-md mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Edit Song</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="relative group cursor-pointer" id="edit-art-wrapper">
|
||||
<img src="${artUrl}" alt="" class="w-20 h-20 rounded-lg object-cover bg-dark-600" id="edit-art-preview">
|
||||
<div class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
|
||||
<span class="text-white text-xs">Change</span>
|
||||
</div>
|
||||
<input type="file" accept="image/*" id="edit-art-file" class="hidden" onchange="previewEditArt(this)">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 flex-1">Click image to change album art</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Title</label>
|
||||
<input type="text" id="edit-title" value="${_escAttr(songData.t)}"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Artist</label>
|
||||
<input type="text" id="edit-artist" value="${_escAttr(songData.a)}"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Album</label>
|
||||
<input type="text" id="edit-album" value="${_escAttr(songData.al)}"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Year</label>
|
||||
<input type="text" inputmode="numeric" id="edit-year" value="${_escAttr(songData.y)}" placeholder="e.g. 2024"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button data-edit-save
|
||||
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
<button data-edit-close
|
||||
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-800">
|
||||
<button data-delete-filename="${_escAttr(songData.f)}"
|
||||
class="w-full px-4 py-2 bg-red-900/30 hover:bg-red-900/60 border border-red-900/50 hover:border-red-700 rounded-xl text-sm text-red-300 hover:text-red-100 transition">Remove from library</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Move focus into the dialog's first text input so background
|
||||
// shortcuts (and arrow nav) can't fire on the underlying library
|
||||
// entry while the edit form is open. Title is the natural primary
|
||||
// field — most edits are correcting spelling there. Caret-end
|
||||
// selection so the user can keep typing rather than overtype the
|
||||
// current value.
|
||||
const titleInput = document.getElementById('edit-title');
|
||||
if (titleInput) {
|
||||
titleInput.focus({ preventScroll: true });
|
||||
try {
|
||||
const len = titleInput.value.length;
|
||||
titleInput.setSelectionRange(len, len);
|
||||
} catch { /* some browsers reject selection on certain input types */ }
|
||||
}
|
||||
|
||||
// Trap Tab / Shift+Tab inside the modal so focus can't escape to
|
||||
// the library content underneath while the edit form is open.
|
||||
_trapFocusInModal(modal);
|
||||
|
||||
// Click on art triggers file input
|
||||
document.getElementById('edit-art-wrapper').addEventListener('click', () => {
|
||||
document.getElementById('edit-art-file').click();
|
||||
});
|
||||
|
||||
// Save — wired in JS (not an inline onclick) so the filename never has to
|
||||
// survive embedding in a single-quoted attribute string. encodeURIComponent
|
||||
// does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break
|
||||
// the inline `saveEditModal('…')` handler and silently fail the save. The
|
||||
// raw filename lives in the closure; encode it here for saveEditModal.
|
||||
const saveBtn = modal.querySelector('[data-edit-save]');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f)));
|
||||
}
|
||||
|
||||
const deleteBtn = modal.querySelector('[data-delete-filename]');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
deleteSongFromModal(deleteBtn.dataset.deleteFilename);
|
||||
});
|
||||
}
|
||||
|
||||
// Close on backdrop click or Cancel button; restore focus to opener.
|
||||
// Backdrop dismissal requires the gesture's mousedown to have STARTED on
|
||||
// the backdrop — not just the click/mouseup to land there. Otherwise a
|
||||
// click-drag that begins inside a field (e.g. selecting text) and is
|
||||
// released past the modal edge resolves its `click` target to the backdrop
|
||||
// and silently discards the edit. Cancel / ✕ (data-edit-close) always close.
|
||||
let _downOnBackdrop = false;
|
||||
modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); });
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return;
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕
|
||||
// control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH
|
||||
// the click target to be the backdrop element itself AND the gesture to have
|
||||
// started there (downOnBackdrop) — so a click-drag begun inside a field and
|
||||
// released on the backdrop does not discard the form. Pure + top-level so it's
|
||||
// unit-testable in isolation.
|
||||
export function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
|
||||
if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
|
||||
return clickTarget === modalEl && downOnBackdrop === true;
|
||||
}
|
||||
|
||||
export async function saveEditModal(encodedFilename) {
|
||||
const filename = decodeURIComponent(encodedFilename);
|
||||
|
||||
// Save metadata
|
||||
await fetch(`/api/song/${encodeURIComponent(filename)}/meta`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: document.getElementById('edit-title').value.trim(),
|
||||
artist: document.getElementById('edit-artist').value.trim(),
|
||||
album: document.getElementById('edit-album').value.trim(),
|
||||
// Year is normalised server-side (non-numeric/empty → ""), so a
|
||||
// blank or cleared field round-trips safely.
|
||||
year: document.getElementById('edit-year').value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Upload art if changed
|
||||
const fileInput = document.getElementById('edit-art-file');
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
await fetch(`/api/song/${encodeURIComponent(filename)}/art/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: e.target.result }),
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(fileInput.files[0]);
|
||||
}
|
||||
|
||||
const modal = document.getElementById('edit-modal');
|
||||
const opener = modal ? modal._opener : null;
|
||||
if (modal) modal.remove();
|
||||
// Restore focus to the entry the modal was opened from so subsequent
|
||||
// keyboard navigation resumes correctly (same as Esc / Cancel paths).
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
// Refresh current view
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
if (activeScreen?.id === 'favorites') loadFavorites();
|
||||
else loadLibrary();
|
||||
}
|
||||
|
||||
export async function deleteSongFromModal(filename) {
|
||||
const title = (document.getElementById('edit-title')?.value || filename).trim();
|
||||
const ok = await _confirmDialog({
|
||||
title: 'Remove from library?',
|
||||
body: `<p class="text-sm text-gray-300">Remove <span class="font-semibold text-white">${_escAttr(title)}</span> from your library?</p>
|
||||
<p class="text-xs text-red-400/90 mt-2">This permanently deletes the file from disk. This cannot be undone.</p>`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`/api/song/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
alert(`Delete failed: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { msg = (await resp.json()).error || msg; } catch (_) {}
|
||||
alert(`Delete failed: ${msg}`);
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('edit-modal');
|
||||
if (modal) modal.remove();
|
||||
L.treeStats = null;
|
||||
L.favTreeStats = null;
|
||||
L.tuningNames = null;
|
||||
|
||||
// Remove the deleted song's card from any currently-rendered grid/tree
|
||||
// so the user sees it disappear without waiting for a refetch. A full
|
||||
// loadLibrary() here would re-call loadGridPage(currentPage), which
|
||||
// uses 'append' mode when currentPage > 0 and re-appends the same
|
||||
// (now-shortened) page on top of what's already rendered — leaving
|
||||
// the deleted card visible. Direct DOM removal also preserves scroll
|
||||
// position, which a refetch from page 0 would lose.
|
||||
_removeLibCardsForFilename(filename);
|
||||
|
||||
// Tree views group by artist with song counts; a single card removal
|
||||
// leaves stale counts, so refresh the tree for whichever screen we're
|
||||
// looking at (each tree-view renderer replaces innerHTML cleanly).
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
if (activeScreen?.id === 'favorites') {
|
||||
// loadFavorites() routes to either loadFavGridPage (always
|
||||
// 'replace') or loadFavTreeView — both safe for a single delete.
|
||||
loadFavorites();
|
||||
} else if (libView === 'tree') {
|
||||
loadTreeView();
|
||||
}
|
||||
// Main library grid view: DOM removal above is sufficient.
|
||||
}
|
||||
@@ -155,7 +155,7 @@ function _hwcSlotKeysForChart(sc, isBass) {
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
}
|
||||
|
||||
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
|
||||
// Current arrangement shape (string count + bass-vs-guitar) from the 2D window.highway.
|
||||
function _hwcChartShape() {
|
||||
let sc = 6, arr = '';
|
||||
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// highway.js's immutable constants: geometry, colour tables, timing budgets, and the
|
||||
// load-adaptive render-scale thresholds.
|
||||
//
|
||||
// WHY THESE — AND ONLY THESE — MAY LIVE AT MODULE SCOPE
|
||||
//
|
||||
// createHighway() is a FACTORY, not a singleton. The constitution publishes
|
||||
// window.createHighway precisely so a plugin can build a SECOND highway for its own panel,
|
||||
// and highway.js says so at the top of the closure:
|
||||
//
|
||||
// // R3c: per-instance mutable state in one object, so extracted renderer/ws
|
||||
// // modules can close over it as a factory arg without cross-panel sharing.
|
||||
//
|
||||
// So MUTABLE state (hwState) must never become a module-level singleton — two highways would
|
||||
// silently share it. That is the opposite of the app.js carve, where a single state container
|
||||
// was right because there is exactly one app.
|
||||
//
|
||||
// These 29 are pure literals: frozen numbers, strings and colour tables, never reassigned and
|
||||
// never mutated. Sharing them across instances is not just safe, it is what you want — one
|
||||
// copy of the shimmer LUT bounds and the string palettes rather than one per panel.
|
||||
//
|
||||
// Anything with a runtime dependency (document, window, performance, localStorage) stays in
|
||||
// the factory. Checked: none of these has one.
|
||||
|
||||
// Cap the interpolation so a stalled main thread (long task, GC,
|
||||
// dropped tick) can't make getTime drift far past reality. Also the
|
||||
// threshold for "audio looks paused" — if setTime hasn't advanced t
|
||||
// in this long, treat as paused.
|
||||
export const _CHART_MAX_INTERP_MS = 100;
|
||||
|
||||
// Throttled DOM visibility sampling. Reading canvas.offsetParent
|
||||
// every rAF frame forces a style/layout recalc — profiled at ~0.5 s
|
||||
// main-thread self-time over a 63 s session. The displayed state
|
||||
// changes rarely (navigate / splitscreen panel toggle), so the DOM
|
||||
// is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached
|
||||
// value serves the frames in between (worst-case transition latency
|
||||
// ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal).
|
||||
// Set _domVisSampledFrame to NaN to force a fresh sample on the next
|
||||
// check (done on init, canvas replace, resize, and override-clear so
|
||||
// deliberate transitions don't wait out the throttle window).
|
||||
// NOTE those manual resets are LATENCY optimizations, not correctness
|
||||
// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES
|
||||
// frames regardless, so a visibility-affecting path that forgets to
|
||||
// reset self-heals within ~10 frames — stale visibility can never be
|
||||
// served indefinitely.
|
||||
export const _DOM_VIS_CHECK_FRAMES = 10;
|
||||
|
||||
// Paused-render throttle (feedBack#654). The rAF loop runs
|
||||
// unconditionally and only gates on visibility + ready, never on
|
||||
// playback — so an expensive renderer (3D Highway's Three.js WebGL
|
||||
// scene) does a full render every frame even while paused. That is
|
||||
// pure waste, and the dominant cost on high-refresh / ANGLE setups
|
||||
// (Chromium on Windows paces rAF to the fastest attached monitor,
|
||||
// so the loop can run at 144 Hz even on a 60 Hz panel). While the
|
||||
// audio clock is stalled, cap draws to one per
|
||||
// _PAUSED_FRAME_INTERVAL_MS. Note position is clock-derived
|
||||
// (n.t - currentTime), so this changes smoothness only — never
|
||||
// audio/visual sync. A low non-zero rate (not a hard skip) keeps
|
||||
// resize / seek-scrub / renderer-swap repaints correct without
|
||||
// having to hook each of those paths.
|
||||
export const _PAUSED_FRAME_INTERVAL_MS = 100;
|
||||
|
||||
export const _DRAW_BUDGET_HI_MS = 12;
|
||||
|
||||
export const _DRAW_BUDGET_LO_MS = 7;
|
||||
|
||||
export const _AUTO_SCALE_MIN = 0.25;
|
||||
|
||||
export const _AUTO_ADJUST_COOLDOWN_MS = 600;
|
||||
|
||||
// Upscaling is deliberately LAZY (longer cooldown than the downscale path) so
|
||||
// the resolution doesn't visibly hunt up/down on passages that hover near the
|
||||
// budget — testers saw "quality going up and down" as parts got busier (#618
|
||||
// charrette). Downscale stays prompt to protect the frame rate.
|
||||
export const _AUTO_UPSCALE_COOLDOWN_MS = 2500;
|
||||
|
||||
// 64-entry precomputed jitter LUT replacing Math.random() in the
|
||||
// lit-sustain shimmer hot path (drawSustains). Visually
|
||||
// indistinguishable from per-frame Math.random at rAF cadence,
|
||||
// allocation-free, and removes 4 RNG calls per visible lit sustain
|
||||
// per frame on dense charts. Seeded deterministically (xorshift32)
|
||||
// so the LUT itself is identical across `createHighway()` instances
|
||||
// — shimmer is therefore reload-stable and test-reproducible PER
|
||||
// instance for a given (frameIdx, n.s, n.t) seed. The seed includes
|
||||
// closure-scope `_frameIdx` which is per-instance, so two
|
||||
// splitscreen highways with different rAF cadence will shimmer
|
||||
// differently at any given wall-clock moment; what's stable is the
|
||||
// LUT contents.
|
||||
//
|
||||
// _SHIMMER_LUT_SIZE MUST stay a power of two — `_shimmerNoise`
|
||||
// indexes with `& (_SHIMMER_LUT_SIZE - 1)` for the cheap modulo.
|
||||
export const _SHIMMER_LUT_SIZE = 64;
|
||||
|
||||
// Memoize ctx.measureText() for the lyric overlay. Per-syllable
|
||||
// measurement was the dominant cost in dense karaoke charts; text
|
||||
// and fontSize are the only inputs (font face string is constant
|
||||
// `bold ${fontSize}px sans-serif`). Two-level Map (outer: fontSize,
|
||||
// inner: text) so a cache hit avoids the `fontSize + '|' + text`
|
||||
// concat that previously allocated on every lookup.
|
||||
//
|
||||
// Bounded on BOTH levels: window resizes change `fontSize`, so each
|
||||
// resize creates a fresh inner Map; without an outer cap, the cache
|
||||
// would retain every fontSize ever rendered for the page lifetime.
|
||||
// Cap outer at 16 distinct fontSize buckets (more than enough — a
|
||||
// session typically sees one or two), inner at 4096 entries per
|
||||
// bucket. Clear-on-overflow on both — a karaoke cold start re-warms
|
||||
// in one frame.
|
||||
export const _LYRIC_MEASURE_OUTER_MAX = 16;
|
||||
|
||||
export const _LYRIC_MEASURE_INNER_MAX = 4096;
|
||||
|
||||
// Rendering config
|
||||
export const VISIBLE_SECONDS = 3.0;
|
||||
|
||||
export const Z_CAM = 2.2;
|
||||
|
||||
export const Z_MAX = 10.0;
|
||||
|
||||
export const BG = '#080810';
|
||||
|
||||
// String color palettes. Indices 0–5 cover guitar / bass; 6–7
|
||||
// are added for extended-range GP imports (7-string, 8-string).
|
||||
// Lookups still use `|| '#888'` as a safety fallback for any
|
||||
// out-of-range index.
|
||||
//
|
||||
// These are `let`, not `const`: setStringColors() (used by the core
|
||||
// "Highway String Colors" theming UI) overrides per-index entries at
|
||||
// runtime, deriving the dim/bright variants from the chosen base color.
|
||||
// DEFAULT_* keep the originals so a reset restores them byte-for-byte.
|
||||
export const DEFAULT_STRING_COLORS = [
|
||||
'#cc0000', '#cca800', '#0066cc',
|
||||
'#cc6600', '#00cc66', '#9900cc',
|
||||
'#cc00aa', '#00cccc', // 7th = magenta, 8th = teal
|
||||
];
|
||||
|
||||
export const DEFAULT_STRING_DIM = [
|
||||
'#520000', '#524200', '#002952',
|
||||
'#522900', '#005229', '#3d0052',
|
||||
'#520042', '#005252',
|
||||
];
|
||||
|
||||
export const DEFAULT_STRING_BRIGHT = [
|
||||
'#ff3c3c', '#ffe040', '#3c9cff',
|
||||
'#ff9c3c', '#3cff9c', '#cc3cff',
|
||||
'#ff3ce0', '#3ce0e0',
|
||||
];
|
||||
|
||||
export const MAX_RENDERER_DRAW_FAILURES = 3;
|
||||
|
||||
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
|
||||
//
|
||||
// Charts often repeat the same chord shape several times in a
|
||||
// row (e.g. a G strummed 4 times). We call a contiguous run of same-id
|
||||
// chords with gaps < CHAIN_GAP_THRESHOLD a "chain". Chains drive two
|
||||
// visual choices:
|
||||
// • The first chord in a chain renders in full; subsequent chords in
|
||||
// a chain of CHAIN_RENDER_FULL_MAX or longer render as a "repeat
|
||||
// box" — a translucent boxed frame so the eye can see the rhythm
|
||||
// pattern without re-scanning identical fret numbers.
|
||||
// • Each chord anchors a CHORD_FRAME_FRETS-wide frame; muted and
|
||||
// open-only chords inherit the frame from their predecessor so
|
||||
// they don't snap to fret 0.
|
||||
//
|
||||
// We compute chain stats and frame anchors once per `src` array via
|
||||
// _ensureChordRenderCache (lazy, invalidates when the array reference
|
||||
// changes — which happens on chord ingest, mastery rebuild, or song
|
||||
// reset). The render path is then pure read.
|
||||
export const CHAIN_GAP_THRESHOLD = 0.5;
|
||||
|
||||
export const CHAIN_RENDER_FULL_MAX = 4;
|
||||
|
||||
export const CHORD_FRAME_FRETS = 4;
|
||||
|
||||
// Fretline preview: the static fret line at the bottom shows the chord
|
||||
// closest to the strum line (currentTime + FRETLINE_TARGET_OFFSET) within
|
||||
// the [target - FRETLINE_WINDOW_BEFORE, target + FRETLINE_WINDOW_AFTER]
|
||||
// window, as a teaching aid.
|
||||
export const FRETLINE_TARGET_OFFSET = -0.25;
|
||||
|
||||
export const FRETLINE_WINDOW_BEFORE = 0.1;
|
||||
|
||||
export const FRETLINE_WINDOW_AFTER = 0.3;
|
||||
|
||||
// Repeat / mute box colors.
|
||||
export const REPEAT_BOX_FILL = 'rgba(48, 80, 128, 0.06)';
|
||||
|
||||
export const REPEAT_BOX_BAR = '#50a0dc';
|
||||
|
||||
export const MUTE_BOX_STROKE = '#6060809b';
|
||||
|
||||
export const MUTE_BOX_BAR = '#606080d1';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
// highway.js's PURE geometry + label primitives.
|
||||
//
|
||||
// Every function here is a pure function of its arguments. None of them touches hwState, and
|
||||
// none closes over the canvas context — roundRect() already took `ctx` explicitly, and the
|
||||
// rest need nothing but numbers. project() reads only the module-level constants from
|
||||
// ./highway-constants.js.
|
||||
//
|
||||
// THAT PURITY IS WHY THIS SLICE IS SAFE, and why it is the one to do first. createHighway() is
|
||||
// a FACTORY — a plugin can build a second highway for its own panel — so anything holding
|
||||
// per-instance state (hwState) must be passed it as an argument rather than importing it, or
|
||||
// two panels silently share one clock and palette. These six hold no state at all, so they
|
||||
// move VERBATIM: not one call site changes.
|
||||
//
|
||||
// The primitives that DO need hwState (fretX, fillTextReadable, _noteState, _paintGemGlow)
|
||||
// are deliberately left behind. They need an explicit hwState parameter threaded through 53
|
||||
// call sites, which is a real change and belongs in its own commit, not smuggled in beside a
|
||||
// provably-identical move.
|
||||
import { VISIBLE_SECONDS, Z_CAM, Z_MAX, _SHIMMER_LUT_SIZE } from './highway-constants.js';
|
||||
|
||||
// ── Projection ───────────────────────────────────────────────────────
|
||||
export function project(tOffset) {
|
||||
if (tOffset > VISIBLE_SECONDS || tOffset < -0.05) return null;
|
||||
if (tOffset < 0) return { y: 0.82 + Math.abs(tOffset) * 0.3, scale: 1.0 };
|
||||
|
||||
const z = tOffset * (Z_MAX / VISIBLE_SECONDS);
|
||||
const denom = z + Z_CAM;
|
||||
if (denom < 0.01) return null;
|
||||
const scale = Z_CAM / denom;
|
||||
const y = 0.82 + (0.08 - 0.82) * (1.0 - scale);
|
||||
return { y, scale };
|
||||
}
|
||||
|
||||
export function bnvNormalizedPoints(bnv, sus) {
|
||||
if (!Array.isArray(bnv) || bnv.length === 0) return [];
|
||||
// Map each point's time over the NOTE's span [0, sus] so it sits at its
|
||||
// real fraction of the note (a bend that completes before the note ends
|
||||
// draws short of the glyph's right edge). Fall back to the curve's own
|
||||
// t-range only when the note has no usable sustain.
|
||||
if (Number.isFinite(sus) && sus > 0) {
|
||||
return bnv.map(p => ({ x: Math.min(Math.max(p.t / sus, 0), 1), v: p.v }));
|
||||
}
|
||||
const t0 = bnv[0].t;
|
||||
const span = bnv[bnv.length - 1].t - t0;
|
||||
return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v }));
|
||||
}
|
||||
|
||||
export function teachingFingerLabel(fg) {
|
||||
if (!Number.isInteger(fg) || fg < 0 || fg > 4) return '';
|
||||
return fg === 0 ? 'T' : String(fg);
|
||||
}
|
||||
|
||||
export function teachingDegreeLabel(sd) {
|
||||
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
|
||||
return String(sd);
|
||||
}
|
||||
|
||||
export function chordHarmonyLabels(fn, voicing, caged, guideTones) {
|
||||
const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : '';
|
||||
const vc = (typeof voicing === 'string') ? voicing.trim() : '';
|
||||
const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim()))
|
||||
? 'CAGED: ' + caged.trim() : '';
|
||||
const gt = Array.isArray(guideTones)
|
||||
? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : [];
|
||||
return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' };
|
||||
}
|
||||
|
||||
export function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
|
||||
// ── The shimmer noise LUT ───────────────────────────────────────────────────────
|
||||
//
|
||||
// A DETERMINISTIC xorshift table: no randomness, no state, byte-for-byte identical for every
|
||||
// highway instance. Unlike the three per-instance caches that came out of the drawing layer (a
|
||||
// warn-once Set, a chord WeakMap, a lyric-width Map — all MUTATED, all lifted onto hwState so
|
||||
// two panels cannot stomp each other), this one is not merely SAFE to share but BETTER shared:
|
||||
// built once for the page instead of once per panel.
|
||||
//
|
||||
// MUTABILITY, NOT LOCATION, IS WHAT DECIDES WHERE A THING BELONGS.
|
||||
const _shimmerLut = new Float32Array(_SHIMMER_LUT_SIZE);
|
||||
for (let i = 0; i < _SHIMMER_LUT_SIZE; i++) {
|
||||
let x = (i + 1) | 0; // +1 dodges the all-zero xorshift trap
|
||||
x ^= x << 13;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5;
|
||||
_shimmerLut[i] = (x >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
export function _shimmerNoise(seed) {
|
||||
// Mask works only because _SHIMMER_LUT_SIZE is a power of two.
|
||||
return _shimmerLut[(seed >>> 0) & (_SHIMMER_LUT_SIZE - 1)];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// highway.js's STATEFUL primitives: the four shared helpers that need per-instance state.
|
||||
//
|
||||
// ━━━ hwState IS A PARAMETER, NOT AN IMPORT. THIS IS THE WHOLE DESIGN. ━━━
|
||||
//
|
||||
// createHighway() is a FACTORY. The constitution publishes window.createHighway so a plugin can
|
||||
// build a SECOND highway for its own panel, and highway.js says so itself:
|
||||
//
|
||||
// // R3c: per-instance mutable state in one object, so extracted renderer/ws
|
||||
// // modules can close over it as a factory arg without cross-panel sharing.
|
||||
//
|
||||
// Import hwState as a module singleton and the two panels silently share one clock, one render
|
||||
// scale, one string palette — each driving the other. Nothing would throw. The picture would
|
||||
// just be wrong, in a way no test would catch.
|
||||
//
|
||||
// So every function here takes hwState as its FIRST ARGUMENT. It reads a little worse at the
|
||||
// call site and it is the only correct shape.
|
||||
//
|
||||
// (This is the exact opposite of the app.js carve, where player-state.js and library-state.js
|
||||
// ARE module singletons — correctly, because there is exactly one app. Same epic, same
|
||||
// language, opposite answer, decided entirely by whether the thing is a factory.)
|
||||
//
|
||||
// The PURE primitives — project, roundRect, and the label helpers — need none of this and live
|
||||
// in ./highway-geometry.js.
|
||||
// No imports. These four need nothing but the hwState they are handed and their arguments.
|
||||
|
||||
export function fretX(hwState, fret, scale, w) {
|
||||
const hw = w * 0.52 * scale;
|
||||
const margin = hw * 0.06;
|
||||
const usable = hw * 2 - 2 * margin;
|
||||
const t = fret / Math.max(1, hwState.displayMaxFret);
|
||||
return w / 2 - hw + margin + t * usable;
|
||||
}
|
||||
|
||||
export function fillTextReadable(hwState, text, x, y) {
|
||||
// ctx may be null when the 2D context was never acquired
|
||||
// (canvas already locked to WebGL). No-op in that case —
|
||||
// alternatives would be throwing, which breaks plugin hooks
|
||||
// that call this after a context-type mismatch.
|
||||
if (!hwState.canvas || !hwState.ctx) return;
|
||||
const W = hwState.canvas.width;
|
||||
if (!hwState._lefty) {
|
||||
hwState.ctx.fillText(text, x, y);
|
||||
return;
|
||||
}
|
||||
hwState.ctx.save();
|
||||
hwState.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
hwState.ctx.fillText(text, W - x, y);
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
|
||||
// ── Per-note judgment state (feedBack#254) ──────────────────────────
|
||||
// Resolves the registered provider for one chart note. Returns null
|
||||
// when no provider is set, the provider throws, it reports nothing,
|
||||
// or the reported alpha is non-positive. Otherwise a normalized
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
// 'hit' and 'active' are both "lit" — renderers may treat them the
|
||||
// same; the distinction (struck note vs currently-held sustain) is
|
||||
// there for renderers that want it. The provider owns all timing /
|
||||
// fade — `alpha` is whatever intensity it wants right now.
|
||||
export function _noteState(hwState, note, chartTime) {
|
||||
if (!hwState._noteStateProvider) return null;
|
||||
let raw;
|
||||
try { raw = hwState._noteStateProvider(note, chartTime); } catch (e) { return null; }
|
||||
if (!raw) return null;
|
||||
const state = typeof raw === 'string' ? raw : raw.state;
|
||||
if (state !== 'hit' && state !== 'active' && state !== 'miss') return null;
|
||||
const alpha = (raw && typeof raw === 'object' && Number.isFinite(raw.alpha))
|
||||
? Math.max(0, Math.min(1, raw.alpha))
|
||||
: 1;
|
||||
if (alpha <= 0) return null;
|
||||
const color = (raw && typeof raw === 'object' && typeof raw.color === 'string') ? raw.color : null;
|
||||
// Pass through the provider's `live` flag: note_detect tags its
|
||||
// ring-tracking 'active' responses with live:true so a renderer can
|
||||
// treat them as authoritative (extinguish on mute, relight on
|
||||
// re-strike) instead of latching them for the whole chart sustain.
|
||||
// Renderers that don't care simply ignore it.
|
||||
const live = (raw && typeof raw === 'object' && raw.live === true);
|
||||
return { state, alpha, color, live };
|
||||
}
|
||||
|
||||
// Paints the judgment effect on top of an already-drawn gem at
|
||||
// (cx,cy) with half-extent `r`. `ns` is the normalized state from
|
||||
// _noteState (or null → no-op). A miss → faint red wash. A correct
|
||||
// hit / held sustain → a "sizzle": throbbing additive halo + a
|
||||
// flickering white-hot core + crackling spark lines re-randomised
|
||||
// each frame + (for a fresh struck note that's fading) an expanding
|
||||
// shockwave ring. Intensity scales with `ns.alpha`, so a struck
|
||||
// note flares and dies while a held sustain crackles continuously.
|
||||
// Caller draws the gem normally first, then calls this BEFORE any
|
||||
// glyph so a readable fret number can land on top.
|
||||
export function _paintGemGlow(hwState, cx, cy, r, stringIdx, ns) {
|
||||
if (!ns || !hwState.ctx) return;
|
||||
hwState.ctx.save();
|
||||
if (ns.state === 'miss') {
|
||||
hwState.ctx.globalAlpha = 0.4 * ns.alpha;
|
||||
hwState.ctx.fillStyle = '#ff2828';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 1.05, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
hwState.ctx.restore();
|
||||
return;
|
||||
}
|
||||
const col = ns.color || hwState.STRING_BRIGHT[stringIdx] || '#ffffff';
|
||||
const a = ns.alpha;
|
||||
const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
||||
hwState.ctx.lineCap = 'round';
|
||||
|
||||
// Expanding shockwave — only on a fresh struck-and-fading hit
|
||||
// (alpha decays 1→0). 'active' (held sustain, alpha pinned 1) skips it.
|
||||
if (ns.state === 'hit' && a < 1) {
|
||||
const prog = 1 - a; // 0 at strike → 1 at fade-out
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * 0.85;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(1.5, r * 0.26 * a);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (1.0 + prog * 2.7), 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Throbbing halo (≈9 Hz wobble).
|
||||
const pulse = 0.8 + 0.2 * Math.sin(nowMs / 18);
|
||||
const haloR = r * 2.0 * pulse;
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
const g = hwState.ctx.createRadialGradient(cx, cy, 0, cx, cy, haloR);
|
||||
g.addColorStop(0, '#ffffff');
|
||||
g.addColorStop(0.30, col);
|
||||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
hwState.ctx.fillStyle = g;
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crackle — short bright spark lines flicking out from the gem,
|
||||
// re-randomised every frame so it shimmers.
|
||||
const sparkCount = 6;
|
||||
for (let i = 0; i < sparkCount; i++) {
|
||||
if (Math.random() > 0.55 * a + 0.2) continue; // intermittent
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const inR = r * 0.45;
|
||||
const len = r * (0.7 + Math.random() * 1.6) * (0.5 + 0.5 * a);
|
||||
hwState.ctx.globalAlpha = a * (0.45 + Math.random() * 0.55);
|
||||
hwState.ctx.strokeStyle = Math.random() < 0.5 ? '#ffffff' : col;
|
||||
hwState.ctx.lineWidth = Math.max(1, r * (0.08 + Math.random() * 0.08));
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.moveTo(cx + Math.cos(ang) * inR, cy + Math.sin(ang) * inR);
|
||||
hwState.ctx.lineTo(cx + Math.cos(ang) * (inR + len), cy + Math.sin(ang) * (inR + len));
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Flickering white-hot core.
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * (0.55 + Math.random() * 0.45);
|
||||
hwState.ctx.fillStyle = '#ffffff';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (0.30 + Math.random() * 0.14), 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crisp bright rim.
|
||||
hwState.ctx.globalCompositeOperation = 'source-over';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(2, r * 0.2);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 0.95, 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
@@ -111,7 +111,7 @@ import { S } from './player-state.js';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// highway.js's initial song-load routing consults this for the same
|
||||
// window.highway.js's initial song-load routing consults this for the same
|
||||
// feedpak-under-exclusive decision the watcher makes below.
|
||||
window._juceOutputIsExclusive = _outputIsExclusive;
|
||||
// Returns true when window._currentSongAudio no longer references the exact
|
||||
@@ -383,7 +383,7 @@ import { S } from './player-state.js';
|
||||
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
|
||||
// are never routable (per-stem mix can't ride a single transport).
|
||||
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
|
||||
// Don't race highway.js's own initial song-load routing: it owns
|
||||
// Don't race window.highway.js's own initial song-load routing: it owns
|
||||
// _juceMode until _juceRoutingPromise settles. Re-running our switch
|
||||
// concurrently would double-call loadBackingTrack for the same URL.
|
||||
if (window._highwayJuceRoutingPending) return;
|
||||
|
||||
@@ -160,7 +160,7 @@ export function _resetPlaybackSpeedForNewSong() {
|
||||
//
|
||||
// Debounced trailing-edge (300ms) so dragging the slider — which fires
|
||||
// oninput per pixel — doesn't flood the server with concurrent writes
|
||||
// to config.json. highway.setMastery() still fires every oninput so
|
||||
// to config.json. window.highway.setMastery() still fires every oninput so
|
||||
// the chart re-filters in real time; only disk persistence waits.
|
||||
let _masteryPersistTimer = null;
|
||||
function _persistMastery(pct) {
|
||||
@@ -209,7 +209,7 @@ export function _applyMastery(v, opts = {}) {
|
||||
// unlike #mastery-label above, whose markup carries no trailing unit.
|
||||
const setLabel = document.getElementById('setting-highway-speed-val');
|
||||
if (setLabel) setLabel.textContent = pct;
|
||||
highway.setMastery(pct / 100);
|
||||
window.highway.setMastery(pct / 100);
|
||||
if (!opts.skipPersist) _persistMastery(pct);
|
||||
}
|
||||
// Reflect phrase-data availability on the slider after every `ready`.
|
||||
|
||||
@@ -31,12 +31,12 @@ const _RESUME_END_GUARD_S = 5; // ignore basically-finished
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
// window.highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
export function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!host.currentFilename()) return;
|
||||
const si = (window.highway && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const si = (window.highway && typeof window.highway.getSongInfo === 'function')
|
||||
? (window.highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
|
||||
@@ -38,7 +38,7 @@ export function _sectionPracticeBarContains(el) {
|
||||
}
|
||||
|
||||
// ── Section Practice Bar ────────────────────────────────────────────────
|
||||
// One-click looping over song section markers (highway.getSections —
|
||||
// One-click looping over song section markers (window.highway.getSections —
|
||||
// same array as 3D highway bundle.sections / "Now / Up Next").
|
||||
// Reuses setLoop() so manual A/B controls and saved loops stay canonical.
|
||||
let _sectionPracticeRanges = [];
|
||||
@@ -127,7 +127,7 @@ export function _resetSectionPracticeLog() {
|
||||
}
|
||||
|
||||
function _sectionPracticeHighway() {
|
||||
return window.highway || (typeof highway !== 'undefined' ? highway : null);
|
||||
return window.highway || null;
|
||||
}
|
||||
|
||||
function _sectionPracticeDuration() {
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
//
|
||||
// ━━━ THIS WAS THE UNCUTTABLE HEART, AND IT IS 359 LINES ━━━
|
||||
//
|
||||
// At the start of the app.js carve, seeding a dependency closure from count-in, from loops, from
|
||||
// section-practice or from the JUCE seek shim all returned the SAME 178-function, 3,360-line
|
||||
// set. playSong and showScreen called each other; everything called them; nothing could be cut
|
||||
// anywhere. The conclusion — correct at the time — was that no closure-based carve could touch
|
||||
// it at any seed, and the answer was a HOST SEAM.
|
||||
//
|
||||
// That was true THEN. It is not true now. Every slice taken out since (transport, loops,
|
||||
// count-in, section-practice, the library, the edit modal, settings) removed edges, and the
|
||||
// strongly-connected component DISSOLVED. This closure is 36 declarations with an interface
|
||||
// width of FOUR.
|
||||
//
|
||||
// The lesson is not that the seam was wrong. The seam is what MADE this possible: it let the
|
||||
// carves proceed against a cyclic core instead of stalling on it. The lesson is to RE-MEASURE.
|
||||
// An SCC is a fact about a graph at a moment, not a property of the code.
|
||||
//
|
||||
// ━━━ THE GATE STATEMENTS AT THE BOTTOM, AND WHY NO SCAN FOUND THEM ━━━
|
||||
//
|
||||
// window.feedBack.holdAutoplay / holdAutoExit and their two event handlers are TOP-LEVEL
|
||||
// STATEMENTS, not declarations. They WRITE this module's state (_autoplayHeld, _autoExitTimer,
|
||||
// …), and an imported binding is READ-ONLY — so left behind in app.js, every one of them threw
|
||||
// "Assignment to constant variable" the instant this module existed.
|
||||
//
|
||||
// A dependency scan that walks DECLARATIONS cannot see them. Only the browser A/B did. It is the
|
||||
// same blind spot that nearly shipped a dead library A-Z rail (#896): app.js keeps its public
|
||||
// API in top-level statements, and those are invisible to a call-graph.
|
||||
//
|
||||
// ━━━ ZERO OUTSIDE WRITES, BY MOVING THE BOUNDARY RATHER THAN BUILDING MACHINERY ━━━
|
||||
//
|
||||
// Autoplay scalars and the wake-lock state were written from outside — which would have forced a
|
||||
// setter or a container. But the writers (_releaseAutoplay, _acquireWakeLock) plainly belong
|
||||
// here. Pulling them in left ZERO outside writes, so every export is a plain import. Same move as
|
||||
// settings (#920): measure the writers before you reach for a container.
|
||||
|
||||
import {
|
||||
loadSettings,
|
||||
} from './settings.js';
|
||||
import {
|
||||
clearLoop,
|
||||
loadSavedLoops,
|
||||
} from './loops.js';
|
||||
import {
|
||||
audio,
|
||||
} from './audio-el.js';
|
||||
import {
|
||||
_snapshotResumeSession,
|
||||
} from './resume-session.js';
|
||||
import {
|
||||
_resetJuceAudioShimChain,
|
||||
} from './juce-audio.js';
|
||||
import {
|
||||
_hideSectionPracticeBar,
|
||||
_resetSectionPracticeLog,
|
||||
_scheduleSectionPracticeRetries,
|
||||
} from './section-practice.js';
|
||||
import {
|
||||
_cancelCountIn,
|
||||
armCreditsHideOnPlay,
|
||||
hideSongCreditsOverlay,
|
||||
holdCreditsThen,
|
||||
scheduleCreditsHide,
|
||||
showSongCreditsOverlay,
|
||||
startSongCountIn,
|
||||
} from './count-in.js';
|
||||
import {
|
||||
_autoplayExitEnabled,
|
||||
_countdownBeforeSongEnabled,
|
||||
_resetPlaybackSpeedForNewSong,
|
||||
} from './player-controls.js';
|
||||
import {
|
||||
_audioTime,
|
||||
_resetAudioSeekState,
|
||||
_songEventPayload,
|
||||
jucePlayer,
|
||||
setPlayButtonState,
|
||||
togglePlay,
|
||||
} from './transport.js';
|
||||
import {
|
||||
_activeLibraryProviderId,
|
||||
_bumpLibNavGeneration,
|
||||
_getArrangementNamingMode,
|
||||
_libScrollOnNextRender,
|
||||
_resetLibraryProviderViewState,
|
||||
loadFavorites,
|
||||
loadLibrary,
|
||||
loadLibraryProviders,
|
||||
stopInfiniteScroll,
|
||||
} from './library.js';
|
||||
import {
|
||||
S,
|
||||
} from './player-state.js';
|
||||
import {
|
||||
L,
|
||||
} from './library-state.js';
|
||||
// Tracks which list screen launched the player so Esc-from-player
|
||||
// returns the user to that screen instead of always defaulting to
|
||||
// the Library (feedBack#126). Reset on every `playSong` call so a
|
||||
// song launched from a deep-link / plugin screen still gets a sane
|
||||
// fallback ('home').
|
||||
export let _playerOriginScreen = 'home';
|
||||
|
||||
export let _settingsOriginScreen = 'home';
|
||||
|
||||
// ── Screen Navigation ─────────────────────────────────────────────────────
|
||||
export async function showScreen(id) {
|
||||
// ── 'home' is the LEGACY library screen. Always route it to the v3 Songs list. ──
|
||||
//
|
||||
// The v3 shell replaced #home with #v3-songs. That mapping DID exist — but only inside
|
||||
// wrappers on `window.showScreen`, and only for callers that go through `window`:
|
||||
//
|
||||
// app.js publishes the raw fn -> shell.js wraps it (adding the mapping)
|
||||
// -> the stems plugin wraps it AGAIN, capturing whatever
|
||||
// happened to be there at the time
|
||||
//
|
||||
// Two ways that fails, and testers hit both:
|
||||
//
|
||||
// 1. ORDER. Three independent parties monkey-patch window.showScreen, each capturing the
|
||||
// current value. Plugins load ASYNCHRONOUSLY, so the chain links up in whatever order
|
||||
// the race settles — and any capture taken before shell.js installs, or any
|
||||
// re-assignment after it, silently drops the mapping.
|
||||
//
|
||||
// 2. THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
|
||||
// Esc-from-settings shortcut call the IMPORTED showScreen directly, so no wrapper ever
|
||||
// sees them. Verified in a browser: the unwrapped function with 'home' lands on the dead
|
||||
// legacy screen every single time.
|
||||
//
|
||||
// Hence "randomly, when moving to the library from another menu option" — and "never when a
|
||||
// song ends", because closeCurrentSong resolves its target through _resolvePlayerOrigin(),
|
||||
// which already applies this mapping.
|
||||
//
|
||||
// So it lives HERE now: ONE guard in the function every caller routes through, rather than a
|
||||
// chain of monkey-patches that must each remember.
|
||||
//
|
||||
// ONLY 'home'. NOT 'v3-home'. _resolvePlayerOrigin() maps BOTH — correctly, because it
|
||||
// computes where to RETURN TO after a song, and coming back to the Songs list from the
|
||||
// dashboard is the right behaviour. Copying that condition here was a [P1] (Codex caught it):
|
||||
// #v3-home is the v3 DASHBOARD, a real screen the shell's Home nav, the onboarding tour and
|
||||
// the dashboard re-render listener all target. Redirecting it would make Home unreachable.
|
||||
//
|
||||
// A legacy alias is not the same thing as a return target.
|
||||
if (id === 'home' && document.getElementById('v3-songs')) {
|
||||
id = 'v3-songs';
|
||||
}
|
||||
|
||||
// Capture the previous screen before changing active classes
|
||||
const prevScreenId = document.querySelector('.screen.active')?.id;
|
||||
|
||||
// ── screen:changing — emitted BEFORE any of the work below ──────────────────
|
||||
//
|
||||
// Timing matters here, and Codex caught me getting it wrong. The stems plugin used to
|
||||
// monkey-patch window.showScreen so it could tear down its audio graph BEFORE navigation
|
||||
// began. screen:changed fires at the very END of this function — after awaiting library and
|
||||
// provider loads — so moving that plugin onto it would have delayed teardown behind a slow
|
||||
// fetch, or skipped it entirely if the fetch threw. Stems would keep playing on a non-player
|
||||
// screen.
|
||||
//
|
||||
// So there are two events, and the distinction is the whole point:
|
||||
// screen:changing — before anything happens. "I am leaving `from`." Cancel/teardown here.
|
||||
// screen:changed — after the DOM and data are settled. "I am on `id`."
|
||||
if (window.feedBack) window.feedBack.emit('screen:changing', { id, from: prevScreenId || null });
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
// Mark the next render as a screen-entry so it scrolls the
|
||||
// restored selection into view exactly once. Routine renders
|
||||
// (search / sort / filter typing) won't have this flag set and
|
||||
// so won't yank the viewport. Also bump the nav-items
|
||||
// generation so the next keypress doesn't reuse a cache built
|
||||
// against a now-hidden screen's container.
|
||||
_bumpLibNavGeneration();
|
||||
if (id === 'home') {
|
||||
_libScrollOnNextRender.home = true;
|
||||
const beforeProviderId = _activeLibraryProviderId();
|
||||
await loadLibraryProviders({ restoreSaved: true });
|
||||
if (_activeLibraryProviderId() !== beforeProviderId) {
|
||||
_resetLibraryProviderViewState();
|
||||
} else {
|
||||
L.libEpoch++;
|
||||
L.currentPage = 0;
|
||||
L.treeStats = null;
|
||||
stopInfiniteScroll();
|
||||
}
|
||||
loadLibrary(0);
|
||||
}
|
||||
if (id === 'favorites') { _libScrollOnNextRender.favorites = true; loadFavorites(); }
|
||||
if (id === 'settings') {
|
||||
// Record where we came from so Esc can go back. The player screen
|
||||
// is torn down by the `id !== 'player'` branch below, so
|
||||
// re-entering it via showScreen() would land on a dead screen —
|
||||
// fall back to the player's own origin (or 'home') instead.
|
||||
if (prevScreenId && prevScreenId !== 'settings') {
|
||||
_settingsOriginScreen = prevScreenId === 'player'
|
||||
? (_playerOriginScreen || 'home')
|
||||
: prevScreenId;
|
||||
}
|
||||
loadSettings();
|
||||
}
|
||||
if (id !== 'player') {
|
||||
const audio = document.getElementById('audio');
|
||||
const stopTime = _audioTime();
|
||||
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying;
|
||||
// Snapshot where we were so leaving the player — especially by accident
|
||||
// — is recoverable instead of dumping the user back at bar 1 next time.
|
||||
// Must run BEFORE window.highway.stop()/audio unload, while getSongInfo() and
|
||||
// the position (stopTime) are still live.
|
||||
if (hadPlayableSong) _snapshotResumeSession(stopTime);
|
||||
window.highway.stop();
|
||||
// Cancel any queued seeks, in-flight shim closures, AND active
|
||||
// count-in timers before stopping playback so none of these paths
|
||||
// can mutate the torn-down session (mirrors the same triple reset
|
||||
// in playSong()).
|
||||
_cancelCountIn();
|
||||
_resetJuceAudioShimChain();
|
||||
_resetAudioSeekState();
|
||||
if (window._juceMode) {
|
||||
// HTML5 emits 'pause' via the media-element listener below;
|
||||
// JUCE doesn't, so plugins would stay stuck in "playing".
|
||||
// Snapshot the canonical payload BEFORE stop() resets _pos
|
||||
// to 0, then emit AFTER stop completes. Mirrors the HTML5
|
||||
// pause contract via _songEventPayload (audioT/chartT/perfNow).
|
||||
const payload = _songEventPayload();
|
||||
const wasPlaying = S.isPlaying;
|
||||
await jucePlayer.stop().catch(() => {});
|
||||
if (wasPlaying && window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', payload);
|
||||
}
|
||||
window._juceMode = false;
|
||||
window._juceAudioUrl = null;
|
||||
}
|
||||
if (hadPlayableSong) window.feedBack.emit('song:stop', { time: stopTime || 0, screen: id });
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
window._currentSongAudio = null;
|
||||
// Reloading any song later should get a fresh JUCE routing attempt.
|
||||
window._clearJuceRerouteMemo?.();
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
// `from` is the screen we just LEFT. Without it, "I am leaving the player" is not
|
||||
// expressible from an event, and the only way to express it was to WRAP window.showScreen —
|
||||
// which is what shell.js and the stems plugin both did, and why the library intermittently
|
||||
// showed the legacy screen (#923, #924): three parties patching one global, each capturing
|
||||
// whatever was there at the time, in whatever order the plugin loads settled.
|
||||
//
|
||||
// Additive: every existing listener (app.js, audio-mixer.js, tour-engine.js) reads `id` and
|
||||
// is unaffected.
|
||||
if (window.feedBack) window.feedBack.emit('screen:changed', { id, from: prevScreenId || null });
|
||||
}
|
||||
|
||||
export let currentFilename = '';
|
||||
|
||||
export function _playbackApi() {
|
||||
return window.feedBack && window.feedBack.playback && window.feedBack.playback.version === 1
|
||||
? window.feedBack.playback
|
||||
: null;
|
||||
}
|
||||
|
||||
// Bridge hits are a "this legacy surface is still in use" signal, not a call
|
||||
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
|
||||
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
|
||||
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
|
||||
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
|
||||
// snapshot serialization on the main thread and saturated the inspector's
|
||||
// hitCount. Throttle per surface: the first call records immediately, repeats
|
||||
// within the window are dropped.
|
||||
export const _bridgeRecordLast = new Map();
|
||||
|
||||
export const _BRIDGE_RECORD_MIN_MS = 5000;
|
||||
|
||||
export function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
|
||||
const playback = _playbackApi();
|
||||
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
|
||||
const key = `${bridgeId}|${legacySurface}`;
|
||||
const now = Date.now();
|
||||
const last = _bridgeRecordLast.get(key);
|
||||
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
|
||||
_bridgeRecordLast.set(key, now);
|
||||
playback.recordBridgeHit({
|
||||
bridgeId,
|
||||
legacySurface,
|
||||
source: 'core.app',
|
||||
reason: reason || 'legacy playback surface used',
|
||||
});
|
||||
}
|
||||
|
||||
// Screen Wake Lock — keep the display awake while a song is playing so the
|
||||
// OS screensaver doesn't kick in during windowed-mode playback (only audio +
|
||||
// the highway animation are active, so the input-idle timer otherwise fires).
|
||||
// Engaged only while playing (acquire on play/resume, release on
|
||||
// pause/ended/stop) per issue #686. In a plain browser this uses the W3C
|
||||
// Screen Wake Lock API; inside feedBack-desktop (Electron) navigator.wakeLock
|
||||
// is unreliable, so we also drive the native powerSaveBlocker bridge when it
|
||||
// is exposed — both calls are best-effort and degrade silently elsewhere.
|
||||
export let _screenWakeLock = null;
|
||||
|
||||
export let _wakeLockPending = false;
|
||||
|
||||
// Desired state: true while a song should be keeping the screen awake. This is
|
||||
// the source of truth that survives the async gap of navigator.wakeLock.request
|
||||
// — set synchronously by acquire/release so an in-flight request that resolves
|
||||
// after playback already stopped can release itself instead of leaking a lock.
|
||||
export let _wakeLockWanted = false;
|
||||
|
||||
// Set when an acquire is requested while one is already in flight (e.g. a quick
|
||||
// hide→show during the first request); the in-flight request retries once on
|
||||
// settle so a transient NotAllowedError doesn't leave the song unprotected.
|
||||
export let _wakeLockRetry = false;
|
||||
|
||||
// Last value handed to the desktop bridge. This is the value we *requested*,
|
||||
// not one confirmed by the IPC round trip: the Electron main-process side
|
||||
// effect (powerSaveBlocker start/stop) happens when the message is received,
|
||||
// before its promise resolves, so deduping on the requested value lets opposite
|
||||
// transitions (true↔false) always go through promptly while still suppressing
|
||||
// redundant repeats (e.g. the synchronous song:play + song:resume pair). A
|
||||
// rejected/throwing call invalidates the marker (the side effect never landed)
|
||||
// so the next song:* / visibilitychange retries — without an inline re-sync,
|
||||
// which would tight-loop on a persistently failing bridge.
|
||||
// Last value handed to the bridge: false (off) / true (on) / null (unknown —
|
||||
// a call failed, so the real blocker state can't be assumed). null never equals
|
||||
// a boolean `want`, so the next sync always re-sends and recovers.
|
||||
export let _desktopAwakeReq = false;
|
||||
|
||||
// Monotonic id of the most recent bridge call, so a stale (out-of-order)
|
||||
// rejection from a superseded call can be ignored rather than corrupting the
|
||||
// marker — a boolean alone can't tell "my request failed" from "an older
|
||||
// same-valued request failed after a newer one already succeeded".
|
||||
export let _desktopAwakeGen = 0;
|
||||
|
||||
// Drive the native feedBack-desktop blocker to exactly (wanted && visible),
|
||||
// mirroring the browser wake lock which is only held while the page is visible.
|
||||
// Gating on visibility stops a minimized Electron window from keeping the whole
|
||||
// display awake. No-op in a plain browser; isolated from the wakeLock path so a
|
||||
// flaky bridge can't abort it.
|
||||
export function _syncDesktopBridge() {
|
||||
const want = _wakeLockWanted && document.visibilityState === 'visible';
|
||||
if (want === _desktopAwakeReq) return; // already requested this value
|
||||
const bridge = window.feedBackDesktop?.power?.setScreenAwake;
|
||||
if (typeof bridge !== 'function') return; // plain browser — nothing to sync
|
||||
_desktopAwakeReq = want;
|
||||
const gen = ++_desktopAwakeGen;
|
||||
let r;
|
||||
try {
|
||||
r = bridge(want);
|
||||
} catch (e) {
|
||||
console.debug('desktop wake bridge failed:', e?.name || e);
|
||||
if (gen === _desktopAwakeGen) _desktopAwakeReq = null; // unknown — force a re-send next event
|
||||
return;
|
||||
}
|
||||
if (r && typeof r.then === 'function') {
|
||||
r.catch((e) => {
|
||||
console.debug('desktop wake bridge rejected:', e);
|
||||
// The IPC didn't take effect; we can't assume which state the blocker
|
||||
// is in (a prior call may also have failed), so mark it unknown and
|
||||
// let the next song:* / visibilitychange re-send. Only if this is
|
||||
// still the latest request — a stale rejection from a superseded call
|
||||
// must not clobber a newer request's marker.
|
||||
if (gen === _desktopAwakeGen) _desktopAwakeReq = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function _acquireWakeLock() {
|
||||
_wakeLockWanted = true;
|
||||
_syncDesktopBridge();
|
||||
if (_screenWakeLock) return; // already held — nothing to do
|
||||
// A request is already in flight (song:play and song:resume fire
|
||||
// synchronously from the audio 'play' listener, and visibilitychange can
|
||||
// re-enter): don't issue a duplicate, but remember to retry on settle so a
|
||||
// visibility bounce during the request can't strand us without a lock.
|
||||
if (_wakeLockPending) { _wakeLockRetry = true; return; }
|
||||
if (!navigator.wakeLock?.request) return;
|
||||
_wakeLockPending = true;
|
||||
_wakeLockRetry = false;
|
||||
try {
|
||||
const sentinel = await navigator.wakeLock.request('screen');
|
||||
if (!_wakeLockWanted) {
|
||||
// Playback stopped while the request was in flight — release the
|
||||
// just-granted lock immediately rather than holding it stale.
|
||||
try { await sentinel.release(); } catch (e) { /* already released */ }
|
||||
return;
|
||||
}
|
||||
_screenWakeLock = sentinel;
|
||||
sentinel.addEventListener('release', () => {
|
||||
_screenWakeLock = null;
|
||||
// The UA auto-releases on tab hide, but may also release for its own
|
||||
// reasons (power policy) while the page stays visible. Re-acquire if
|
||||
// a song is still playing and we're visible — the visibilitychange
|
||||
// handler covers the hidden→visible case.
|
||||
if (_wakeLockWanted && document.visibilityState === 'visible') {
|
||||
_acquireWakeLock();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// NotAllowedError (page hidden / no user activation) or unsupported.
|
||||
console.debug('wakeLock request failed:', e?.name || e);
|
||||
} finally {
|
||||
_wakeLockPending = false;
|
||||
// A re-acquire arrived while the request was in flight (typically a
|
||||
// hide→show bounce). If we still want the lock, are visible, and didn't
|
||||
// get one (the request raced a hidden window and rejected), try once
|
||||
// more now that the page state has settled. Bounded: only fires when a
|
||||
// bounce actually occurred, so a permanently-denied request can't loop.
|
||||
if (_wakeLockRetry && _wakeLockWanted && !_screenWakeLock
|
||||
&& document.visibilityState === 'visible') {
|
||||
_wakeLockRetry = false;
|
||||
_acquireWakeLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function _releaseWakeLock() {
|
||||
_wakeLockWanted = false;
|
||||
_syncDesktopBridge();
|
||||
if (!_screenWakeLock) return;
|
||||
try { await _screenWakeLock.release(); } catch (e) { /* already released */ }
|
||||
_screenWakeLock = null;
|
||||
}
|
||||
|
||||
// Resolve where the player should return on Esc / close / auto-exit.
|
||||
// A one-shot setReturnScreen() override wins (consumed here) — used by the
|
||||
// lessons catalog so a lesson returns to the lessons screen rather than the
|
||||
// library, even though the external tutorials plugin owns the playSong call.
|
||||
// Otherwise remember the actual launch screen; the element-exists guard
|
||||
// keeps the classic v2 UI (no #v3-* ids) from being stranded on a missing
|
||||
// screen, and unknown launches fall back to 'home'. The dashboard — classic
|
||||
// 'home' and the v3 shell's 'v3-home' — returns to the Songs list when it
|
||||
// exists (dashboard actions call playSong() directly, so its id is the
|
||||
// active screen at launch).
|
||||
export function _resolvePlayerOrigin() {
|
||||
const override = window.feedBack && window.feedBack._nextReturnScreen;
|
||||
if (window.feedBack) window.feedBack._nextReturnScreen = null;
|
||||
if (override && document.getElementById(override)) return override;
|
||||
const launchFrom = document.querySelector('.screen.active');
|
||||
const launchId = launchFrom && launchFrom.id;
|
||||
if (launchId && launchId !== 'player' && document.getElementById(launchId)) {
|
||||
return ((launchId === 'home' || launchId === 'v3-home') && document.getElementById('v3-songs'))
|
||||
? 'v3-songs' : launchId;
|
||||
}
|
||||
return 'home';
|
||||
}
|
||||
|
||||
// Autoplay: one-shot flag armed by each fresh playSong(), consumed by the
|
||||
// next song:ready. song:ready also fires on arrangement switches / seeks,
|
||||
// which never arm the flag, so those don't auto-restart.
|
||||
export let _pendingAutostart = false;
|
||||
|
||||
// Autoplay gate (window.feedBack.holdAutoplay): a plugin (the tuner) can defer the
|
||||
// auto-start of a freshly-loaded song until it's cleared — "tune before you play".
|
||||
// The hold is claimed synchronously on song:loading (so it beats this song:ready
|
||||
// autostart); release() — or a fail-open backstop — runs the deferred start.
|
||||
// Generation-guarded so a newer song invalidates a stale hold. Manual Play never
|
||||
// flows through here, so Play always wins.
|
||||
export let _autoplayHeld = false;
|
||||
|
||||
export let _autoplayStart = null;
|
||||
|
||||
export let _autoplayGen = 0;
|
||||
|
||||
export let _autoplayBackstop = null;
|
||||
|
||||
export const AUTOPLAY_HOLD_BACKSTOP_MS = 12000;
|
||||
|
||||
export function _clearAutoplayHold() {
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
_autoplayHeld = false;
|
||||
_autoplayStart = null;
|
||||
_autoplayGen++;
|
||||
}
|
||||
|
||||
export function _releaseAutoplay(gen) {
|
||||
if (gen !== _autoplayGen) return; // a newer song superseded this hold
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
_autoplayHeld = false;
|
||||
const start = _autoplayStart;
|
||||
_autoplayStart = null;
|
||||
if (typeof start === 'function') start();
|
||||
}
|
||||
|
||||
export let _autoplayHoldToken = 0;
|
||||
|
||||
window.feedBack.holdAutoplay = function () {
|
||||
const gen = _autoplayGen;
|
||||
const token = ++_autoplayHoldToken; // this hold's identity — a stale release from an earlier hold is a no-op
|
||||
_autoplayHeld = true;
|
||||
if (_autoplayBackstop) clearTimeout(_autoplayBackstop);
|
||||
// Fail-open: a hold that's never released (a plugin that claimed but wedged before
|
||||
// it could decide) must never permanently block the song. Once the holder commits
|
||||
// to an intentional, user-dismissable hold it calls release.settle() to cancel this
|
||||
// — so the backstop can't cut off e.g. a user still tuning past the timeout.
|
||||
_autoplayBackstop = setTimeout(() => _releaseAutoplay(gen), AUTOPLAY_HOLD_BACKSTOP_MS);
|
||||
let released = false;
|
||||
function release() {
|
||||
if (released || gen !== _autoplayGen || token !== _autoplayHoldToken) return;
|
||||
released = true;
|
||||
_releaseAutoplay(gen);
|
||||
}
|
||||
// Cancel the fail-open backstop WITHOUT releasing: the holder has taken explicit
|
||||
// responsibility for releasing (on dismiss), and a song switch clears the hold anyway.
|
||||
release.settle = function () {
|
||||
if (gen !== _autoplayGen || token !== _autoplayHoldToken) return;
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
};
|
||||
return release;
|
||||
};
|
||||
|
||||
window.feedBack.on('song:ready', () => {
|
||||
if (!_pendingAutostart) return;
|
||||
_pendingAutostart = false;
|
||||
if (S.isPlaying) return;
|
||||
// Feedpak contributor credits: only real feedpak plays carry authors
|
||||
// (loose/archive and minigames get []), so a non-empty list is the gate.
|
||||
// Shown over the highway and dismissed the moment real playback begins
|
||||
// (song:play). This fresh-load path is the only place it fires —
|
||||
// arrangement switches / seeks / manual replays never arm _pendingAutostart,
|
||||
// and minigames never get here. Decoupled from autoplay below so credits
|
||||
// show on load even when autoplay-exit is disabled.
|
||||
const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || [];
|
||||
if (authors.length) {
|
||||
showSongCreditsOverlay(authors);
|
||||
armCreditsHideOnPlay();
|
||||
}
|
||||
// Autoplay-exit disabled: don't auto-start. Still let the credits dwell a
|
||||
// couple seconds on the freshly-loaded song, then clear them (they also
|
||||
// clear early if the user manually presses Play, via _creditsHideOnPlay).
|
||||
if (!_autoplayExitEnabled()) {
|
||||
if (authors.length) scheduleCreditsHide();
|
||||
return;
|
||||
}
|
||||
// The actual auto-start: a count-in (which handles HTML5 + _juceMode) or the
|
||||
// Play path directly. Guarded so a manual Play during a gate / credits hold
|
||||
// can't double-toggle, and so a stale (released-after-leaving) start never
|
||||
// begins playback off the player.
|
||||
const start = () => {
|
||||
if (S.isPlaying) return;
|
||||
if (!document.getElementById('player')?.classList.contains('active')) { hideSongCreditsOverlay(); return; }
|
||||
if (_countdownBeforeSongEnabled()) {
|
||||
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
|
||||
} else {
|
||||
Promise.resolve(togglePlay())
|
||||
.then(() => { if (!S.isPlaying) hideSongCreditsOverlay(); })
|
||||
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
|
||||
}
|
||||
};
|
||||
// A plugin (the tuner) may gate playback until it's cleared. The hold was
|
||||
// claimed on song:loading; stash the start and let release()/the backstop run
|
||||
// it. _cancelCountIn()/changeArrangement() clear _creditsTimer below, so a
|
||||
// teardown during the credits dwell still cancels a non-gated play.
|
||||
if (_autoplayHeld) { _autoplayStart = start; return; }
|
||||
// Not gated: a count-in starts now (it owns its on-screen dwell); otherwise
|
||||
// let the credits dwell a couple seconds first, then start.
|
||||
if (_countdownBeforeSongEnabled() || !authors.length) start();
|
||||
else holdCreditsThen(start);
|
||||
});
|
||||
|
||||
// Auto-exit: when the song ends, return to the launching menu. A scoring
|
||||
// plugin that shows an end-of-song results screen calls holdAutoExit() to
|
||||
// defer this; the user closing that screen (its Close button calls
|
||||
// window.closeCurrentSong()) performs the exit. With no results screen the
|
||||
// grace timer returns to the menu on its own.
|
||||
export const AUTO_EXIT_GRACE_MS = 1500;
|
||||
|
||||
export let _autoExitTimer = null;
|
||||
|
||||
export let _autoExitHeld = false;
|
||||
|
||||
// Bumped every time the auto-exit state is reset (new song via playSong, and
|
||||
// each song:ended). A hold's release() captures the generation at hold time
|
||||
// and no-ops once it changes, so a plugin that drops or fires its release
|
||||
// handle after the player has moved on can never navigate a fresh session —
|
||||
// callers don't need to balance the handle.
|
||||
export let _autoExitGen = 0;
|
||||
|
||||
export function _clearAutoExit() {
|
||||
if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; }
|
||||
_autoExitHeld = false;
|
||||
_autoExitGen++;
|
||||
}
|
||||
|
||||
// Heuristic safety net for score-screen plugins that don't (yet) call
|
||||
// holdAutoExit(): if a visible full-screen results/dialog overlay is on top
|
||||
// when the grace timer fires, defer the auto-return and let that screen's
|
||||
// own close button drive the exit (its Close should call closeCurrentSong).
|
||||
// getClientRects() is used for the visibility test because it reports
|
||||
// position:fixed overlays correctly, unlike offsetParent.
|
||||
export function _resultsOverlayVisible() {
|
||||
let nodes;
|
||||
try {
|
||||
nodes = document.querySelectorAll('[role="dialog"][aria-modal="true"], .fixed.inset-0');
|
||||
} catch (_) { return false; }
|
||||
for (const el of nodes) {
|
||||
if (!el || el.id === 'player') continue; // never the player itself
|
||||
if (el.classList && el.classList.contains('hidden')) continue;
|
||||
if (el.getClientRects && el.getClientRects().length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Plugins call this synchronously from their own song:ended handler (core
|
||||
// runs first, so the timer is already pending) to claim the exit.
|
||||
window.feedBack.holdAutoExit = function () {
|
||||
if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; }
|
||||
_autoExitHeld = true;
|
||||
const gen = _autoExitGen;
|
||||
let released = false;
|
||||
return function release() {
|
||||
// No-op once released, or once the session has moved on (a newer
|
||||
// playSong / song:ended bumped the generation) — so a stale handle
|
||||
// never navigates away from a fresh song.
|
||||
if (released || gen !== _autoExitGen) return;
|
||||
released = true;
|
||||
if (typeof window.closeCurrentSong === 'function') window.closeCurrentSong();
|
||||
};
|
||||
};
|
||||
|
||||
window.feedBack.on('song:ended', () => {
|
||||
_clearAutoExit();
|
||||
if (!_autoplayExitEnabled()) return;
|
||||
// Only auto-exit from the player screen (ignore stale/duplicate ends).
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (!active || active.id !== 'player') return;
|
||||
_autoExitTimer = setTimeout(() => {
|
||||
_autoExitTimer = null;
|
||||
if (_autoExitHeld) return; // a plugin explicitly claimed the exit
|
||||
if (_resultsOverlayVisible()) return; // a score/results overlay is up; let it drive the exit
|
||||
const cur = document.querySelector('.screen.active');
|
||||
if (cur && cur.id === 'player' && typeof window.closeCurrentSong === 'function') {
|
||||
window.closeCurrentSong();
|
||||
}
|
||||
}, AUTO_EXIT_GRACE_MS);
|
||||
});
|
||||
|
||||
// Abort controller for cancelling pending requests when entering player
|
||||
export let artAbortController = null;
|
||||
|
||||
export async function playSong(filename, arrangement, options) {
|
||||
console.log('playSong called:', filename);
|
||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
}
|
||||
// Invalidate any prior song's autoplay gate before plugins re-claim it on the
|
||||
// song:loading emit below.
|
||||
_clearAutoplayHold();
|
||||
window.feedBack.emit('song:loading', { filename, arrangement: arrangement ?? null });
|
||||
|
||||
// Cancel any pending art/metadata requests
|
||||
if (artAbortController) artAbortController.abort();
|
||||
artAbortController = null;
|
||||
|
||||
window.highway.stop();
|
||||
// Cancel any active count-in: clear timers/RAF and bump the gen so
|
||||
// delayed callbacks (rewind frames, post-seek then, count-in ticks,
|
||||
// post-count play) bail before mutating the new session.
|
||||
_cancelCountIn();
|
||||
// Reset the JUCE shim BEFORE awaiting jucePlayer.stop() so any in-flight
|
||||
// shim closures see a stale generation after their await and bail out
|
||||
// before mutating isPlaying / button label / song:* events for the
|
||||
// outgoing song.
|
||||
_resetJuceAudioShimChain();
|
||||
// Cancel queued _audioSeek calls from the previous song: bumping the
|
||||
// generation makes their chained callbacks bail out.
|
||||
_resetAudioSeekState();
|
||||
if (window._juceMode) {
|
||||
// Mirror the showScreen teardown: emit song:pause for the JUCE
|
||||
// path so plugins don't see a stale "playing" state on song
|
||||
// change. (HTML5 fires it via the audio element 'pause' event.)
|
||||
// Snapshot payload BEFORE stop() resets _pos so audioT/chartT
|
||||
// capture the actual paused position.
|
||||
const payload = _songEventPayload();
|
||||
const wasPlaying = S.isPlaying;
|
||||
await jucePlayer.stop().catch(() => {});
|
||||
if (wasPlaying && window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', payload);
|
||||
}
|
||||
window._juceMode = false;
|
||||
window._juceAudioUrl = null;
|
||||
}
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
// Stale until the incoming song's WS handler (window.highway.js) sets it again.
|
||||
window._currentSongAudio = null;
|
||||
// Fresh JUCE routing attempt for whatever song loads next.
|
||||
window._clearJuceRerouteMemo?.();
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
_resetPlaybackSpeedForNewSong();
|
||||
clearLoop();
|
||||
_resetSectionPracticeLog();
|
||||
_hideSectionPracticeBar();
|
||||
// Reset so the jump-fix (setInterval, ~line 8979) doesn't mistake the new
|
||||
// song starting at t=0 for an unexpected seek from the previous song's
|
||||
// position. audio.currentTime may not reset synchronously when src is cleared.
|
||||
S.lastAudioTime = 0;
|
||||
|
||||
currentFilename = filename;
|
||||
// A fresh load arms autoplay; a pending auto-exit from the previous
|
||||
// song is no longer relevant. A *resume* load (options.resume) instead
|
||||
// arms _pendingResume — consumed at song:ready to restore speed + seek to
|
||||
// the saved position, then start — so autostart and resume don't both try
|
||||
// to begin playback from different positions.
|
||||
if (options && options.resume && Number(options.resume.position) > 0) {
|
||||
S.pendingResume = options.resume;
|
||||
_pendingAutostart = false;
|
||||
} else {
|
||||
S.pendingResume = null;
|
||||
_pendingAutostart = true;
|
||||
}
|
||||
_clearAutoExit();
|
||||
// Remember which screen the player was launched from so Esc /
|
||||
// navigation back from the player (and auto-exit) returns the user
|
||||
// there (feedBack#126).
|
||||
_playerOriginScreen = _resolvePlayerOrigin();
|
||||
showScreen('player');
|
||||
|
||||
// Wait for previous WebSocket to fully close before opening new one
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
window.highway.init(document.getElementById('highway'));
|
||||
|
||||
const wsParams = new URLSearchParams();
|
||||
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
|
||||
wsParams.set('naming_mode', _getArrangementNamingMode());
|
||||
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/highway/${decodeURIComponent(filename)}?${wsParams.toString()}`;
|
||||
window.highway.connect(wsUrl);
|
||||
_resetSectionPracticeLog();
|
||||
_scheduleSectionPracticeRetries();
|
||||
loadSavedLoops();
|
||||
document.getElementById('quality-select').value = window.highway.getRenderScale();
|
||||
const _minScaleSel = document.getElementById('min-scale-select');
|
||||
if (_minScaleSel && window.highway.getMinRenderScale) _minScaleSel.value = String(window.highway.getMinRenderScale());
|
||||
}
|
||||
|
||||
// Leave the player and return to the screen the song was launched from
|
||||
// (Esc shortcut uses the same origin-aware target). showScreen() owns the
|
||||
// full teardown: song:stop, audio unload, window.highway.stop(), count-in cancel.
|
||||
export function closeCurrentSong() {
|
||||
// A real close (user Escape/✕, or the queue-aware wrapper once the queue is
|
||||
// exhausted) abandons any play-queue so a stale one can't advance later.
|
||||
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.clear();
|
||||
return showScreen(_playerOriginScreen || 'home');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
// KEYBOARD SHORTCUTS: the panel registry, the global dispatchers, and the plugin-facing API.
|
||||
//
|
||||
// ━━━ MOST OF THIS SUBSYSTEM IS TOP-LEVEL STATEMENTS, NOT DECLARATIONS ━━━
|
||||
//
|
||||
// 10 declarations — and 18 top-level statements. window.registerShortcut,
|
||||
// window.createShortcutPanel, getAllShortcuts, unregisterShortcut, clearWindowShortcuts, the
|
||||
// panel registry, and BOTH global keydown dispatchers are all bare statements at app.js's top
|
||||
// level. A dependency scan that walks declarations sees NONE of them, and would have reported
|
||||
// this cluster as 246 lines. It is more than double that.
|
||||
//
|
||||
// That blind spot has now cost twice: it nearly shipped a dead library A-Z rail (#896), and it
|
||||
// threw "Assignment to constant variable" in the session carve (#921), where the autoplay gate's
|
||||
// top-level statements wrote state that had become a read-only import. The extractor takes them
|
||||
// by construction now — any top-level statement that TOUCHES a moved binding comes along.
|
||||
//
|
||||
// window.registerShortcut and friends are a PLUGIN-FACING API. They keep working because app.js
|
||||
// still publishes them; the definitions simply live here, next to the dispatcher they feed.
|
||||
|
||||
import {
|
||||
_lastLibSelected,
|
||||
_libNavItems,
|
||||
_moveSelectionInItems,
|
||||
_providerSupports,
|
||||
_setLibSelection,
|
||||
_toggleHeader,
|
||||
} from './library.js';
|
||||
import {
|
||||
_sectionPracticeBarContains,
|
||||
_sectionPracticePopoverOpen,
|
||||
} from './section-practice.js';
|
||||
import {
|
||||
_trapFocusInModal,
|
||||
esc,
|
||||
} from './dom.js';
|
||||
import {
|
||||
playSong,
|
||||
} from './session.js';
|
||||
import { host } from './host.js';
|
||||
// ── Global keyboard shortcuts ─────────────────────────────────────────────
|
||||
//
|
||||
// `/` focuses the active screen's search input (Library / Favorites);
|
||||
// `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail
|
||||
// convention. The listener bails when the user is already typing in
|
||||
// any text-accepting element so it can't intercept normal typing —
|
||||
// including inputs inside the filters drawer, plugin settings, or
|
||||
// modal dialogs.
|
||||
export function _isTextInput(el) {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT') {
|
||||
// Some <input> types (button, checkbox, radio, range, ...) don't
|
||||
// accept text; only intercept the ones that do.
|
||||
const t = (el.type || 'text').toLowerCase();
|
||||
return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t);
|
||||
}
|
||||
if (tag === 'TEXTAREA') return true;
|
||||
if (tag === 'SELECT') return true;
|
||||
if (el.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _isShortcutHelpKey(e) {
|
||||
return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/'));
|
||||
}
|
||||
|
||||
export function _isShortcutHelpSuppressedTarget(el) {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT') {
|
||||
const t = (el.type || 'text').toLowerCase();
|
||||
return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t);
|
||||
}
|
||||
if (tag === 'TEXTAREA') return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _activeSearchInput() {
|
||||
// Pick the search field for whichever screen is currently active.
|
||||
// No match (e.g. on the player or settings screen) means `/` does
|
||||
// nothing — the shortcut only fires where a search box exists.
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (!active) return null;
|
||||
if (active.id === 'home') return document.getElementById('lib-filter');
|
||||
if (active.id === 'favorites') return document.getElementById('fav-filter');
|
||||
return null;
|
||||
}
|
||||
|
||||
export function _gridColumns(container) {
|
||||
// Count columns by grouping the first row of children by their
|
||||
// top coordinate. Robust against any grid-template-columns syntax
|
||||
// (`repeat(...)`, `auto-fit`, named lines, etc.) where naively
|
||||
// splitting `getComputedStyle().gridTemplateColumns` on whitespace
|
||||
// would miscount because of spaces inside `repeat(...)` /
|
||||
// `minmax(...)`. Falls back to 1 when the container is empty
|
||||
// so callers' max(1, ...) clamps stay valid.
|
||||
if (!container) return 1;
|
||||
const children = Array.from(container.children).filter(
|
||||
c => c && c.offsetParent !== null
|
||||
);
|
||||
if (!children.length) return 1;
|
||||
const firstTop = children[0].getBoundingClientRect().top;
|
||||
let cols = 0;
|
||||
for (const c of children) {
|
||||
// Allow ~1px slop for sub-pixel rounding so two children that
|
||||
// would visually align still group together.
|
||||
if (Math.abs(c.getBoundingClientRect().top - firstTop) < 1.5) cols++;
|
||||
else break;
|
||||
}
|
||||
return Math.max(1, cols);
|
||||
}
|
||||
|
||||
export function _isInsideInteractiveControl(el) {
|
||||
// Bail when the user is interacting with anything that has its
|
||||
// own keyboard semantics — form controls (checkbox / select /
|
||||
// button) consume arrow keys for their own behavior, and the
|
||||
// filters drawer is a focus trap of those. Without this guard the
|
||||
// library's arrow nav would steal arrow presses from a focused
|
||||
// tuning checkbox or sort dropdown.
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _isSpaceKey(e) {
|
||||
return e.key === ' ' || e.key === 'Spacebar';
|
||||
}
|
||||
|
||||
export function _shortcutDispatchBlocked(e) {
|
||||
if (_isTextInput(e.target)) return true;
|
||||
// Space in Section Practice bar should pause/resume, not toggle checkboxes/buttons.
|
||||
if (_isSpaceKey(e) && _sectionPracticeBarContains(e.target)) return false;
|
||||
// While the Section Practice popover is open, Esc just closes it (handled by
|
||||
// the popover's own keydown listener) — suppress the player-scope
|
||||
// "back to library" Esc so the user doesn't get bounced out of the player.
|
||||
if (e.key === 'Escape' && _sectionPracticePopoverOpen()) return true;
|
||||
// Space on the player screen should always play/pause, even if focus is on a
|
||||
// sidebar nav link, player rail button, popover control, or any other
|
||||
// interactive element — the shortcut dispatcher calls preventDefault so the
|
||||
// focused element won't also activate. Two exceptions keep native Space:
|
||||
// text inputs (already exempted above), and focus inside a true modal
|
||||
// dialog (role="dialog" aria-modal="true", or a .feedBack-modal overlay)
|
||||
// layered over the player — a modal traps interaction, so Space must reach
|
||||
// its focused control (e.g. the Close button) rather than toggle playback
|
||||
// behind it. Non-modal player popovers/toasts (loop A/B, arrangement pin,
|
||||
// role="dialog" aria-modal="false") are not modals and stay covered.
|
||||
if (_isSpaceKey(e) && _getCurrentContext().isPlayer &&
|
||||
!(e.target && e.target.closest &&
|
||||
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
|
||||
return false;
|
||||
}
|
||||
// Escape is the universal "back" action and must fire like Space above even
|
||||
// when a transport/rail control <button> holds keyboard focus after a click
|
||||
// — otherwise a focused control swallows Esc and the user can't leave the
|
||||
// song until they click empty canvas (feedBack — "Escape in song not
|
||||
// consistent"). It applies on the player (exit the song) AND settings
|
||||
// (return to the previous screen), both of which register an Escape=Back
|
||||
// shortcut. The earlier guards still win: text inputs are exempted at the
|
||||
// top (Esc there clears/blurs the field), and the Section Practice popover
|
||||
// already claimed Esc above. A true modal layered over the screen still
|
||||
// traps Esc — the modal-overlay check keeps Esc closing the modal rather
|
||||
// than ejecting past it to the screen behind.
|
||||
if (e.key === 'Escape') {
|
||||
const ctx = _getCurrentContext();
|
||||
if ((ctx.isPlayer || ctx.isSettings) &&
|
||||
!(e.target && e.target.closest &&
|
||||
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return _isInsideInteractiveControl(e.target);
|
||||
}
|
||||
|
||||
export function _handleLibArrowNav(e) {
|
||||
// Space (' ') is the standard activation key for focusable
|
||||
// elements alongside Enter — without it, a screen-reader user
|
||||
// hitting Space on a focused card would just scroll the page
|
||||
// instead of activating it. We treat Space identically to Enter
|
||||
// inside this handler.
|
||||
const isActivate = e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar';
|
||||
if (!isActivate &&
|
||||
!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
|
||||
return false;
|
||||
}
|
||||
if (_isInsideInteractiveControl(document.activeElement)) return false;
|
||||
const { items, container, mode } = _libNavItems();
|
||||
if (!items.length) return false;
|
||||
|
||||
const currentTarget = (document.activeElement && items.includes(document.activeElement))
|
||||
? document.activeElement
|
||||
: (_lastLibSelected && items.includes(_lastLibSelected) ? _lastLibSelected : null);
|
||||
|
||||
if (isActivate) {
|
||||
if (!currentTarget) return false;
|
||||
e.preventDefault();
|
||||
// Sync persistent selection before activating so Tab-then-Enter
|
||||
// (no prior arrow nav or mouse click) still lights up the `.selected`
|
||||
// ring and updates `_lastLibSelected`/localStorage — consistent with
|
||||
// the click delegate at the bottom of this file.
|
||||
_setLibSelection(currentTarget, { focus: false });
|
||||
if (currentTarget.classList.contains('song-row') ||
|
||||
currentTarget.classList.contains('song-card')) {
|
||||
if (currentTarget.dataset.librarySong && !currentTarget.dataset.play) {
|
||||
const providerId = decodeURIComponent(currentTarget.dataset.libraryProvider || '');
|
||||
if (!_providerSupports(providerId, 'song.sync')) return true;
|
||||
host.syncLibrarySong(
|
||||
providerId,
|
||||
decodeURIComponent(currentTarget.dataset.librarySong || ''),
|
||||
{ playWhenReady: true },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Song row OR card → play it. Pass `dataset.play` raw to
|
||||
// match the click delegate; `playSong` handles decoding
|
||||
// internally so decoding here would double-decode and
|
||||
// throw `URIError` on filenames containing `%`.
|
||||
playSong(currentTarget.dataset.play, undefined, { bridge: false });
|
||||
} else if (currentTarget.classList.contains('artist-header') ||
|
||||
currentTarget.classList.contains('album-header')) {
|
||||
// Header row → toggle the parent open/closed and re-derive
|
||||
// visible items so the next arrow press lands correctly.
|
||||
// `_toggleHeader` keeps `aria-expanded` in sync for
|
||||
// assistive tech.
|
||||
_toggleHeader(currentTarget);
|
||||
// Keep keyboard focus on the header we just toggled —
|
||||
// browsers sometimes drop focus to body when the
|
||||
// surrounding subtree changes display.
|
||||
currentTarget.focus({ preventScroll: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Home') { e.preventDefault(); _setLibSelection(items[0]); return true; }
|
||||
if (e.key === 'End') { e.preventDefault(); _setLibSelection(items[items.length - 1]); return true; }
|
||||
|
||||
if (mode === 'list') {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveSelectionInItems(items, 1); return true; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveSelectionInItems(items, -1); return true; }
|
||||
// Right/Left expand and collapse the artist/album under focus,
|
||||
// file-manager style. With nothing selected yet, both keys
|
||||
// initialize selection on the first visible item (matches
|
||||
// Up/Down behavior in `_moveSelectionInItems`) so the first
|
||||
// press doesn't fall through to native scroll.
|
||||
if (!currentTarget && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
|
||||
e.preventDefault();
|
||||
_setLibSelection(items[0]);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowRight' && currentTarget) {
|
||||
const parent = (currentTarget.classList.contains('artist-header') ||
|
||||
currentTarget.classList.contains('album-header'))
|
||||
? currentTarget.parentElement : null;
|
||||
if (parent && !parent.classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
// Use the shared toggle path so aria-expanded stays
|
||||
// synced with the visual state for screen readers.
|
||||
_toggleHeader(currentTarget);
|
||||
currentTarget.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
// Already open — step to the next visible item (which is
|
||||
// the first child of this header).
|
||||
e.preventDefault();
|
||||
_moveSelectionInItems(items, 1);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && currentTarget) {
|
||||
// If on an open header, collapse it. If on a song row or
|
||||
// closed header, jump to the nearest enclosing header.
|
||||
const isHeader = currentTarget.classList.contains('artist-header') ||
|
||||
currentTarget.classList.contains('album-header');
|
||||
const headerParent = isHeader ? currentTarget.parentElement : null;
|
||||
if (headerParent && headerParent.classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
_toggleHeader(currentTarget);
|
||||
currentTarget.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
// Walk up to the nearest .album-header / .artist-header
|
||||
// ancestor's sibling header. Closest album-group → its
|
||||
// header; otherwise closest artist-row → its header.
|
||||
const albumGroup = currentTarget.closest('.album-group');
|
||||
if (albumGroup && albumGroup.contains(currentTarget) &&
|
||||
!currentTarget.classList.contains('album-header')) {
|
||||
e.preventDefault();
|
||||
_setLibSelection(albumGroup.querySelector('.album-header'));
|
||||
return true;
|
||||
}
|
||||
const artistRow = currentTarget.closest('.artist-row');
|
||||
if (artistRow && !currentTarget.classList.contains('artist-header')) {
|
||||
e.preventDefault();
|
||||
_setLibSelection(artistRow.querySelector('.artist-header'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Grid mode: 2D nav. Columns are read from the live CSS grid so
|
||||
// we follow the responsive breakpoints automatically.
|
||||
const cols = _gridColumns(container);
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); _moveSelectionInItems(items, 1); return true; }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); _moveSelectionInItems(items, -1); return true; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveSelectionInItems(items, cols); return true; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveSelectionInItems(items, -cols); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shortcut cheat-sheet overlay. Opens on `?` (Shift+/), closes on
|
||||
// Esc (handled by the generic modal close path) or on backdrop /
|
||||
// close-button click. The list mirrors the canonical shortcut table
|
||||
// in this file's keydown handler — when a shortcut changes here, the
|
||||
// table below should change too. We keep it inline rather than
|
||||
// fetching a separate file so the cheat sheet can never disagree
|
||||
// with the version of app.js the user actually loaded.
|
||||
export function _openShortcutsModal() {
|
||||
if (document.getElementById('shortcuts-modal')) return;
|
||||
|
||||
function _isTreeMode() {
|
||||
// Check if we're in tree view (not grid) on the active library screen
|
||||
const screen = document.querySelector('.screen.active');
|
||||
if (!screen) return false;
|
||||
const tree = screen.querySelector('#lib-tree,#fav-tree');
|
||||
return tree && !tree.classList.contains('hidden');
|
||||
}
|
||||
|
||||
const ctx = _getCurrentContext();
|
||||
|
||||
// Library shortcuts that are handled by the navigation system (not in registry)
|
||||
const navShortcuts = [
|
||||
{ keys: '↑ ↓', desc: 'Move selection' },
|
||||
{ keys: '→', desc: 'Step in', condition: _isTreeMode },
|
||||
{ keys: '←', desc: 'Step out', condition: _isTreeMode },
|
||||
{ keys: 'Home / End', desc: 'Jump to first / last item' },
|
||||
{ keys: 'Enter / Space', desc: 'Activate selection (play song / toggle header)' },
|
||||
];
|
||||
|
||||
// Filter out items whose condition returns false
|
||||
const filterNavItems = (items) => items.filter(item => !item.condition || item.condition());
|
||||
|
||||
// Format a shortcut entry for display, including modifier prefixes
|
||||
const formatShortcut = (s) => {
|
||||
const mods = s.modifiers || {};
|
||||
let label = '';
|
||||
if (mods.ctrl) label += 'Ctrl+';
|
||||
if (mods.alt) label += 'Alt+';
|
||||
if (mods.shift) label += 'Shift+';
|
||||
if (mods.meta) label += 'Meta+';
|
||||
return label + s.key;
|
||||
};
|
||||
|
||||
// Get shortcuts from active panel by scope
|
||||
const getPanelShortcuts = (panel, scope) => {
|
||||
const shortcuts = [];
|
||||
for (const [key, s] of panel.shortcuts) {
|
||||
if (s.scope === scope) {
|
||||
shortcuts.push({ keys: formatShortcut(s), desc: s.description });
|
||||
}
|
||||
}
|
||||
return shortcuts;
|
||||
};
|
||||
|
||||
const activePanel = _panels.get(_activePanel);
|
||||
const defaultPanel = _panels.get('default');
|
||||
|
||||
// Merge shortcuts from both active and default panel for display
|
||||
const mergeShortcuts = (scope) => {
|
||||
const result = [];
|
||||
if (activePanel) result.push(...getPanelShortcuts(activePanel, scope));
|
||||
if (defaultPanel && defaultPanel !== activePanel) result.push(...getPanelShortcuts(defaultPanel, scope));
|
||||
return result;
|
||||
};
|
||||
|
||||
const playerShortcuts = mergeShortcuts('player');
|
||||
const globalShortcuts = mergeShortcuts('global');
|
||||
const libraryShortcuts = mergeShortcuts('library');
|
||||
|
||||
// Get plugin shortcuts for current plugin screen
|
||||
const pluginShortcuts = [];
|
||||
if (ctx.isPlugin && activePanel) {
|
||||
for (const [key, s] of activePanel.shortcuts) {
|
||||
if (s.scope.startsWith('plugin-') && s.scope === ctx.screen) {
|
||||
pluginShortcuts.push({ keys: formatShortcut(s), desc: s.description });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get shortcuts from other panels (if multiple panels exist)
|
||||
const otherPanelShortcuts = [];
|
||||
if (_panels.size > 1) {
|
||||
for (const [panelId, panel] of _panels) {
|
||||
if (panelId === _activePanel) continue;
|
||||
for (const [key, s] of panel.shortcuts) {
|
||||
otherPanelShortcuts.push({ keys: formatShortcut(s), desc: s.description, panel: panelId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build sections based on current context
|
||||
const sections = [];
|
||||
if (ctx.isSettings) {
|
||||
sections.push({ heading: 'Settings', items: mergeShortcuts('settings') });
|
||||
} else if (ctx.isLibrary) {
|
||||
sections.push({ heading: 'Library', items: [
|
||||
...filterNavItems(navShortcuts),
|
||||
...libraryShortcuts,
|
||||
{ keys: 'Esc', desc: 'Clear search' }
|
||||
]});
|
||||
}
|
||||
if (ctx.isPlayer) {
|
||||
sections.push({ heading: 'Player', items: playerShortcuts });
|
||||
}
|
||||
if (!ctx.isSettings && globalShortcuts.length > 0) {
|
||||
sections.push({ heading: 'Global', items: globalShortcuts });
|
||||
}
|
||||
if (pluginShortcuts.length > 0) {
|
||||
sections.push({ heading: 'Current Plugin', items: pluginShortcuts });
|
||||
}
|
||||
if (otherPanelShortcuts.length > 0) {
|
||||
// Group other panel shortcuts by panel
|
||||
const byPanel = new Map();
|
||||
for (const item of otherPanelShortcuts) {
|
||||
if (!byPanel.has(item.panel)) {
|
||||
byPanel.set(item.panel, []);
|
||||
}
|
||||
byPanel.get(item.panel).push(item);
|
||||
}
|
||||
for (const [panelId, items] of byPanel) {
|
||||
sections.push({ heading: `Panel ${panelId}`, items });
|
||||
}
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'shortcuts-modal';
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', 'Keyboard shortcuts');
|
||||
// Record the element that triggered the modal so Esc / close can
|
||||
// return focus to the correct entry even if _lastLibSelected drifts.
|
||||
// Scope to the active screen so a stale _lastLibSelected from a
|
||||
// different screen (e.g. Library vs Favorites) doesn't receive focus.
|
||||
const _scModal = document.querySelector('.screen.active');
|
||||
modal._opener = (_lastLibSelected && document.body.contains(_lastLibSelected)
|
||||
&& _scModal && _scModal.contains(_lastLibSelected))
|
||||
? _lastLibSelected : null;
|
||||
|
||||
const sectionsHtml = sections.map(section => {
|
||||
const itemsHtml = section.items.map(({ keys, desc }) => `
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-sm text-gray-300">${esc(desc)}</span>
|
||||
<kbd class="text-xs font-mono px-2 py-0.5 rounded bg-dark-600 border border-gray-700 text-gray-200 whitespace-nowrap">${esc(keys)}</kbd>
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<section class="mb-4 last:mb-0">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">${esc(section.heading)}</h4>
|
||||
${itemsHtml}
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-md mx-4 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-white">Keyboard shortcuts</h3>
|
||||
<button type="button" data-shortcuts-close
|
||||
class="text-gray-500 hover:text-white transition flex items-center gap-1.5" aria-label="Close shortcuts">
|
||||
<span class="text-xs text-gray-600">Esc</span>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
${sectionsHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click outside the inner panel (i.e. on the backdrop) closes the
|
||||
// modal — matches the conventional dialog UX.
|
||||
modal.addEventListener('click', (ev) => {
|
||||
if (ev.target === modal || ev.target.closest('[data-shortcuts-close]')) {
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
// Move focus into the dialog so background shortcuts (and arrow
|
||||
// nav) can't fire on the underlying library entry while the
|
||||
// overlay is open. Close button is the safe default — there's no
|
||||
// primary input to focus on a read-only cheat sheet.
|
||||
const closeBtn = modal.querySelector('[data-shortcuts-close]');
|
||||
if (closeBtn) closeBtn.focus({ preventScroll: true });
|
||||
// Trap Tab / Shift+Tab inside the modal so focus can't escape to
|
||||
// the library content underneath while the overlay is open.
|
||||
_trapFocusInModal(modal);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Modifier-key combos belong to the browser / OS shortcuts; never
|
||||
// intercept those.
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
if (_handleLibArrowNav(e)) return;
|
||||
|
||||
// `?` (Shift+/) opens the keyboard-shortcuts cheat sheet. Some
|
||||
// Linux/Electron stacks report Shift+/ as key='/' with code='Slash',
|
||||
// so check the help shape before treating plain '/' as search.
|
||||
if (_isShortcutHelpKey(e)) {
|
||||
if (_isShortcutHelpSuppressedTarget(e.target || document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
// Stop other keydown listeners on document (notably the shortcut
|
||||
// registry below) from also consuming this event — otherwise a
|
||||
// Linux/Electron Shift+Slash reported as key='/' opens help here and
|
||||
// then the registry's plain `/` library-search shortcut focuses
|
||||
// #lib-filter behind the modal. (Copilot review on #602.)
|
||||
e.stopImmediatePropagation();
|
||||
_openShortcutsModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '/') {
|
||||
if (_isTextInput(document.activeElement)) return;
|
||||
// Also bail when focus is inside the filter drawer, a dialog, or
|
||||
// any other interactive region — those contexts have their own
|
||||
// keyboard semantics and shouldn't be hijacked by the search
|
||||
// shortcut (e.g. a focused checkbox inside the filters drawer).
|
||||
if (_isInsideInteractiveControl(document.activeElement)) return;
|
||||
const search = _activeSearchInput();
|
||||
if (!search) return;
|
||||
e.preventDefault(); // suppress the literal '/' the input would receive
|
||||
search.focus();
|
||||
// Move caret to end without mutating .value — round-tripping
|
||||
// the value resets the browser's undo stack and can fire
|
||||
// unexpected input events on some engines. setSelectionRange
|
||||
// is the no-side-effects path.
|
||||
try {
|
||||
const len = search.value.length;
|
||||
search.setSelectionRange(len, len);
|
||||
} catch {
|
||||
// Some input types (search/email/tel) don't support
|
||||
// selection APIs in older browsers; the focus alone is
|
||||
// still useful, just no caret-end guarantee.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-letter shortcuts that act on the focused / selected
|
||||
// library entry — works on both grid cards and tree rows. Each
|
||||
// dispatches to a button class that the entry markup already
|
||||
// exposes, so plugins can keep owning the actual behavior:
|
||||
// f → .fav-btn (favorite heart toggle)
|
||||
// e → .edit-btn (edit metadata modal)
|
||||
// No-op when no entry is currently focused / selected, when the
|
||||
// entry doesn't expose the requested button, or when the button is disabled.
|
||||
// Bails on text input / drawer focus so single-letter typing in
|
||||
// inputs still works.
|
||||
const entryShortcut = { f: 'button.fav-btn', e: 'button.edit-btn' }[e.key.toLowerCase()];
|
||||
if (entryShortcut) {
|
||||
if (_isInsideInteractiveControl(document.activeElement)) return;
|
||||
const ae = document.activeElement;
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
const isEntry = el => el && el.classList && (el.classList.contains('song-card') || el.classList.contains('song-row'));
|
||||
// Scope both candidates to the active screen so that a stale
|
||||
// _lastLibSelected from Library doesn't fire when the user is
|
||||
// on Favorites (or vice-versa), and so pressing f/e/c on a
|
||||
// hidden screen can't accidentally persist that filename into
|
||||
// the current screen's localStorage key.
|
||||
const inActiveScreen = el => activeScreen && activeScreen.contains(el);
|
||||
const target = (isEntry(ae) && inActiveScreen(ae)) ? ae
|
||||
: (isEntry(_lastLibSelected) && inActiveScreen(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (!target) return;
|
||||
const btn = target.querySelector(entryShortcut);
|
||||
if (!btn || btn.disabled) return;
|
||||
e.preventDefault();
|
||||
// Sync the persistent selection to the acted-on entry so that
|
||||
// Esc-to-close-modal returns focus to the correct element and
|
||||
// the `.selected` highlight stays consistent with the action.
|
||||
_setLibSelection(target, { focus: false });
|
||||
btn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// Modal-first: close the topmost open modal (edit-metadata,
|
||||
// shortcuts cheat sheet, future modals) so Esc dismisses
|
||||
// from anywhere — including when keyboard focus is inside
|
||||
// a form field within the modal. Restores focus to the
|
||||
// element that opened the modal (tracked in modal._opener)
|
||||
// so arrow nav resumes without an extra Tab; falls back to
|
||||
// _lastLibSelected when the opener is no longer in the DOM.
|
||||
const modals = document.querySelectorAll('[role="dialog"][aria-modal="true"].feedBack-modal');
|
||||
if (modals.length) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const modal = modals[modals.length - 1];
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
// Esc while typing in either search box clears + blurs. Other Esc
|
||||
// semantics (drawer close, screen back) are handled elsewhere; we
|
||||
// only act when a search box is the focused element.
|
||||
const ae = document.activeElement;
|
||||
if (ae && (ae.id === 'lib-filter' || ae.id === 'fav-filter')) {
|
||||
if (ae.value) {
|
||||
ae.value = '';
|
||||
ae.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
ae.blur();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export class ShortcutPanel {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.shortcuts = new Map();
|
||||
}
|
||||
|
||||
_compositeKey(key, scope) {
|
||||
return `${scope}::${key}`;
|
||||
}
|
||||
|
||||
registerShortcut(options) {
|
||||
const { key, description, scope = 'global', condition = null, handler, modifiers = null } = options;
|
||||
|
||||
if (!key || !handler) {
|
||||
console.error(`registerShortcut: key and handler are required`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
const validScopes = ['global', 'player', 'library', 'settings'];
|
||||
const isValidScope = validScopes.includes(scope) ||
|
||||
scope.startsWith('plugin-');
|
||||
if (!isValidScope) {
|
||||
console.warn(`registerShortcut: invalid scope '${scope}'. Valid scopes are: global, player, library, settings, or plugin-{id}`);
|
||||
}
|
||||
|
||||
// Conflict detection: warn if key+scope is already registered
|
||||
const compositeKey = this._compositeKey(key, scope);
|
||||
if (this.shortcuts.has(compositeKey)) {
|
||||
console.warn(`registerShortcut [${this.id}]: '${key}' in scope '${scope}' is already registered; overwriting. Previous:`, this.shortcuts.get(compositeKey));
|
||||
}
|
||||
|
||||
this.shortcuts.set(compositeKey, { key, description, scope, condition, handler, modifiers });
|
||||
}
|
||||
|
||||
unregisterShortcut(key, scope) {
|
||||
return this.shortcuts.delete(this._compositeKey(key, scope));
|
||||
}
|
||||
|
||||
clearShortcuts() {
|
||||
this.shortcuts.clear();
|
||||
}
|
||||
|
||||
listShortcuts() {
|
||||
return Array.from(this.shortcuts.entries()).map(([ck, s]) => [s.key, s]);
|
||||
}
|
||||
}
|
||||
|
||||
// Global panel management
|
||||
export const _panels = new Map();
|
||||
|
||||
export let _activePanel = null;
|
||||
|
||||
export let _defaultPanel = null;
|
||||
|
||||
// Create default panel on init
|
||||
export const defaultPanel = new ShortcutPanel('default');
|
||||
|
||||
_panels.set('default', defaultPanel);
|
||||
|
||||
_defaultPanel = 'default';
|
||||
|
||||
_activePanel = 'default';
|
||||
|
||||
window.createShortcutPanel = (id) => {
|
||||
if (_panels.has(id)) {
|
||||
console.warn(`createShortcutPanel: panel '${id}' already exists`);
|
||||
return _panels.get(id);
|
||||
}
|
||||
const panel = new ShortcutPanel(id);
|
||||
_panels.set(id, panel);
|
||||
return panel;
|
||||
};
|
||||
|
||||
window.setActiveShortcutPanel = (id) => {
|
||||
if (!_panels.has(id)) {
|
||||
console.error(`setActiveShortcutPanel: panel '${id}' does not exist`);
|
||||
return;
|
||||
}
|
||||
_activePanel = id;
|
||||
};
|
||||
|
||||
window.getActiveShortcutPanel = () => _activePanel;
|
||||
|
||||
window.isInShortcutPanel = () => {
|
||||
return _activePanel !== 'default';
|
||||
};
|
||||
|
||||
window.getGlobalShortcutContext = () => {
|
||||
console.warn('getGlobalShortcutContext: Global shortcuts are exceptional. Consider using panel-scoped shortcuts instead.');
|
||||
return _panels.get('default');
|
||||
};
|
||||
|
||||
window.registerShortcut = (options) => {
|
||||
const panelId = _activePanel || _defaultPanel || 'default';
|
||||
const panel = _panels.get(panelId);
|
||||
|
||||
if (!panel) {
|
||||
console.error(`registerShortcut: No panel found for registration: ${panelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
panel.registerShortcut(options);
|
||||
};
|
||||
|
||||
// Flat, read-only snapshot of every registered shortcut across all panels,
|
||||
// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same
|
||||
// shortcut can live in both the active panel and the default panel) and uses
|
||||
// the same modifier-prefix formatting as the shortcuts modal. Returns
|
||||
// [{ combo, description, scope }]; remapping is not supported, so this is
|
||||
// purely informational.
|
||||
window.getAllShortcuts = () => {
|
||||
const fmt = (s) => {
|
||||
const m = s.modifiers || {};
|
||||
return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '')
|
||||
+ (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key;
|
||||
};
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const [, panel] of _panels) {
|
||||
if (!panel || !panel.shortcuts) continue;
|
||||
for (const [, s] of panel.shortcuts) {
|
||||
const combo = fmt(s);
|
||||
const dedupe = combo + '|' + (s.scope || '');
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
out.push({ combo, description: s.description || '', scope: s.scope || 'global' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
window.unregisterShortcut = (key, scope) => {
|
||||
// Try the active panel first to preserve panel isolation; fall back to
|
||||
// other panels so a shortcut registered before a panel switch is still
|
||||
// removable.
|
||||
const resolvedScope = scope || 'global';
|
||||
const activePanelId = _activePanel || _defaultPanel || 'default';
|
||||
const activePanel = _panels.get(activePanelId);
|
||||
if (activePanel && activePanel.unregisterShortcut(key, resolvedScope)) {
|
||||
return true;
|
||||
}
|
||||
for (const [panelId, panel] of _panels) {
|
||||
if (panelId === activePanelId) continue;
|
||||
if (panel.unregisterShortcut(key, resolvedScope)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
window.clearWindowShortcuts = (windowId) => {
|
||||
// Remove all shortcuts registered for a specific window
|
||||
// This is for backward compatibility with window-specific shortcuts
|
||||
let removed = 0;
|
||||
for (const [panelId, panel] of _panels) {
|
||||
if (panelId.startsWith(`window-${windowId}`)) {
|
||||
panel.clearShortcuts();
|
||||
_panels.delete(panelId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export function _getCurrentContext() {
|
||||
const currentScreen = document.querySelector('.screen.active')?.id;
|
||||
return {
|
||||
screen: currentScreen,
|
||||
windowId: window.getShortcutWindowId(),
|
||||
activePanel: _activePanel,
|
||||
isPlayer: currentScreen === 'player',
|
||||
isLibrary: ['home', 'favorites'].includes(currentScreen),
|
||||
isSettings: currentScreen === 'settings',
|
||||
isPlugin: currentScreen?.startsWith('plugin-')
|
||||
};
|
||||
}
|
||||
|
||||
export function _isShortcutActive(shortcut, ctx) {
|
||||
if (shortcut.scope === 'global') return true;
|
||||
if (shortcut.scope === 'player' && ctx.isPlayer) return true;
|
||||
if (shortcut.scope === 'library' && ctx.isLibrary) return true;
|
||||
if (shortcut.scope === 'settings' && ctx.isSettings) return true;
|
||||
if (shortcut.scope.startsWith('plugin-')) {
|
||||
const pluginId = shortcut.scope.replace('plugin-', '');
|
||||
return ctx.screen === `plugin-${pluginId}`;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _modifiersMatch(e, modifiers) {
|
||||
if (!modifiers) return true;
|
||||
if (modifiers.ctrl !== undefined && modifiers.ctrl !== e.ctrlKey) return false;
|
||||
if (modifiers.alt !== undefined && modifiers.alt !== e.altKey) return false;
|
||||
if (modifiers.shift !== undefined && modifiers.shift !== e.shiftKey) return false;
|
||||
if (modifiers.meta !== undefined && modifiers.meta !== e.metaKey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Debug mode for keyboard shortcuts
|
||||
export let _DEBUG_SHORTCUTS = false;
|
||||
|
||||
window._setDebugShortcuts = (enabled) => {
|
||||
_DEBUG_SHORTCUTS = enabled;
|
||||
console.log(`[Shortcuts] Debug mode ${enabled ? 'ENABLED' : 'DISABLED'}`);
|
||||
};
|
||||
|
||||
window._listShortcuts = () => {
|
||||
console.log('=== Registered Shortcuts ===');
|
||||
for (const [panelId, panel] of _panels) {
|
||||
console.log(`Panel: ${panelId}`);
|
||||
for (const [, s] of panel.shortcuts) {
|
||||
console.log(` ${s.key.padEnd(15)} | ${s.scope.padEnd(10)} | ${s.description}`);
|
||||
}
|
||||
}
|
||||
console.log('=== End ===');
|
||||
};
|
||||
|
||||
window._testShortcut = (key, scope) => {
|
||||
// Mirror the dispatcher: try the active panel first, then default.
|
||||
const resolvedScope = scope || 'global';
|
||||
const tried = new Set();
|
||||
const panelOrder = [_activePanel, _defaultPanel, 'default'].filter(id => {
|
||||
if (!id || tried.has(id)) return false;
|
||||
tried.add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const panelId of panelOrder) {
|
||||
const panel = _panels.get(panelId);
|
||||
if (!panel) continue;
|
||||
const shortcut = panel.shortcuts.get(panel._compositeKey(key, resolvedScope));
|
||||
if (!shortcut) continue;
|
||||
|
||||
const ctx = _getCurrentContext();
|
||||
const active = _isShortcutActive(shortcut, ctx);
|
||||
let conditionMet = true;
|
||||
if (shortcut.condition) {
|
||||
try { conditionMet = !!shortcut.condition(); }
|
||||
catch (err) { conditionMet = `threw: ${err.message}`; }
|
||||
}
|
||||
console.log(`Shortcut '${key}' [${resolvedScope}] [${panelId}]:`, {
|
||||
description: shortcut.description,
|
||||
scope: shortcut.scope,
|
||||
currentContext: ctx,
|
||||
isActive: active,
|
||||
conditionMet
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Shortcut '${key}' (scope: ${resolvedScope}) not registered in any panel`);
|
||||
};
|
||||
|
||||
// Expose internals for debugging (prefixed with _ to indicate private)
|
||||
// These are for development/debugging only and should not be used by plugins.
|
||||
window._panels = _panels;
|
||||
|
||||
window._getCurrentContext = _getCurrentContext;
|
||||
|
||||
window._isShortcutActive = _isShortcutActive;
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (_shortcutDispatchBlocked(e)) return;
|
||||
|
||||
const ctx = _getCurrentContext();
|
||||
const activePanel = _panels.get(_activePanel);
|
||||
const defaultPanel = _panels.get('default');
|
||||
|
||||
if (!activePanel && !defaultPanel) return;
|
||||
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Key pressed:', { key: e.key, code: e.code, ctx, activePanel: _activePanel });
|
||||
}
|
||||
|
||||
// Try active panel first, then fall back to default
|
||||
const panelsToDispatch = [];
|
||||
if (activePanel && activePanel !== defaultPanel) panelsToDispatch.push(activePanel);
|
||||
if (defaultPanel) panelsToDispatch.push(defaultPanel);
|
||||
|
||||
for (const panel of panelsToDispatch) {
|
||||
for (const [, shortcut] of panel.shortcuts) {
|
||||
// Match on both e.key (character produced) and e.code (physical key)
|
||||
if (e.key !== shortcut.key && e.code !== shortcut.key) continue;
|
||||
|
||||
// Check modifier keys if specified
|
||||
if (!_modifiersMatch(e, shortcut.modifiers)) continue;
|
||||
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Matched shortcut:', shortcut.key, shortcut);
|
||||
}
|
||||
|
||||
// Check scope
|
||||
if (!_isShortcutActive(shortcut, ctx)) {
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Not active - scope mismatch:', shortcut.scope, ctx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check condition callback — guard against plugin errors
|
||||
if (shortcut.condition) {
|
||||
try {
|
||||
if (!shortcut.condition()) {
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Not active - condition failed');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Shortcuts] condition() threw for key:', shortcut.key, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] Executing handler for:', shortcut.key);
|
||||
}
|
||||
// Guard handler against plugin errors
|
||||
try {
|
||||
shortcut.handler(e);
|
||||
} catch (err) {
|
||||
console.error('[Shortcuts] handler() threw for key:', shortcut.key, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_DEBUG_SHORTCUTS) {
|
||||
console.log('[Shortcuts] No shortcut matched for:', e.key, e.code);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
const windowId = window.getShortcutWindowId();
|
||||
const removed = window.clearWindowShortcuts(windowId);
|
||||
if (removed > 0 && _DEBUG_SHORTCUTS) {
|
||||
console.log(`[Shortcuts] Cleaned up ${removed} shortcuts for window ${windowId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Global shortcuts
|
||||
registerShortcut({
|
||||
key: '?',
|
||||
description: 'Show keyboard shortcuts',
|
||||
scope: 'global',
|
||||
handler: () => _openShortcutsModal()
|
||||
});
|
||||
|
||||
// Library shortcuts
|
||||
registerShortcut({
|
||||
key: '/',
|
||||
description: 'Focus search',
|
||||
scope: 'library',
|
||||
handler: () => {
|
||||
const input = _activeSearchInput();
|
||||
if (input) input.focus();
|
||||
}
|
||||
});
|
||||
@@ -172,7 +172,7 @@ export function _songEventPayload() {
|
||||
return {
|
||||
time: audioT,
|
||||
audioT,
|
||||
chartT: highway.getTime(),
|
||||
chartT: window.highway.getTime(),
|
||||
perfNow: performance.now(),
|
||||
};
|
||||
}
|
||||
@@ -289,8 +289,8 @@ export async function _audioSeek(s, reason) {
|
||||
// _audioSeek resolves (e.g. the auto-resume song:play in
|
||||
// changeArrangement) sees an in-sync chartT via _songEventPayload.
|
||||
// Without this, chartT lags by one 60Hz tick after a seek.
|
||||
if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
|
||||
highway.setTime(to);
|
||||
if (window.highway && typeof window.highway.setTime === 'function') {
|
||||
window.highway.setTime(to);
|
||||
}
|
||||
window.feedBack.emit('song:seek', { from, to, reason: reason || null });
|
||||
return { completed: true, from, to };
|
||||
|
||||
+19
-19
@@ -62,7 +62,7 @@ function _hasPromotedFlag() {
|
||||
// Pending nag: queued during _populateVizPicker, fired on the first
|
||||
// `song:ready` (so the toast lands when the user actually opens the
|
||||
// player, not at page load when they're still in the library).
|
||||
// `song:ready` is emitted by highway.js via window.feedBack.emit(), so
|
||||
// `song:ready` is emitted by window.highway.js via window.feedBack.emit(), so
|
||||
// subscribe through the same EventTarget. window.feedBack is created in
|
||||
// this same file before _populateVizPicker is reachable, so the global
|
||||
// is guaranteed to exist by the time this listener registers — but guard
|
||||
@@ -326,7 +326,7 @@ export async function _populateVizPicker(plugins) {
|
||||
// plugin options — _autoMatchViz saw no candidates and left the
|
||||
// default active. Now that plugins are registered, re-evaluate
|
||||
// against whatever song is currently loaded (a no-op when no song
|
||||
// has been loaded yet, since highway.getSongInfo() returns {}).
|
||||
// has been loaded yet, since window.highway.getSongInfo() returns {}).
|
||||
if (sel.value === 'auto') _autoMatchViz();
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ function _noteVizAutoMatch(id, matched) {
|
||||
}
|
||||
|
||||
function _installVizRenderer(renderer, id, source = 'user-select') {
|
||||
highway.setRenderer(_tagVizRenderer(renderer, id));
|
||||
window.highway.setRenderer(_tagVizRenderer(renderer, id));
|
||||
// Drop any stale notation-view hint now that we have a resolved renderer id.
|
||||
// This is also the path used by _autoMatchViz() after it resolves 'auto' to
|
||||
// a real plugin id, so the null passed at evaluation start is corrected here.
|
||||
@@ -377,7 +377,7 @@ export function setViz(id) {
|
||||
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (sel) sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
window.highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
@@ -399,7 +399,7 @@ export function setViz(id) {
|
||||
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
|
||||
const _sel = document.getElementById('viz-picker');
|
||||
if (_sel) _sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
window.highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
@@ -483,7 +483,7 @@ export function setViz(id) {
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
// Validate shape — highway.setRenderer will itself fall back to
|
||||
// Validate shape — window.highway.setRenderer will itself fall back to
|
||||
// default on a bad renderer, but without this check the UI and
|
||||
// localStorage would still advertise the broken selection.
|
||||
if (!renderer || typeof renderer.draw !== 'function') {
|
||||
@@ -503,7 +503,7 @@ export function setViz(id) {
|
||||
|
||||
// Auto mode: evaluate each registered viz factory's static
|
||||
// `matchesArrangement(songInfo)` predicate and install the first
|
||||
// matching renderer. No match → fall back to the built-in 2D highway.
|
||||
// matching renderer. No match → fall back to the built-in 2D window.highway.
|
||||
//
|
||||
// vizSelection stays 'auto' across invocations so the next song:ready
|
||||
// re-evaluates. An explicit picker choice overrides Auto by persisting
|
||||
@@ -530,10 +530,10 @@ function _setAutoVizLabel(resolvedText) {
|
||||
let _cancelPendingAutoLabel = null;
|
||||
|
||||
// One-shot (per song) hint shown when a notation-only arrangement falls back
|
||||
// to the built-in 2D highway. Such arrangements carry no wire notes
|
||||
// to the built-in 2D window.highway. Such arrangements carry no wire notes
|
||||
// (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so
|
||||
// the default renderer draws an empty board — without this the user is left
|
||||
// staring at a silently blank highway. Core ships no notation view; point at
|
||||
// staring at a silently blank window.highway. Core ships no notation view; point at
|
||||
// the viz picker instead.
|
||||
let _notationHintShownFor = null;
|
||||
function _showNotationViewHint(arrangementIndex, activeVizId) {
|
||||
@@ -579,8 +579,8 @@ function _dropStaleNotationHint(activeVizId) {
|
||||
const curFilename = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.filename) || '';
|
||||
if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const songInfo = (typeof window.highway?.getSongInfo === 'function')
|
||||
? (window.highway.getSongInfo() || {}) : {};
|
||||
const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null;
|
||||
if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined
|
||||
&& stale.dataset.arrangementIndex !== curArrIdx) {
|
||||
@@ -593,8 +593,8 @@ function _dropStaleNotationHint(activeVizId) {
|
||||
|
||||
export function _maybeShowNotationViewHint(activeVizId) {
|
||||
_dropStaleNotationHint(activeVizId);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const songInfo = (typeof window.highway?.getSongInfo === 'function')
|
||||
? (window.highway.getSongInfo() || {}) : {};
|
||||
const activeArr = Array.isArray(songInfo.arrangements)
|
||||
? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index)
|
||||
: null;
|
||||
@@ -641,8 +641,8 @@ export function _autoMatchViz() {
|
||||
// Reset label at evaluation start so a stale resolved label never persists
|
||||
// if the song changes or the picker re-evaluates with a different outcome.
|
||||
_setAutoVizLabel(null);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const songInfo = (typeof window.highway?.getSongInfo === 'function')
|
||||
? (window.highway.getSongInfo() || {}) : {};
|
||||
// Only update the label when a real song is loaded. Before the first
|
||||
// song_info frame, getSongInfo() returns {} — leaving the reset state
|
||||
// ("Auto (match arrangement)") is correct; we haven't evaluated yet.
|
||||
@@ -714,17 +714,17 @@ export function _autoMatchViz() {
|
||||
_noteVizAutoMatch(id, true);
|
||||
return;
|
||||
}
|
||||
// No match — restore the built-in 2D highway. setRenderer(null) is
|
||||
// No match — restore the built-in 2D window.highway. setRenderer(null) is
|
||||
// a no-op when the default is already active. If the previous Auto
|
||||
// pick was a WebGL renderer, highway.setRenderer() handles the
|
||||
// pick was a WebGL renderer, window.highway.setRenderer() handles the
|
||||
// context-type change by replacing the canvas element (cloneNode +
|
||||
// replaceWith) so the default 2D renderer's getContext('2d') always
|
||||
// succeeds — no canvas-lock limitation here.
|
||||
highway.setRenderer(null);
|
||||
window.highway.setRenderer(null);
|
||||
_notifyVizDomain('default', 'auto-match');
|
||||
_noteVizAutoMatch('default', false);
|
||||
// Update the label so the user can see Auto resolved to the built-in
|
||||
// highway. Read from the DOM rather than hard-coding the name so a
|
||||
// window.highway. Read from the DOM rather than hard-coding the name so a
|
||||
// future rename of the default entry is automatically reflected.
|
||||
if (hasSong) {
|
||||
const defaultOpt = Array.from(sel.options).find(o => o.value === 'default');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)));
|
||||
@@ -73,7 +73,7 @@
|
||||
function readArrangementSignal() {
|
||||
// Intentional karaoke/vocals signal: active arrangement name from the
|
||||
// highway WS (user selected Vocals in #arr-select). Do NOT use
|
||||
// highway.getLyricsVisible() — lyrics overlay stays on during normal
|
||||
// window.highway.getLyricsVisible() — lyrics overlay stays on during normal
|
||||
// guitar practice and must not force vocals POV.
|
||||
try {
|
||||
const si = root.highway && typeof root.highway.getSongInfo === 'function'
|
||||
@@ -96,6 +96,7 @@
|
||||
if (_active) {
|
||||
syncInstrumentPov();
|
||||
syncVenueMotion();
|
||||
syncCrowd(true);
|
||||
return;
|
||||
}
|
||||
_active = true;
|
||||
@@ -105,6 +106,17 @@
|
||||
setH3dMood(_lastMood);
|
||||
syncInstrumentPov();
|
||||
syncVenueMotion();
|
||||
syncCrowd(true);
|
||||
}
|
||||
|
||||
function syncCrowd(on) {
|
||||
// Reactive crowd video layer (career mode) — inert without a pack.
|
||||
try {
|
||||
if (root && root.v3VenueCrowd &&
|
||||
typeof root.v3VenueCrowd.setVenueActive === 'function') {
|
||||
root.v3VenueCrowd.setVenueActive(!!on);
|
||||
}
|
||||
} catch (_) { /* visual-only */ }
|
||||
}
|
||||
|
||||
function syncVenueMotion() {
|
||||
@@ -128,6 +140,7 @@
|
||||
_assetsLoaded = false;
|
||||
_loadFailed = false;
|
||||
setH3dActive(false);
|
||||
syncCrowd(false);
|
||||
syncPlaceholderVisibility();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The R3c perf gate: highway.js's render loop must not get more expensive.
|
||||
*
|
||||
* ── WHY FRAME RATE IS THE WRONG THING TO MEASURE ──────────────────────────────
|
||||
*
|
||||
* The highway AUTO-SCALES. When the smoothed draw cost climbs past its budget
|
||||
* (_DRAW_BUDGET_HI_MS = 12ms) it LOWERS THE RENDER RESOLUTION to protect the frame rate
|
||||
* (#654). That is exactly right for players. It also means a real performance regression
|
||||
* does not show up as dropped frames — it shows up as a BLURRIER PICTURE at a perfectly
|
||||
* healthy 60fps.
|
||||
*
|
||||
* Benchmark fps and you measure the feedback loop, not the renderer, and cheerfully
|
||||
* conclude that nothing changed while the picture quietly got worse.
|
||||
*
|
||||
* So this pins the scale — setRenderScale(1) + setMinRenderScale(1), which clamps
|
||||
* autoScale to [1, 1] — and measures `drawMs`, the renderer's own cost, straight from
|
||||
* highway.getPerf(). With the adaptive loop held still, drawMs is the signal.
|
||||
*
|
||||
* ── WHAT IT ASSERTS, AND THE TRAP I FELL INTO FIRST ──────────────────────────
|
||||
*
|
||||
* My first cut asserted "the auto-scaler was not forced to intervene" — i.e. effectiveScale
|
||||
* still == 1. That gate is VACUOUS, and the bite test proved it: I injected a 10x
|
||||
* regression (drawMs 2.4 -> 22.4ms, nearly double the 12ms budget) and the test PASSED.
|
||||
*
|
||||
* Of course it did. setMinRenderScale(1) sets the auto-scaler's FLOOR to 1, so
|
||||
* effectiveScale CANNOT drop below 1 — the very pinning that stops the scaler from hiding a
|
||||
* regression also stops it from ever reporting one. A guard that cannot fail.
|
||||
*
|
||||
* So with the scale pinned, drawMs IS the signal, and the threshold is the app's own:
|
||||
* _DRAW_BUDGET_HI_MS (12ms) is the cost at which the highway itself decides it is too
|
||||
* expensive and starts dropping resolution in production. Exceeding it is not an arbitrary
|
||||
* line in a benchmark — it is the renderer failing its own budget.
|
||||
*
|
||||
* That is a real gate and not a flaky one: the current cost is ~2.4ms, so there is ~5x
|
||||
* headroom before it trips, which is far more than headless-CI variance and far less than
|
||||
* any regression worth shipping.
|
||||
*/
|
||||
test('highway draw cost stays within its own render budget', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const perf = await page.evaluate(async () => {
|
||||
const w = window as any;
|
||||
const hw = w.highway;
|
||||
if (!hw || typeof hw.getPerf !== 'function') {
|
||||
return { error: 'highway.getPerf() missing — the perf gate is blind' };
|
||||
}
|
||||
|
||||
// Pin the adaptive loop so it cannot mask a regression by dropping resolution.
|
||||
hw.setRenderScale(1);
|
||||
hw.setMinRenderScale(1);
|
||||
|
||||
// Load a real chart and get the transport ACTUALLY RUNNING.
|
||||
//
|
||||
// Codex [P2] on the first cut of this, and it was right: headless Chromium may block
|
||||
// autoplay, in which case playSong() only LOADS the chart. The audio clock never
|
||||
// advances, the draw loop treats the session as paused, and _drawMsEMA keeps whatever
|
||||
// stale value it had at startup. The gate would then sample an IDLE renderer and
|
||||
// cheerfully report a healthy 2ms — while measuring nothing at all, on exactly the path
|
||||
// it exists to protect.
|
||||
const d = await (await fetch('/api/library?limit=1')).json();
|
||||
const f = d.songs && d.songs[0] && (d.songs[0].filename || d.songs[0].id);
|
||||
if (!f) return { error: 'no song in the library — the perf gate has nothing to render' };
|
||||
|
||||
const audio = document.getElementById('audio') as HTMLAudioElement | null;
|
||||
if (audio) audio.muted = true; // so autoplay policy cannot refuse us
|
||||
// ENCODE. playSong() decodes its argument before interpolating it into the /ws/highway
|
||||
// path, so every real caller hands it encodeURIComponent(filename) (app.js:2879, 4137).
|
||||
// A raw filename containing #, ?, % or / builds an invalid WebSocket URL and the song
|
||||
// never loads — on which libraries this gate would silently measure an idle renderer
|
||||
// rather than fail. Codex [P2], and correct.
|
||||
await w.playSong(encodeURIComponent(f));
|
||||
|
||||
// WAIT for playback to start; do NOT force it on a fixed timer.
|
||||
//
|
||||
// playSong() autoplays, but it takes ~3-4s to get there — it is fetching and decoding
|
||||
// stems. An earlier version of this test called togglePlay() after a flat 2s "if not
|
||||
// playing yet", which fired BEFORE autoplay, started playback, and then had the app's
|
||||
// own autoplay toggle it straight back to PAUSED. The renderer then idled through the
|
||||
// whole measurement and the gate happily reported 2ms of nothing.
|
||||
const playDeadline = Date.now() + 12000;
|
||||
while (!w.feedBack.isPlaying && Date.now() < playDeadline) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
// Only intervene if it truly never started (a stricter autoplay policy than we expect).
|
||||
if (!w.feedBack.isPlaying) await w.togglePlay();
|
||||
|
||||
// Wait for the CHART CLOCK to actually move. That is the proof the render loop is doing
|
||||
// real per-frame work, not sitting paused.
|
||||
const t0 = hw.getTime();
|
||||
const deadline = Date.now() + 8000;
|
||||
while (hw.getTime() - t0 < 0.5 && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
const advanced = hw.getTime() - t0;
|
||||
|
||||
// Let the EMAs settle under load (they are 0.9/0.1, so they need a few dozen frames).
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// Sample — and measure the clock ACROSS the sampling window, not just before it.
|
||||
// "It advanced at some point earlier" is not good enough: if playback stopped before we
|
||||
// started sampling (short song, ended track, autoplay revoked), the EMAs decay toward
|
||||
// idle and we would be reading the cost of drawing nothing.
|
||||
const sampleStart = hw.getTime();
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
samples.push(hw.getPerf().drawMs);
|
||||
}
|
||||
const advancedDuringSampling = hw.getTime() - sampleStart;
|
||||
|
||||
return {
|
||||
...hw.getPerf(),
|
||||
samples,
|
||||
advanced,
|
||||
advancedDuringSampling,
|
||||
isPlaying: !!w.feedBack.isPlaying,
|
||||
};
|
||||
});
|
||||
|
||||
expect(perf.error, String(perf.error)).toBeUndefined();
|
||||
|
||||
console.log(
|
||||
`[highway perf] drawMs=${(perf.drawMs ?? 0).toFixed(2)} frameMs=${(perf.frameMs ?? 0).toFixed(2)} ` +
|
||||
`renderScale=${perf.renderScale} autoScale=${perf.autoScale} effectiveScale=${perf.effectiveScale} ` +
|
||||
`budget=${perf.drawBudgetLoMs}..${perf.drawBudgetHiMs}ms ` +
|
||||
`playing=${perf.isPlaying} advancedBefore=${(perf.advanced ?? 0).toFixed(2)}s ` +
|
||||
`advancedDuringSampling=${(perf.advancedDuringSampling ?? 0).toFixed(3)}s`,
|
||||
);
|
||||
|
||||
// 0. THE GATE MUST NOT BE MEASURING AN IDLE RENDERER. If the transport never started, the
|
||||
// draw loop is paused, _drawMsEMA is a stale startup value, and every assertion below
|
||||
// passes while testing nothing. Assert the chart clock actually MOVED.
|
||||
expect(
|
||||
perf.advanced,
|
||||
'the chart clock never advanced — playback did not start, so drawMs is a stale idle ' +
|
||||
'value and this gate is measuring nothing',
|
||||
).toBeGreaterThan(0.5);
|
||||
|
||||
// …and it must STILL have been advancing while we sampled. "It moved at some point
|
||||
// earlier" is not good enough: if playback stopped before the sampling window, the EMAs
|
||||
// decay toward idle and we would be measuring the cost of drawing nothing.
|
||||
expect(
|
||||
perf.advancedDuringSampling,
|
||||
'the chart clock was not advancing DURING the sampling window — playback stopped, so ' +
|
||||
'these drawMs samples are the cost of an idle renderer, not a rendering one',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// 1. the renderer actually ran and reports a sane cost
|
||||
expect(Number.isFinite(perf.drawMs)).toBe(true);
|
||||
expect(perf.drawMs).toBeGreaterThan(0);
|
||||
|
||||
// 2. THE REGRESSION SIGNAL. With the scale pinned, drawMs is the renderer's true cost.
|
||||
// _DRAW_BUDGET_HI_MS is the app's OWN definition of "too expensive" — the cost at
|
||||
// which it starts sacrificing resolution for players in production. Blow through it
|
||||
// and the renderer has failed its own budget.
|
||||
//
|
||||
// Do NOT be tempted to assert on effectiveScale instead: pinning the scale makes that
|
||||
// number a constant, so it can never report anything. See the note above.
|
||||
expect(
|
||||
perf.drawMs,
|
||||
`highway draw cost ${perf.drawMs.toFixed(2)}ms exceeds its own budget of ` +
|
||||
`${perf.drawBudgetHiMs}ms — in production this is the point where the highway starts ` +
|
||||
`dropping render resolution to keep up`,
|
||||
).toBeLessThan(perf.drawBudgetHiMs);
|
||||
});
|
||||
@@ -18,7 +18,14 @@ const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// auto-exit machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin)
|
||||
// stayed in app.js.
|
||||
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
// R3d: the song session (showScreen / playSong / closeCurrentSong, and the autoplay hold and
|
||||
// auto-exit timer they own) was carved out of app.js into static/js/session.js. This file slices
|
||||
// functions from BOTH — `_resultsOverlayVisible` is still in app.js; `_releaseAutoplay` and
|
||||
// `_resolvePlayerOrigin` moved. Read both and strip `export`, exactly as CONTROLS_SRC already
|
||||
// does, rather than re-pinning each extraction at whichever file currently holds it.
|
||||
const SESSION_JS = path.join(__dirname, '..', '..', 'static', 'js', 'session.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8')
|
||||
+ '\n' + fs.readFileSync(SESSION_JS, 'utf8').replace(/^export /gm, '');
|
||||
// the module is ESM; these sandboxes evaluate plain script text
|
||||
const CONTROLS_SRC = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career');
|
||||
const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js');
|
||||
|
||||
test('career plugin manifest is complete and bundled', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
||||
assert.equal(manifest.id, 'career');
|
||||
assert.equal(manifest.bundled, true);
|
||||
assert.equal(manifest.screen, 'screen.html');
|
||||
assert.equal(manifest.script, 'screen.js');
|
||||
assert.equal(manifest.routes, 'routes.py');
|
||||
for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) {
|
||||
assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test('venues.json defines the 3 ascending tiers with star thresholds', () => {
|
||||
const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8'));
|
||||
assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]);
|
||||
const venues = content.venues;
|
||||
assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']);
|
||||
assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked');
|
||||
for (let i = 1; i < venues.length; i++) {
|
||||
assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold,
|
||||
'thresholds must ascend');
|
||||
}
|
||||
});
|
||||
|
||||
test('bar venue pack ships with intro media in the plugin checkout', () => {
|
||||
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'bar');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
|
||||
assert.deepEqual(Object.keys(manifest.loops).sort(),
|
||||
['bored', 'ecstatic', 'engaged', 'neutral']);
|
||||
assert.equal(manifest.intro.video, 'intro.mp4');
|
||||
assert.equal(manifest.intro.audio, 'bar-ambience.mp3');
|
||||
for (const f of [
|
||||
...Object.values(manifest.loops),
|
||||
...Object.values(manifest.stingers),
|
||||
manifest.intro.video,
|
||||
manifest.intro.audio,
|
||||
]) {
|
||||
const stat = fs.statSync(path.join(packDir, f));
|
||||
assert.ok(stat.size > 0, `${f} must be present`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shell promotes the career plugin into the sidebar', () => {
|
||||
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
||||
assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/);
|
||||
});
|
||||
|
||||
test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8');
|
||||
assert.match(src, /v3VenueCrowd/);
|
||||
assert.match(src, /setManifest\(manifest\)/);
|
||||
assert.match(src, /manifest\.base = /);
|
||||
assert.match(src, /feedBack-career-venue/);
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Regression guards for two Edit-Metadata modal fixes (static/app.js):
|
||||
// Regression guards for two Edit-Metadata modal fixes (static/js/edit-modal.js):
|
||||
//
|
||||
// 1. Year is editable — the modal renders an `edit-year` field and
|
||||
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
|
||||
@@ -19,8 +19,11 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const readApp = () => fs.readFileSync(APP_JS, 'utf8');
|
||||
// R3d: the edit modal was carved out of app.js into its own module. Bodies unchanged — only
|
||||
// the file moved. (It could go cleanly because the LIBRARY came out first: every dependency the
|
||||
// modal has is a module now, and it reads six library bindings without writing any.)
|
||||
const EDIT_MODAL_JS = path.join(__dirname, '..', '..', 'static', 'js', 'edit-modal.js');
|
||||
const readApp = () => fs.readFileSync(EDIT_MODAL_JS, 'utf8');
|
||||
|
||||
function loadFn(signature, sandbox, exportAs) {
|
||||
const fnSrc = extractFunction(readApp(), signature);
|
||||
|
||||
@@ -27,16 +27,33 @@ function extractBlock(src, signature) {
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
|
||||
// means they break again on the next carve — and worse, a source-shape assertion that silently
|
||||
// stops finding its target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) {
|
||||
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('highway declares adaptive-scale state with a floor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
assert.match(src, /(?:export\s+)?const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
});
|
||||
|
||||
test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
// Derives from the (sanitized) user ceiling and auto factor.
|
||||
assert.match(fn, /_renderScale/, 'effective scale must derive from the user _renderScale');
|
||||
@@ -47,7 +64,7 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
|
||||
});
|
||||
|
||||
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Hard floor constant kept; configurable floor read from localStorage.
|
||||
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
|
||||
@@ -65,7 +82,7 @@ test('min render scale floor is user-configurable + exposed on the api (#654)',
|
||||
});
|
||||
|
||||
test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resize', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(fn, /_DRAW_BUDGET_HI_MS/, 'must scale down past the high budget');
|
||||
assert.match(fn, /_DRAW_BUDGET_LO_MS/, 'must scale up below the low budget');
|
||||
@@ -74,27 +91,27 @@ test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resiz
|
||||
});
|
||||
|
||||
test('draw() only adapts during active playback and feeds the HUD', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
assert.match(fn, /if\s*\(\s*!_paused\s*\)\s*_adaptRenderScale/, 'must skip adaptation while paused');
|
||||
assert.match(fn, /_updatePerfHud\(\)/, 'must update the perf HUD each drawn frame');
|
||||
});
|
||||
|
||||
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
|
||||
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
|
||||
});
|
||||
|
||||
test('api exposes effective scale + perf stats', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /getEffectiveRenderScale\(\)\s*\{\s*return\s+_effectiveRenderScale\(\)/, 'api.getEffectiveRenderScale missing');
|
||||
assert.match(src, /getPerfStats\(\)\s*\{/, 'api.getPerfStats missing');
|
||||
});
|
||||
|
||||
// Robustness fixes from the #655 Copilot review.
|
||||
test('render scale is sanitized on load and effective scale guards non-finite', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /parseFloat\(localStorage\.getItem\('renderScale'\)[\s\S]{0,160}?Number\.isFinite/,
|
||||
'render scale load must validate via Number.isFinite + clamp');
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
@@ -102,7 +119,7 @@ test('render scale is sanitized on load and effective scale guards non-finite',
|
||||
});
|
||||
|
||||
test('stop() tears down the perf HUD and resets per-session accumulators', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /stop\(\)\s*\{[\s\S]{0,400}?_perfHud\.remove\(\)/,
|
||||
'stop() must remove the perf HUD so it cannot strand in the DOM');
|
||||
assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_autoScale\s*=\s*1/,
|
||||
@@ -112,7 +129,7 @@ test('stop() tears down the perf HUD and resets per-session accumulators', () =>
|
||||
});
|
||||
|
||||
test('perf HUD throttles its localStorage flag read off the hot path', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _updatePerfHud()');
|
||||
assert.match(fn, /_hudFlagAt/, 'HUD must cache the flag and re-read on an interval, not every frame');
|
||||
});
|
||||
|
||||
@@ -25,7 +25,9 @@ function loadFn(file, name) {
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
|
||||
// R3c: the PURE geometry/label primitives were carved out of highway.js into
|
||||
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
|
||||
const bnvNormalizedPoints = loadFn('static/js/highway-geometry.js', 'bnvNormalizedPoints');
|
||||
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
|
||||
|
||||
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -25,7 +25,9 @@ function loadFn(file, name) {
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const labels2D = loadFn('static/highway.js', 'chordHarmonyLabels');
|
||||
// R3c: the PURE geometry/label primitives were carved out of highway.js into
|
||||
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
|
||||
const labels2D = loadFn('static/js/highway-geometry.js', 'chordHarmonyLabels');
|
||||
const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels');
|
||||
|
||||
for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) {
|
||||
|
||||
@@ -19,8 +19,23 @@ const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set rather than re-pinning at whichever file currently holds a function — re-pinning
|
||||
// just breaks again next time, and a source-shape assertion that silently stops finding its
|
||||
// target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// The cache key triple must include chordTemplates — without it, a
|
||||
// late-arriving `chord_templates` WS message leaves cached
|
||||
// nonZeroNotes / nonZeroFrets stale until the next chord transition.
|
||||
@@ -41,7 +56,7 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
});
|
||||
|
||||
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// The cache-invalidation block must clear both _chordFretLineNotes
|
||||
// (so _updateFretLinePreview re-publishes with corrected isOpen
|
||||
// classification) and _frameMismatchWarned (so a chord ID warned
|
||||
|
||||
@@ -60,7 +60,7 @@ function buildClockSandbox(perfNowImpl) {
|
||||
performance: { now: perfNowImpl },
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
const setTimeBody = extractBlock(src, 'setTime(t) {');
|
||||
const getTimeBody = extractBlock(src, 'getTime() {');
|
||||
// Strip trailing comma if present (object-literal method declarations).
|
||||
@@ -72,8 +72,25 @@ function buildClockSandbox(perfNowImpl) {
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
|
||||
// means they break again on the next carve — and worse, a source-shape assertion that silently
|
||||
// stops finding its target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) {
|
||||
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Both anchor fields use NaN sentinels — _chartAnchorAudioT in
|
||||
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
|
||||
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
|
||||
@@ -82,11 +99,11 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
assert.match(src, /(?:export\s+)?const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
});
|
||||
|
||||
test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
|
||||
assert.ok(m, 'getTime() body not found');
|
||||
const slice = m[0];
|
||||
@@ -98,7 +115,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
|
||||
});
|
||||
|
||||
test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually changes', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Repeated setTime calls with the same value must not refresh the
|
||||
// anchor (else interpolation stutters); they also must not refresh
|
||||
// _chartLastAdvanceAt (else getTime would never detect a stalled
|
||||
@@ -115,7 +132,7 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
|
||||
});
|
||||
|
||||
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Find the actual getTime body. Match the whole brace-balanced
|
||||
// method (using a generous greedy slice to ensure we capture both
|
||||
// the stall check and the interpolation expression below it).
|
||||
@@ -139,7 +156,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
});
|
||||
|
||||
test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const src = highwaySources();
|
||||
// Use the brace-balanced extractor so the assertions are scoped to
|
||||
// the actual stop() body — a fixed-size slice would falsely match
|
||||
// resets that landed in an adjacent method.
|
||||
|
||||
@@ -12,6 +12,10 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
// R3c: _noteState moved to static/js/highway-state-primitives.js and gained an explicit
|
||||
// hwState first parameter — it has to, because createHighway() is a factory and a module
|
||||
// cannot import per-instance state without two panels sharing it.
|
||||
const primitivesJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-state-primitives.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_visibility.test.js).
|
||||
@@ -32,13 +36,28 @@ function extractBlock(src, signature) {
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
|
||||
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
|
||||
// whole set rather than re-pinning at whichever file currently holds a function — re-pinning
|
||||
// just breaks again next time, and a source-shape assertion that silently stops finding its
|
||||
// target is indistinguishable from one that passes.
|
||||
function highwaySources() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const jsDir = path.join(root, 'static', 'js');
|
||||
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
|
||||
for (const f of fs.readdirSync(jsDir).sort()) {
|
||||
if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
test('highway declares the note-state provider slot', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
});
|
||||
|
||||
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
@@ -47,15 +66,23 @@ test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteSt
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// The bundle field must point straight at _noteState — not a fresh
|
||||
// arrow each frame (the per-frame allocation the review flagged).
|
||||
assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
||||
// R3c: _noteState now takes hwState first, so the bundle hands out a per-INSTANCE bound
|
||||
// view created ONCE in the factory (boundNoteState) rather than the raw function. The
|
||||
// contract that matters is unchanged and still asserted: ONE stable reference, never a
|
||||
// fresh arrow per frame (feedBack#254). Assert it is a bare identifier, not an inline
|
||||
// function expression.
|
||||
assert.match(fn, /getNoteState\s*[:=]\s*(?:boundNoteState|_noteState)\b/,
|
||||
'bundle.getNoteState must be a stable reference (a name), not a per-frame arrow');
|
||||
assert.doesNotMatch(fn, /getNoteState\s*[:=]\s*(?:\(|function)/,
|
||||
'bundle.getNoteState must NOT be a fresh function per frame');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// Same allocation discipline as getNoteState: highway_3d uses this
|
||||
// bundle field to tell "provider attached" from "no provider but
|
||||
@@ -78,8 +105,8 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
});
|
||||
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(fs.readFileSync(primitivesJs, 'utf8'), 'function _noteState(hwState, note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
@@ -90,12 +117,24 @@ test('_noteState normalizes provider output as documented', () => {
|
||||
});
|
||||
|
||||
test('default 2D renderer threads note state into drawNote / drawSustains / chord path', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const src = highwaySources();
|
||||
// drawNote takes the trailing `ns` param.
|
||||
assert.match(src, /function\s+drawNote\(\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, 'drawNote must accept the trailing ns param');
|
||||
// R3c: drawNote moved to ./static/js/highway-draw.js and gained hwState as its FIRST arg
|
||||
// (createHighway is a factory — a module cannot import per-instance state without two
|
||||
// panels sharing it). The contract asserted here is unchanged: `ns` is still the TRAILING
|
||||
// parameter, which is what the note-state threading depends on.
|
||||
assert.match(src, /function\s+drawNote\(\s*hwState\s*,\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/,
|
||||
'drawNote must take hwState first and keep ns as the trailing param');
|
||||
// drawNotes / drawSustains / drawChords gate the lookup on the provider.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*n\s*,\s*n\.t\s*\)\s*:\s*null/, 'visible-note paths must skip the lookup when no provider is set');
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/, 'chord-note path must key the lookup by the chord time and gate on the provider');
|
||||
// R3c: _noteState gained an explicit hwState first arg (it lives in a module now, and
|
||||
// createHighway is a factory). The CONTRACT here is unchanged and still the point: skip
|
||||
// the lookup entirely when no provider is set — a per-visible-note call on every frame.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*n\s*,\s*n\.t\s*\)\s*:\s*null/,
|
||||
'visible-note paths must skip the lookup when no provider is set');
|
||||
// Same, for the chord path: keyed by the CHORD's time (ch.t), not the note's, and still
|
||||
// gated on the provider. Only the hwState arg is new.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/,
|
||||
'chord-note path must key the lookup by the chord time and gate on the provider');
|
||||
});
|
||||
|
||||
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
@@ -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) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user