Compare commits

..
Author SHA1 Message Date
ChrisBeWithYouandClaude Opus 4.8 d9d1dcedb8 feat(v3): Refresh button + live scan progress on the Songs library
Add a media-server-style "I dropped files in my folder, hit refresh" control to
the Songs toolbar. Refresh triggers an incremental /api/rescan and reuses the
existing /api/scan-status poll for live progress: a 3-state button (idle /
"Scanning..." while listing / "Scanning N/M" once counting) with a title tooltip
showing the current file + percent. A scan already running (the Settings buttons
or a background pass) is reflected on the button too. On completion it emits
library:changed so the grid reloads, and shows an honest, never-punishing
fbNotify toast (bottom-right, suppressed while in a song). No backend change --
same machinery the Settings rescan already drives. The precise "N added" count
is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
2026-07-01 01:02:12 -05:00
f9607c5c94 Port the Min res (minimum auto-resolution) selector into the v3 player (#663)
The v2 control bar exposes a "Min res" selector next to Quality that sets
highway.setMinRenderScale — capping how far the load-adaptive resolution
scaler (feedBack#654) may downscale, or disabling it entirely (Full). The
v3 UI only ported the Quality selector, so v3 users had no way to stop the
highway auto-downscaling to as low as quarter-res on heavy scenes / weak-
GPU launches — pixelated even at Quality = HD, with no workaround (worse
than v2).

Add the Min res row to the v3 viz/quality rail popover, under Quality,
mirroring the v2 control (same options, handler, title, aria). The handler,
the setMinRenderScale/getMinRenderScale API, and the shared app.js init
that syncs the selector's value (guarded by element id) all already exist —
only the v3 markup was missing.

Fixes #662

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:19:08 +02:00
3e3a98a0d0 Aspect-aware framing for ultra-wide 3D highway panes (#652)
* Add aspect-aware framing for ultra-wide highway panes

On a top/bottom 2-player split each 3D highway pane is full-width /
half-height (~32:9). The camera's vertical FOV was locked at a single
value, so at that aspect the horizontal cone ballooned past 130deg and
squeezed the fixed-width neck into a thin central sliver with large dead
margins on either side.

Add a "horizontal-FOV-hold" path: past a configurable start aspect the
effective vertical FOV is lowered so the horizontal cone stays roughly
constant, letting the neck fill a wide pane. At/under the start aspect it
is an exact no-op, so normal ~16:9 single-player and most 2x2 panes are
unchanged. Optional pose nudges (height / dolly / pitch / look-depth)
further flatten the view toward a low, immersive angle.

Everything is driven by a runtime bridge (window.__h3dAspectTune) with a
live tuner panel (Shift+A in the player) exposing every knob plus a live
aspect/FOV readout, localStorage persistence, and a Copy button. Toggling
the feature off restores the exact prior framing, so it doubles as an A/B
control. Shipped on by default for wide panes for testing feedback.

Source-pinned by tests/js/highway_3d_wide_fov.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): ship wide-pane framing default-OFF with a coherent config

Review fixes for the aspect-aware framing. The first cut shipped
_ASPECT_DEFAULTS = { enabled:true, baseVfov:30, blend:0, minVfovDeg:36 },
which contradicted the PR's own "default off → byte-for-byte prior behaviour"
claim:

- enabled:true made the tune active for everyone, and baseVfov:30 forced every
  pane's vertical fov from 70° to 30° (normal single-player/2x2 panes included —
  a drastic global zoom, not the advertised no-op).
- blend:0 collapsed the Hor+ math back to base, so the actual horizontal-FOV-
  hold did nothing even on wide panes — the only net effect was the zoom.
- minVfovDeg:36 > baseVfov:30 was an inverted floor (clamped wide panes UP to
  36° rather than flooring a real reduction).

New defaults: { enabled:false, baseVfov:BASE_VFOV(70), blend:1,
minVfovDeg:HORPLUS_MIN_VFOV(28) }. Now:

- OFF by default → camUpdate passes a null tune → effectiveVfov returns
  BASE_VFOV → exact no-op on every pane (verified: 70° at 16:9 and 32:9).
- When a tester enables it (Shift+A), baseVfov==BASE_VFOV keeps normal/≤start
  panes at 70° (still a no-op there) and blend:1 makes the hold actually engage
  on genuinely wide panes (47.7° at 32:9, flooring toward 28° as aspect grows).
- minVfovDeg < baseVfov is a real floor.

Also bumps the localStorage key (h3d_aspect_tune → h3d_aspect_tune2) so a
machine that persisted the old broken default gets the corrected one, and adds
source-pin tests guarding default-off + the coherent base/blend/floor so this
can't silently regress to default-on again. The pose-nudge values are left as
the author's in-progress wide-pane look (dormant until enabled). 110/110 tests
pass; node --check clean.

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

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-30 17:36:32 +02:00
db81d7dafb Add progress bar to v3 "Up Next" pill (#649)
The persistent top-right "Up Next" pill showed the upcoming section name
and a countdown ("in 12.3s") but no at-a-glance sense of how far through
the current section the song is. Add a thin progress bar directly under
the existing text that fills as the current section elapses toward the
next, reaching full when the section flips.

The text row is wrapped unchanged in a flex row and the pill stacks the
bar beneath it; nothing else about the pill's content or styling changes.
Progress is computed in updateUpNext() as the fraction elapsed between the
previous section boundary (last section at/before now, else song start)
and the next section. The fill uses the same gradient as the section name
for visual cohesion.

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:44:09 +02:00
a732e1f9d2 perf(tuner): idle the always-on tuner viz rAF when there's no signal (#647)
The v3 home tuner card runs continuously, and every tuner visualization
drove a self-rescheduling 60fps requestAnimationFrame loop that never
stopped — pinning a renderer core even on a silent home screen with the
needle/strobe at rest.

Make each viz idle its loop once there's nothing left to animate, and
re-kick it from update() on the next reading that actually moves it:

- analogue-gauge: stop when the needle + drum strip have settled on their
  targets (|target-current| below a sub-visible epsilon); restart when a
  new reading moves the target.
- strobe / mace-fx-iii / chef-mt3: stop when there's no live signal and the
  strobe drift (and glow fade) have fully decayed; restart on the next note.
- toilet-tuner: stop when silent and the plunger has eased back to centre;
  restart on the next reading (guarded so repeated no-signal updates don't
  re-kick a parked loop).

Active tuning is unchanged — the loop runs whenever a note is sounding or
the indicator is still moving. Bumps tuner 1.3.1 -> 1.3.2.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:12:42 +02:00
dfa825b4ab docs: host theme contract proposal (#645)
* docs: host theme contract proposal (prevent features carving into one theme)

Charrette output after a plugin UI feature (note_detect results card) was built
against only the default skin and broke on the others — the colours adapted via
tokens but the visual *devices* (glow ring, gradient) did not, because themes are
design languages, not palettes, and nothing governs whether a theme does glow.

Proposes a host theme contract: always-present semantic role tokens (incl. the
missing on-accent + focus-ring), intent-named capability recipe slots where "off"
is legal (an EMPHASIS recipe + an ACCENT-TEXT recipe), a window.feedBack.theme
read/capability API + theme:changed event, a derive-surfaces-from-host
reconciliation rule, accessibility baked in, and a skin-matrix verification gate.
All additive + feature-detected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* docs(host-theme-contract): make the proposal normative (review fixes)

Address the review of #645 (manual + Codex) — the doc was a sound design
sketch but not yet the precise *contract* it claims to be:

- P1 Token contract pinned: one public namespace `--fb-*` written on :root by a
  host-owned contract stylesheet (present themed-or-not); `--fbv-*` explicitly
  demoted to internal Tailwind-override plumbing (plugins must not read it).
  Added a normative role table + value grammar (colour roles = `r g b` triplets
  consumed via `rgb(var(--fb-x))`; recipe slots = full CSS device values). The
  §5 example now uses `--fb-*` throughout (was bare `--accent`/`--emph-*`).
- P1 Invisible-text bug removed from the spec: `--fb-acc-text-fill` is the one
  slot where `none` is ILLEGAL (always a real paint, defaulting to the solid
  accent); the example feature-detects `background-clip: text` and keeps a solid
  `color` base, so the accuracy number can never render transparent — honouring
  the DoD "a device stays legible when its slot resolves to none".
- P1 capabilities() booleans removed: they contradicted "never branch on
  glowy?" and were too lossy for canvas. The JS API is now CSS/DOM-forbidden and
  exposes RESOLVED token values (`get().tokens`) for canvas/WebGL renderers only.
- P1 Physical home decided: a static `theme-contract.css` (outside the prebuilt
  Tailwind artifact, so no tailwind-fresh CI churn) holds the :root `--fb-*`
  defaults + the single focus-visible + reduced-motion rules; existing v3.css
  focus/motion rules are a tracked reconciliation, not day-one magic.
- P2 Full on-fill family (`--fb-on-accent/-good/-warn/-bad`) + good/warn/bad ↔
  existing good/mid/low mapping; `theme:changed` lifecycle pinned (get() sync +
  valid pre-apply, event after commit + once on hydration, plugins read on
  mount); same-document-light-DOM scope + shadow/iframe bridge stated;
  prefersReducedMotion() named the single JS motion gate.

Resolved open questions folded into the body; the two genuine ones (skins-as-
host-themes, component-recipe bundles) remain.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 15:48:35 +02:00
3120ae3e71 fix(highway_3d): dolly back so the fret-number row can't clip off the bottom (#633)
The heat-coloured fret-number row is drawn as a band BELOW the board
(sY(lowest) - S_GAP*1.4), but camUpdate's self-correcting framing only
anchors the board CENTRE to the lower third of the screen and reserves no
headroom for that row. So a tight zoom on a centred active span (worst
mid-neck; fine at either end of the neck) pushes the numbers past the
bottom edge -- which is why testers saw it "only when centered" and "not
every song." Tilt can't fix it (it would only trade a bottom clip for a
top clip); the vertical-extent problem at tight zoom needs camera distance.

Add a fret-row fit guard: project the row band with the final camera and,
when it falls below FRET_ROW_FIT_NDC_MIN, raise a capped, hysteretic
_fretRowFitBoost applied to the curDist lerp target (the span-driven
tgtDist still owns zooming IN). The boost rises promptly (proportional to
the deficit), relaxes lazily past a deadband, and is capped at
FRET_ROW_FIT_BOOST_MAX (+60%) so the zoom can't pop or hunt. It cooperates
with the tilt loop (pull-back shrinks the scene, tilt keeps the centre
anchored) and yields entirely to the Camera Director free-cam. Surgical:
passages where the row is already visible never trigger it, so framing is
unchanged everywhere it already worked.

plugin.json 3.30.0 -> 3.30.2 (screen.js cache-buster; 3.30.1 is taken by the
FPS-counter PR). Tests: tests/js/highway_3d_camera_framing.test.js
(guard constants, the boosted curDist lerp, the projected-row hysteresis,
free-cam yield).

Fixes #632


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:31:24 +02:00
199550e5fb fix(v3): dismiss Section Practice popover when another player popover opens (#638)
* fix(v3): dismiss Section Practice popover when another player popover opens

The Section Practice popover (Songs > Song > Practice pill) stayed open
when the user then clicked a v3 player-rail icon (Plugins, Audio, …),
leaving two popovers stacked on top of each other. Reported on 0.3.0
(macOS) and still reproducing in the 2026-06-28 build.

Root cause: the popover's outside-click dismiss was bound in the
bubbling phase, but the v3 rail's icon buttons call e.stopPropagation()
in their click handler (player-chrome.js wireRail), which kills bubbling
before the click reaches document. So the dismiss listener never fired
for a rail-icon click and the popover was orphaned open.

Fix: bind the outside-click dismiss in the capture phase, which runs
before the target's handler so stopPropagation() can't swallow it. This
mirrors the audio mixer popover (audio-mixer.js), which already
dismisses outside-clicks via capture-phase listeners for exactly this
reason. Esc handling stays in the bubble phase (no rail handler stops
keydown propagation, and capturing it would reorder it ahead of the
player's Escape-to-exit handling).

Shared app.js code, so v2 is covered too; v2 has no stopPropagation rail,
so its outside-click dismiss behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* chore(#638): add CHANGELOG entry + capture-phase regression test

Review follow-ups for the Section Practice popover dismiss fix:
- CHANGELOG [Unreleased] → Fixed entry (repo workflow requires one).
- tests/js/section_practice_dismiss.test.js pins the fix: the outside-click
  dismiss binds in the CAPTURE phase (so a rail icon's stopPropagation can't
  swallow it), exactly one capture binding (Escape keydown stays bubble-phase),
  and the #section-practice-control containment guard (no self-close). A revert
  to bubble-phase fails the test.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 15:00:28 +02:00
8fbbc761fc fix(v3): reject accidental text-selection of UI chrome (user-select policy) (#637)
* fix(v3): reject accidental text-selection of UI chrome (user-select policy)

Dragging/double-clicking across the v3 UI marquee-highlighted buttons, labels,
the sidebar, transport, and the note-highway HUD — looks broken (reported Mac +
Windows). Default the v3 shell to user-select:none on html, then opt CONTENT
back in. Decided by a 4-lens panel (UX / a11y / dev-ops / plugin-ecosystem);
their guardrails are baked in:

- Form fields ALWAYS re-enabled (input/textarea/select/[contenteditable]) so the
  caret + IME composition never break. No `* { user-select:none }` (WebKit input
  bug 82692).
- Plugin screens (.screen[id^="plugin-"]) stay selectable BY INHERITANCE (no `*`,
  so a plugin's own non-select chrome still wins) — a plugin's copyable text
  (lyrics, chords, results), including community/out-of-tree plugins that never
  adopt the class, isn't silently locked.
- Core read-only content opts back in by CONTAINER via a hand-authored
  `.fb-selectable` (not a Tailwind utility — so runtime-installed plugins get it
  too): the whole Settings panel (paths, device names, version, diagnostics,
  About) and the now-playing song metadata. Answers the open "keep settings
  copyable?" question: yes, at the container.

Cosmetic only — never used to lock copy-worthy text (errors/IDs/paths/versions/
metadata stay selectable; WCAG 2.2 allows copy-paste as a mechanism). v3-only
(v2 unchanged; v3.css loads only on /v3); plain CSS, no Tailwind rebuild; no
desktop/Electron changes (standard OS-framed window). `.fb-selectable` is
documented in CLAUDE.md for plugin authors.

Tests: tests/js/v3_user_select_policy.test.js (html default, form-field
re-enable, plugin-screen carve without `*`, .fb-selectable, container opt-ins,
and the no-`*`-rule guardrail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): address review of the user-select policy (#637)

Review (manual + Codex) of the v3 text-selection policy:

- P1 (real bug): the now-playing HUD metadata opted into `.fb-selectable` but
  its `#player-hud` parent is `pointer-events: none`, so the mouse could never
  reach the text to select it — the opt-in was inert. Add `pointer-events-auto`
  to the metadata block (verified in-browser: user-select:text + pointer-
  events:auto, while the HUD parent stays pointer-events:none).
- Coverage: the PR's a11y guardrail promised copyable text stays selectable
  "incl. in modals/toasts", but only Settings + the HUD were opted in. Blanket-
  opt the focused copyable surfaces back in by selector — `.feedBack-modal`,
  `[role="dialog"]`, `#fb-notify-stack`, `#v3-fb-toast`, `#scan-banner` — so
  errors / IDs / paths / file names in dialogs, toasts, and the scan banner stay
  copyable. These are focused panels, not dense card lists, so re-enabling
  selection there can't recreate the across-cards marquee mess.
  (Deliberately NOT opting in the library grid / dashboard / profile card lists:
  making dense card text selectable would reintroduce exactly that marquee mess
  on a drag — copy song metadata from the now-playing HUD / Settings instead.)
- Test (P3): assert the selectable rule's selectors order-independently, cover
  the new modal/toast/banner surfaces, and check the HUD block carries BOTH
  fb-selectable and pointer-events-auto (class-order independent).

Verified in a real browser (chromium): html=none, sidebar chrome=none, input=
text, Settings=text, HUD meta=text+pointer-events:auto, dialog/modal=text.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 14:44:56 +02:00
a791a0d8fe feat(v3): DOM-virtualize the Songs grid (#636 item 3 stage 2) (#643)
The v3 Songs grid appended every scrolled page and never released nodes,
so card-node count grew unbounded with scroll depth (24 → 624 → 2001 for a
2000-song library). Replace it with a windowed/recycled render: only the
visible window (± overscan) is in the DOM while a #v3-songs-gridsizer
element sized to ceil(total/cols)*rowH gives the scrollbar full-library
geometry; #v3-songs-grid is absolutely positioned to the first visible row.

- state.songs is a sparse, absolutely-indexed store filled a page at a time
  by ensureWindow(): the stage-1 keyset cursor for contiguous forward scroll
  (O(page)), OFFSET page= for jumps/restore/non-keyset providers. _loadPage
  shares an in-flight promise per page and an epoch guard discards a stale
  fetch that lands after a reset.
- A–Z rail seeks directly via sort_letters cumulative counts (O(1), no
  page-through); bounded scan fallback for legacy providers without it.
- Snapshot/restore is now scrollTop-based (geometry is stable). Select mode,
  accuracy badges, ⋮ menu, plugin card actions, and tree/folder coexistence
  survive cards recycling; renderWindow re-renders when select mode toggles.
- Plugins get window.v3Songs.visibleCards() + a v3:library-window-rendered
  event instead of assuming all cards are present (highway-stutter lesson).

Verified in a browser against a seeded 2001-song library: DOM bounded to
~60 nodes while the count reads "2001 songs", rail jump lands on the target
row, selection survives recycling, scroll-restore exact. Codex-reviewed
(3 findings fixed: stale-fetch epoch guard, await-in-flight page promise,
select-mode resync on cached re-entry).

Frontend-only. Tests: tests/browser/v3-grid-virtualization.spec.ts pins the
bounded-DOM invariant + direct rail jump; tests/js/v3_az_rail.test.js and
v3_songs_scroll.test.js updated to the new wiring.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:26:29 +02:00
5ed6f454e7 feat(library): smart collections as a library provider (#641)
Implements feedBack#636 item 2 (P1) — saved library filters that stay live,
the homelab primitive FeedBack was missing (Plex smart collections / Navidrome
.nsp / *arr custom filters).

A collection is a saved /api/library query surfaced as a registered library
provider, so it appears in the v3 source picker and inherits the whole Songs
UI (paging, stats, A–Z rail, art) with no new screen.

- Storage reuses the playlist subsystem: a `playlists.rules` JSON column
  (additive, idempotent migration). A row with rules != NULL is a smart
  collection; list_playlists + get_playlist filter `rules IS NULL`, so
  collections are excluded from the manual-playlist list and read-only to
  every playlist mutation that gates on get_playlist.
- SmartCollectionProvider (kind="local" — matched songs are local rows, so the
  client's play/art paths stay on the local branch) delegates query_page/
  query_stats/query_artists to the local DB with the stored rules applied;
  tuning_names/get_art delegate straight through. Registered via a boot scan +
  on create/update (replace=True) / delete.
- Rules mirror the raw /api/library query params; `_sanitize_collection_rules`
  drops unknown keys and is applied at API ingress AND on provider load, so a
  hand-edited / imported bad value can't crash a query.
- API: GET/POST/PUT/DELETE /api/collections. Frontend: a "+ Save as
  collection" action in the v3 filter drawer (local provider + active filters
  only) that names the current filter set and switches to it.

Reviewed by Codex; 3 findings fixed (local-kind playback path, save gated to
local provider, re-sanitize persisted rules).

Tests: tests/test_collections_api.py (CRUD, provider filtering, restart
re-registration, kind=local, corrupt-rule tolerance, playlist isolation),
tests/js/v3_collections.test.js.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:42:41 +02:00
331857ff2a feat(library): keyset cursor pagination + stable sort tiebreak (#636 item 3, stage 1) (#642)
Stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3):
the data layer the DOM-recycling render window will build on, plus a latent
paging bug fixed on the way.

- Every grid sort now appends a unique `filename` tiebreak → a TOTAL order.
  Without it, rows with an equal sort key (e.g. two songs by the same artist)
  could be skipped or duplicated across OFFSET pages.
- query_page gains an opaque `after` keyset cursor: when supplied and the sort
  can keyset (artist[-desc], title[-desc], recent), the page is fetched with a
  WHERE-seek instead of OFFSET — O(page), independent of depth. The seek is
  NULL-aware (NULLs first in ASC / last in DESC) so it's EXACTLY OFFSET-
  equivalent; the legacy `dir=desc` shape is canonicalized so its cursor seeks
  the right direction. Unknown/compound sorts + bad cursors fall back to OFFSET.
- /api/library exposes `after` + `next_cursor`. Only the true local provider is
  handed a cursor (a collection may pin a different sort; remote don't keyset),
  so both page by OFFSET safely.
- Composite (artist NOCASE, filename) / (title NOCASE, filename) /
  (mtime, filename) indexes cover the order; `after` added to the optional
  provider kwargs so legacy providers drop it.

Codex-reviewed; 3 findings fixed (dir=desc canonicalization, NULL-key seek,
cursor only for the local provider).

Tests: tests/test_library_keyset.py (keyset==OFFSET parity for 5 sorts, stable
tiebreak on equal keys, dir=desc, NULL sort keys, bad-cursor + compound-sort
fallback).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:40:47 +02:00
8ca7ea4002 feat(library): persisted wishlist / "wanted" list (#640)
Closes feedBack#636 item 4 — the *arr "Wanted/Monitored" analogue FeedBack
was missing. A wishlist entry is a song the user does NOT own yet, so unlike
a playlist (which references owned local songs by filename) it can't reuse the
playlist subsystem; it lives in a new `wanted` table keyed by descriptive
identity (artist, title, source, source_ref, note, created_at).

- New table + a UNIQUE index on (artist NOCASE, title NOCASE, source,
  source_ref); additive + idempotent (CREATE … IF NOT EXISTS).
- MetadataDB.add_wanted (INSERT OR IGNORE + re-select under the write lock,
  so a re-run of an ownership-diff returns the existing row, never a dup),
  list_wanted (newest first), remove_wanted, count_wanted.
- Routes GET/POST/DELETE /api/wanted. POST requires artist or title and
  defaults source to "manual"; idempotent on identity so producers (the
  find_more ownership-diff, or a manual add) can re-post freely.

This is the core persistence primitive the charrette flagged as the missing
piece; the consuming UI lives in the producing plugin (find_more / the_daily).

Tests: tests/test_wanted_api.py (round-trip, identity idempotency incl.
case-insensitive, distinct source_ref, ordering, validation, additive schema).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:36:02 +02:00
07ab902604 feat(settings): back up the library DB + custom art in the export bundle (#639)
Closes the dev-ops lens's #1 finding from the library charrette
(got-feedback/feedBack#636 item 1): scores, favorites, playlists, and play
history — the only library state a rescan can't rebuild — were absent from
the settings backup. Now GET /api/settings/export carries an additive
`core_server_files` section:

- a CONSISTENT snapshot of web_library.db via the SQLite online-backup API
  (a complete single file even while the server runs; taken under the
  MetadataDB write lock), base64-encoded;
- custom playlist covers + avatar (CONFIG_DIR/playlist_covers, /avatars),
  walked with the existing _walk_export_paths machinery.

Restore is DB-safe:
- POST /api/settings/import STAGES the DB to web_library.db.restore (never
  over the live, open file); _apply_pending_db_restore swaps it in at the
  next startup BEFORE the connection opens, clearing stale -wal/-shm so a
  stale WAL can't be replayed onto the restored file. Response sets
  `restart_required` + a warning; custom art applies immediately.
- The staged DB is integrity-checked (open + PRAGMA quick_check) at import
  AND again at startup before the live DB is touched — a corrupt/truncated
  restore is refused/discarded and the live DB is left intact, so a bad
  bundle can never brick startup or lose data.
- Export hard-fails (500) if the snapshot can't be produced (no silent
  DB-less backup); a partial import disarms its own staged restore.

Backward-compatible: older servers ignore the new section; a bundle without
it imports as before. Known gap: custom uploaded *song* art is still
commingled with the rebuildable thumbnail cache in art_cache/, so it isn't
bundled yet (tracked follow-up on #636).

Tests: tests/test_settings_export_library_db.py (snapshot consistency,
staged-not-live restore, sidecar clearing, corrupt-DB refusal at import +
startard, traversal rejection, export hard-fail, disarm-on-failure, full
round-trip).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:34:47 +02:00
6a6efc793a feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf (#635)
* feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf

The Songs page opened cold into a flat sorted grid. This adds a practice-aware
front door on the unfiltered grid, built entirely from data already on hand
(no new endpoints, no new stored state):

- Repertoire meter — "Repertoire: N of M songs · K in progress" + a bar,
  counting songs at/above the same mastery threshold the green accuracy badge
  uses (>= 0.9 best accuracy) over the unfiltered library total. Reads
  state.accuracy (/api/stats/best, already loaded for the card badges) and the
  unfiltered /api/library/stats total.
- "Keep practicing" shelf — a horizontal row of recently-played, not-yet-
  mastered songs (newest first, click to play). Reads /api/stats/recent.

Both show ONLY on the grid view when not searching/filtering/selecting (the
front-door context), refresh after a song is scored (applyScoreRefresh), and
collapse on an empty library. Soft-gamification only: descriptive encouragement
(goal-gradient / endowed-progress), never content-gating, decay, or nagging —
the practice-accuracy "continue" rail a media server can't do.

Frontend-only: static/v3/songs.js (renderLibraryHome / _repertoireCounts /
libHomeVisible, wired through reload() + applyScoreRefresh), static/v3/v3.css.
Came out of the library design charrette (UX + gamification lenses' top pick).

Stacked on the A–Z rail branch (feat/v3-library-az-rail) since both touch
static/v3/songs.js; merge that PR first (or retarget).

Tests: tests/js/v3_keep_practicing.test.js (threshold, front-door gating,
shelf filter, denominator, render/reload/score-refresh wiring, click-to-play).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3 library): correct practice-aware home for review P1/P2/P3

Addresses the PR #635 review findings (manual + Codex):

P1 correctness
- Gate the Repertoire meter + "Keep practicing" shelf to the LOCAL
  provider (libHomeVisible). They read local practice stats
  (state.accuracy / /api/stats/recent); on a remote provider they mixed a
  local mastered count with a remote song total (e.g. "85 of 80") and the
  shelf played local files while browsing a remote library.
- Shelf now gates on the per-SONG best (state.accuracy[filename] = MAX
  across arrangements, what the green badge shows) and dedupes by filename,
  instead of the per-arrangement recents row — so a "keep practicing" card
  can no longer show a green "mastered" badge, and a song can't appear twice.

P2 robustness
- renderLibraryHome fetches /api/library/stats + /api/stats/recent together
  (Promise.all) and a _homeToken generation guard discards a stale render
  so a slow response can't repaint a home the grid already moved past.

P3 polish
- accuracyBadge references MASTERY_ACCURACY instead of a bare 0.9, so the
  badge and the meter/shelf can't drift from "the same mastery threshold".

Tests updated (v3_keep_practicing.test.js): provider gating, per-song
deduped shelf, Promise.all + token.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 08:56:31 +02:00
6a71577e05 feat(v3 library): A–Z fast-scroll jump rail on the Songs grid (#634)
* feat(v3 library): A–Z fast-scroll jump rail on the Songs grid

Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the
right edge next to the scrollbar so you can jump the library to a starting
letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between
letters. The classic (v2) tree already had letter selection; this brings the
new v3 grid to parity (it was the gap behind the "alphabetical scroll
selection next to the scrollbar" idea).

It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only
offers letters present in the current sort AND filter set, so a tap always
lands on a real card (absent letters are dimmed + non-interactive). The grid
is forward-only, server-paged infinite scroll with no virtualization, so a
jump pages through to the target card then scrolls to it; a token guards
overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized
window is the scaling follow-up for very large libraries.

Backend: /api/library/stats gains an optional `sort` param and an additive
`sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist
or title), filter-synced — so the rail's present-letters match the grid's real
order. The legacy `letters` (distinct-artist) field is unchanged, so the
dashboard + classic tree are unaffected. `sort` is dropped for providers whose
query_stats predates it (existing kwarg-filter), so third-party library
providers keep working (rail simply falls back / hides).

Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag +
keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble).

Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs-
distinct-artist counting), tests/test_library_providers.py (sort forwarded),
tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3 library): harden A–Z jump rail (review P2/P3)

Addresses the PR #634 review findings (manual + Codex):

P2 correctness
- refreshRail prefers the active-sort `sort_letters`; falls back to the
  artist-based `letters` only on an artist sort, and hides the rail on a
  title sort when a legacy provider returns none (was mislabeling letters).
- reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a
  grid that's being rebuilt from page 0.
- songBucket no longer trims, matching the server SQL + grid ORDER BY raw
  first-char bucketing (a leading-space title now buckets under '#' on both
  sides).

P3 polish
- Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a
  magic 4000, keeping large libraries reachable while still bounded.
- Roving tabindex: only the first present letter is tabbable; arrow keys
  move it. Removes up to 27 page tab stops.
- `sort_letters` is computed only when the caller opts in
  (want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree
  skip the extra GROUP BY. Added sort + want_sort_letters to the optional
  provider-kwargs so non-introspectable legacy providers drop them.
- _railToken supersedes stale refreshRail responses; hide the rail when no
  letters are present instead of rendering disabled buttons.

Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 08:49:01 +02:00
b29bab1884 fix(highway_3d): keep the FPS counter from hiding behind the v3 "Up Next" pill (#630)
The on-highway FPS readout (Settings -> Graphics -> 3D Highway -> Show FPS
counter) is pinned to the top-right of the highway overlay -- the same
corner the v3 player chrome stacks its persistent Up Next pill and
live-performance HUD into, on a higher layer that paints over the canvas.
So the readout sat behind that chrome and couldn't be read, exactly when a
tester turned it on to judge performance (and because the pill is default-on
it covered the counter regardless of the separate "Up Next won't turn off"
report).

Keep it top-right (where testers look) but drop it just below whichever of
that chrome is showing: measure the lowest visible top-right v3 HUD element
(#v3-upnext / #v3-live-performance-hud / #hud-time) and floor the FPS box's
Y beneath it. Element refs are resolved once and cached (no per-frame
querySelector, per the plugin perf rules) and only read while the counter is
actually drawn; gated on window.feedBack.uiVersion === 'v3' so classic v2 is
byte-for-byte unaffected. Bump plugin version 3.30.0 -> 3.30.1 (the screen.js
cache-buster).


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:50:36 +02:00
8a2175aa1c feat(onboarding): amp-sim opt-in choice + use_amp_sims setting (#631)
Second half of feedBack-desktop#46. The desktop app monitors through an
in-app amp-sim/tone chain that, once loaded, auto-restores every launch —
an idle high-gain amp on the input is a constant distorted buzz, and the
dry-only monitor mute can't silence it. This adds the "own-rig first"
opt-in so players using their own external amp/rig never get a processed
monitor in the first place.

Core changes:
- New `use_amp_sims` setting (default OFF / own-rig first): GET default,
  POST boolean validation, and resettable key — mirroring achievements_enabled.
- Onboarding wizard: a DESKTOP-ONLY step ("How do you want to hear
  yourself?") between instrument paths and the calibration challenge. The
  web build has no native amp sims, so the step is skipped there (5 steps
  on web, 6 on desktop) — gated on window.feedBackDesktop, dot count and
  setStep bounds are derived from it. Ticking "Use in-app amp simulations"
  POSTs use_amp_sims; default unticked.

The desktop renderer consumes this setting to gate its saved-tone-chain
restore (feedback-desktop PR, paired).

Verified by booting core locally and walking the wizard with Playwright:
web shows 5 dots/no amp step, desktop shows 6 dots, the amp step is
reachable, calibration stays the final "Play it now" step, ticking the box
persists use_amp_sims=true, and there are no page errors. Server-side
GET default / POST validation / reset confirmed via curl.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:13:28 +02:00
Byron GamatosandGitHub 5a0b62599d feat(highway): show feedpak author/editor credits on song load (#629)
Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start.

Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end.
2026-06-28 22:08:44 +02:00
271fedda55 fix(input_setup): stop collapsing audio driver-type variants in the wizard (#627)
The onboarding audio picker de-duped the device list by display LABEL. On
Windows the engine enumerates one interface once per host API (ASIO /
Windows Audio / DirectSound) with the same name, so the variants collapsed
to a single choice — silently keeping whichever sorted first, often not the
low-latency ASIO one the player wants. It could also drop the variant that
was actually `selected`.

The audio-input capability already collapses true duplicates by
logicalSourceKey (_visibleInputSources), and these variants each have a
DISTINCT key, so the wizard's extra label-collapse was redundant for real
dupes and destructive for the variants. Removed it; the picker now lists
every selectable input.

Pairs with feedBack-desktop's change to tag each source label with its
driver type ("Focusrite (ASIO)" vs "(Windows Audio)") so the now-distinct
entries are legible.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:25:49 +02:00
90fb2ee3bc feat(v3): content-dependent playlist covers + custom art (#626)
* feat(v3): content-dependent playlist covers + custom art upload

Playlist cards were a tiny 🎵 emoji on an empty square. Now the cover reflects
the playlist's contents, and you can override it with a custom image.

Cover (in priority order):
- custom uploaded cover, else
- empty playlist  -> the icon
- a few songs     -> the first song's album art
- 4+ songs        -> a 2x2 album-art mosaic

Backend (server.py):
- MetadataDB.list_playlists() returns each playlist's first few still-present
  songs' art URLs (`art_urls`) for the content cover.
- GET /api/playlists and GET /api/playlists/{id} add `cover_url` when a custom
  cover exists.
- POST/GET/DELETE /api/playlists/{id}/cover — store a small PNG thumbnail under
  CONFIG_DIR/playlist_covers/ (PIL-converted, mirroring song-art upload); the
  cover is deleted with the playlist. Cover mutators added to _MUTATING_ROUTES.

Frontend (static/v3/playlists.js): playlistCoverHtml(p) renders the rules above;
the playlist detail view gets "Cover" (pick an image) + "Remove cover".

Tests: tests/test_playlists_api.py (art_urls + cover roundtrip / reject-non-image
/ delete-removes-cover — 11 pass) and tests/js/v3_playlist_cover.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(playlists): 400 (not 500) on non-string cover image + bust same-second cover cache

Two review follow-ups on the playlist-cover endpoints:

- POST /cover did `if "," in b64` before any type check, so a non-string
  image (e.g. {"image": 123} / null) raised TypeError -> 500. Guard with
  isinstance (mirrors the avatar/song-art upload) for a clean 400. +regression
  test covering number/null/object/list.

- The cover URL busted only on int(st_mtime) (1s granularity) and GET /cover
  sent no cache headers, so a same-second replace/remove/re-upload could serve
  a stale image. Use st_mtime_ns in the cache-bust token and add the shared
  no-cache header (_ART_CACHE_HEADERS), matching song art.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 14:10:15 +02:00
3d97c07b2b feat(v3): add "Add to playlist" to a song's ⋮ More menu (#625)
* feat(v3): add "Add to playlist" to a song's ⋮ More menu

You could only add a song to a playlist via select-mode (checkbox → batch bar).
Add an "Add to playlist" row to each song card's ⋮ overflow menu that targets
that one song, reusing the same picker (pick a listed number or type a new name
to create the playlist).

The select-mode batch flow and the single-song menu now share one extracted
`addFilenamesToPlaylist(filenames)` helper; the menu is `openCardMenu`, shared by
grid cards and tree rows, so both views get it. Tests:
tests/js/v3_add_to_playlist_menu.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): don't clear the batch selection when the playlist picker is cancelled

The extract-helper refactor made batchAddToPlaylist() call finishBatch()
unconditionally, so cancelling (or a failed create) cleared the multi-select
and reloaded the grid — a regression from the original early-return-on-cancel
behaviour. addFilenamesToPlaylist() already returns null on cancel/failure;
gate finishBatch() on a truthy playlist id so the selection is preserved for
a retry. Adds a regression assertion.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:58:14 +02:00
b103a722ce fix(v3): refresh Songs grid after a Settings rescan / DLC-folder change (#624)
Reported on macOS: on a fresh install, pointing at a DLC folder in Settings and
running a scan showed NO songs until an app restart. The scan itself was fine —
_background_scan re-reads config.json fresh, so it scans the new folder and
populates the library — but the v3 Songs grid never reloaded.

The Settings Rescan / Full Rescan handlers only refreshed the classic (v2)
library via loadLibrary(); the v3 grid (static/v3/songs.js) had no listener for
a scan it didn't initiate (only its own upload path self-refreshes via
watchUploadScan). So its cached, pre-DLC (empty) DOM/snapshot survived a sidebar
return until a full reload (restart).

Fix: the rescan handlers now emit `library:changed` (static/app.js). The v3 grid
listens and reloads if it's the active screen, else sets `_libraryDirty` so the
next onV3SongsScreenEnter does a full re-fetch — a short-circuit placed ahead of
every cached-DOM fast-path so it can't restore the stale grid.

Tests: tests/js/v3_library_refresh.test.js guards the emit + the reload/dirty
wiring (DOM/event glue isn't headlessly unit-testable; end-to-end wants an
in-app run of the reporter's flow: set DLC in Settings → scan → Songs populate
without restart).


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:52:48 +02:00
d841813e0b fix(library): Edit Metadata modal — editable Year + don't close on drag-release outside (#623)
* fix(library): Edit Metadata modal — editable Year + no close on drag-release

Two fixes to the Songs -> Edit Metadata modal (openEditModal/saveEditModal in
static/app.js), both reported on macOS for 0.3.0.

1) Year is now editable. A year can be set when authoring a pak but the modal
   had no Year field, so it could never be changed. The backend
   (POST /api/song/<f>/meta) already accepts + normalizes `year` and writes it
   into the file via songmeta (survives a rescan) -- only the UI omitted it.
   Add a Year input (populated from the song's current year) and include
   `year` in the save POST body. Both the v3 card menu and the legacy edit
   button already pass the year through, so both surfaces get the field.

2) The modal no longer closes when a click-drag is released on the backdrop.
   Selecting text inside a field and releasing the mouse past the modal edge
   dismissed the form without warning (the `click` event's target resolves to
   the backdrop, the common ancestor) -- discarding the edit. Backdrop
   dismissal now also requires the mousedown to have STARTED on the backdrop,
   tracked per-modal and decided by a new pure helper
   _editModalShouldClose(clickTarget, modalEl, downOnBackdrop). Cancel / X
   still close on a normal click.

Tests: tests/js/edit_metadata_modal.test.js extracts the real functions from
app.js and asserts (a) openEditModal renders #edit-year, (b) saveEditModal's
meta POST body carries `year`, and (c) the backdrop-close decision table
(Cancel always closes; backdrop needs down+up on the backdrop; a drag from a
field released on the backdrop does NOT close).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(library): wire Edit Metadata Save via listener, not an inline onclick

encodeURIComponent does not escape "'", so embedding the filename in the
single-quoted inline onclick="saveEditModal('…')" handler produced a
malformed handler for any song whose filename contains an apostrophe
(e.g. Bob's Song.sloppak) — clicking Save threw a syntax error and the
edit silently failed. Replace the inline onclick with a data-edit-save
hook wired in JS from the closure filename (mirrors the existing Delete
button pattern), so the filename never has to survive attribute-string
embedding. Pre-existing bug surfaced during review of this modal.

Adds a regression assertion (no inline saveEditModal onclick; Save wired
via data-edit-save).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:42:02 +02:00
a0f5435854 perf(highway): stop the adaptive renderScale from visibly hunting up/down (#622)
Alpha testers reported the 3D-highway "quality going up and down to try to
compensate" as passages got busier (#618 charrette). That's core's
load-adaptive render scale (_adaptRenderScale, #654) ping-ponging across the
7-12ms deadband: it downscales when a busy frame blows the budget, then the
now-cheaper frame dips under the low watermark so it upscales, which blows the
budget again — a visible resolution pop on a loop.

Fix: keep downscaling prompt (protect the frame rate), but make UPSCALING lazy
and predictive:
- smaller up-step (x1.06 vs x1.1) on a longer, separate cooldown
  (_AUTO_UPSCALE_COOLDOWN_MS = 2500ms vs the 600ms general adjust cooldown),
  reset on any downscale so we never bounce straight back up;
- a predictive guard: only upscale when the projected cost AFTER the step
  (~cost * step^2, since draw cost tracks pixel count) still clears the high
  budget. The scale settles just inside the deadband instead of oscillating.

No public API change; the user-facing "Min res" floor (_autoScaleMin) is
untouched. Pairs with the in-plugin AA-under-bloom fix in feedBack#618.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:05:34 +02:00
2a43d5b494 fix(diagnostics): rebuild diagnostic sloppak so song name reads "FeedBack", not "Slopsmith" (#621)
PR #586 renamed the bundled diagnostic to
docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak but only git-mv'd
the file -- it never regenerated the zip. So the manifest INSIDE still
carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith`
(and the same heading in DIAGNOSTIC.md), which is the name testers saw in
the library/player and the onboarding calibration step. Meanwhile the build
script, server `_BUILTIN_DIAGNOSTIC_SOURCES`, README, and docs all already
say "FeedBack Diagnostic — Basic Guitar" -- only the committed binary was
stale.

Regenerate the artifact from its own generator
(docs/diagnostics/build_diagnostic_basic_guitar.py) so the committed sloppak
matches the source of truth: title/artist/heading now "FeedBack"; the chart
(5 notes / 7 chords / 5 sections), the click-track stem, and the
`diagnostic:` metadata block are unchanged. Verified the rebuilt manifest
parses, carries a real U+2014 em-dash, and contains no "Slopsmith".

No code change -- the #586 rename just needed the rebuild it skipped.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:47:12 +02:00
ee7bafbb47 fix(v3): decode stats:recorded filename so post-play score badge refreshes (#620)
PR #574 added a `stats:recorded` -> in-place accuracy-badge repaint so a
just-earned score shows without restarting the app. But the repaint never
matched a card, so the badge stayed stale until a full render() (app
restart / search / re-enter the screen) -- exactly the "only updates after
a restart" report.

Root cause is a filename key-space mismatch. The event (like song:loading)
carries the filename `encodeURIComponent`'d, because that is what playCard
hands to playSong (the highway WS decodeURIComponent's it). Library cards,
though, key on the DECODED localFilename (data-fn), and /api/stats/best is
server-canonicalized to that same decoded key (server.py
_canonical_song_filename). So repaintAccuracy's `data-fn !== key` check
rejected every card and `state.accuracy[encoded]` was undefined.

Decode the event filename back into the card / state.accuracy key space via
a small `decFn` helper before marking dirty and repainting, fixing both the
immediate repaint and the onV3SongsScreenEnter deferred path. decFn is
idempotent for already-decoded names and falls back to the original on
malformed input, so a real filename containing a literal '%' is never
corrupted.

Tests: tests/js/v3_songs_score_badge_refresh.test.js extracts the real
decFn from the shipped source and proves the encoded event filename
round-trips to the raw card key (incl. spaces and subfolder '/').


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:39:13 +02:00
fef870047b fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619)
* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit

Escape didn't always leave a song: clicking a transport control (play/FF/RW/
restart) left that <button> focused, and _shortcutDispatchBlocked() bails the
shortcut dispatcher for any focused INPUT/SELECT/TEXTAREA/BUTTON — so the
player-scope Escape=Back shortcut never fired until the user clicked empty
canvas to blur the control. Space already had a player-screen carve-out (#593);
Escape did not. That asymmetry was the bug.

Phase 1 — focus fix: generalize the Space carve-out in _shortcutDispatchBlocked
to Escape, on the player AND settings screens (both register Escape=Back;
settings had the identical latent bug). The earlier guards still win: text
inputs are exempted first, the Section Practice popover already claims Escape,
and a true modal (role=dialog aria-modal=true / .feedBack-modal) still traps it.
Plugins' player-scope Escape shortcuts are fixed identically.

Phase 2 — resume: leaving the player snapshots {song, arrangement, position,
speed} to localStorage; a non-blocking "Resume practice" pill offers it back on
the next non-player screen / next launch. playSong() gains a {resume} option
that restores speed + seeks to the saved position on song:ready instead of the
normal autostart. Conservative (ignores <3s / near-end), cleared on natural
song-end and once consumed, expires after 24h.

Phase 3 — opt-in "Ask before leaving a song" (Gameplay tab, default OFF). A
true-modal confirm with monotonic Escape (the second Escape leaves) and
Space/Enter = Leave. The player Escape shortcut and the v3 close button route
through window.requestExitSong(); auto-exit on song-end and a results screen's
own Close stay unguarded.

Design rationale: a multi-seat design charrette (engagement, learning-design,
operability, codebase-reality) — leaving a song should be reliable and
recoverable, not gated; the confirm is opt-in only.

Tests: tests/browser/{keyboard-shortcuts,resume-session,exit-confirm}.spec.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* test(browser): suppress first-run onboarding in keyboard/resume/exit specs

The first-run onboarding overlay (#v3-onboarding) is a modal that intercepts
pointer/keyboard events; on a fresh profile it covers the player and breaks any
test that presses Escape or clicks. Stub GET /api/profile to an onboarded
profile in each beforeEach so the app behaves like a returning user (the state
these tests assume).

Also tighten the Section Practice Escape test to assert the guarantee the fix
actually provides — Escape does not exit the song while the popover is open (the
line-447 guard wins over the carve-out) — rather than asserting the popover's
own close handler fires, which isn't wired for a synthetic bar.

Verified locally against a worktree server (Chromium): all 16 new specs pass
(5 Escape + 6 resume + 5 exit-confirm) plus the existing #593 Space tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): exit-confirm — Escape cancels back to song, pause on open/resume on stay

Refinements from tester feedback on the exit-confirm (default stays OFF):

- Escape on the open prompt now = Stay (dismiss + return to the song), matching
  every other modal and the generic _confirmDialog (Esc=cancel). A second
  Escape therefore returns to the song instead of leaving it. Leaving stays the
  explicit, default-focused "Leave" button, so Space/Enter/click = "just get me
  out" (the OP's "Space always hits leave").
- Opening the prompt PAUSES the song (via the canonical togglePlay path, HTML5
  + _juceMode) so it isn't running/being scored behind the modal; Stay resumes
  exactly what we paused. Guards: cancel any count-in on open; resume only if we
  paused (wasPlaying), only if still the same live song on the player
  (_audioSeekGen unchanged), and never auto-resume a song the user had paused.
- Trap Tab inside the dialog; backdrop click was already Stay.

Specs: exit-confirm.spec.ts updated — the monotonic "second Escape leaves" test
becomes "second Escape stays", plus a backdrop-click-stays test. The audio
pause/resume itself is verified manually on web + desktop (the mock song has no
backing track); these specs lock the navigation + keyboard semantics.

NOTE: the pause/resume adds a new pause→resume cycle on the desktop JUCE
transport (known play/pause-desync path) — smoke-test on the desktop build
before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): accurate exit-confirm copy + keep resume snapshot on failed load

Two review follow-ups on the Escape/resume/confirm work:

- Settings copy said "a second Escape (or Space/Enter) still leaves",
  but Escape dismisses the confirm (Stay) like every other modal — only
  Space/Enter/Leave exit. Corrected the Gameplay-tab description so it
  matches the implementation (and the committed exit-confirm specs).

- resumeLastSession() cleared the snapshot BEFORE awaiting playSong(), so
  a transient load/connect failure permanently lost the Resume pill with
  no retry. Clear only after the load resolves; on failure keep the
  snapshot (and drop the pending in-memory resume) so the pill re-offers
  it on the next non-player screen.

All 16 Escape/resume/exit-confirm Playwright specs still pass.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:32:33 +02:00
290783b80b feat(highway_3d): hit-feedback juice + Hit-sparks toggle (#618)
* feat(highway_3d): hit-feedback juice — cinematic lighting, strike line, sparks, intensity dial

Charrette wave 1 (additive, default-tasteful, all behind settings):
- #8 Hit-feedback settings: hitFx (0..1), cinematic, verdictMarks, timingFx,
  streakFx in BG_DEFAULTS + h3dBgSet* setters + settings.html (intensity slider +
  cinematic toggle). hitFx=0 → colour verdict only.
- #2 Cinematic lighting: ambient 0.85→0.35 + stronger key light when cinematic on,
  so emissive gems have a dark surround to pop against. Live-toggleable.
- #1 Strike line: a glowing bar at the hit line (Z=0) that flashes green on a
  verified hit / red on a miss, eased from the per-frame verdict alpha.
- #3 Hit sparks: a pooled additive Points burst at the gem on a verified hit
  (deduped one burst per note), scaled by hitFx; disposed on teardown.

Staged for wave 2 (after dogfooding): bloom+ACES (#4), colorblind verdict glyphs
(#6), early/late timing tint (#5), streak heat + clean-bar (#7), gem scale-punch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): wave 2 — gem scale-punch, streak heat, colorblind verdict marks

- #3 (completion) gem scale-punch: the hit gem briefly grows (1 + 0.22·hitFx·alpha),
  biggest at the strike and easing with the verdict — the per-gem impulse.
- #7 streak heat: a renderer-side consecutive-hit counter eases a 0..1 "heat"
  (plateau at 16) that grows the spark burst + warms the strike-line idle glow;
  a miss eases it back down. Behind the Streak-feedback toggle.
- #6 colorblind verdict marks: a redundant ✓ (hit) / ✗ (miss) glyph on the verdict
  via the existing 2D label overlay, so the green/red pair isn't the only signal —
  notably also covers the provider path (where the timing labels don't show).
- settings.html: Streak-feedback + Accessible-marks toggles.

Deferred: #4 bloom+ACES (needs the Three.js postprocessing addons vendored into
core static/vendor/three/ — not present; warrants its own infra change), and #5's
timing tint (the early/late ±ms labels already render on the event path; surfacing
them on the provider path needs a notedetect verdict field — a cross-plugin item).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): #4 bloom + ACES — vendored Three.js postprocessing, perf-gated

The single biggest fidelity lever from the charrette. Core had only
three.module.min.js (no postprocessing addons), so this vendors the r170
EffectComposer/RenderPass/UnrealBloomPass/OutputPass + their shader deps into
static/vendor/three/addons/, with every `from 'three'` rewritten to the SAME
vendored three (../../three.module.min.js) so the addons share the plugin's
three instance (a CDN copy would be a second, non-interoperable module).

highway_3d wiring:
- Lazy-loads the addons only when the new `bloom` setting is on (dynamic import),
  builds EffectComposer(RenderPass → UnrealBloomPass(strength .65/radius .5/
  threshold .82 — high so only emissive gems + the hit flash bloom) → OutputPass).
- Render loop uses composer.render() with ACES tone-mapping when bloom is active,
  else the unchanged direct ren.render() with NoToneMapping (bloom-off = today's look).
- Perf-gated: OFF in splitscreen; graceful fallback to direct render if the modules
  or composer fail; composer.setSize on canvas resize; disposed on teardown.
- settings.html: "Glow bloom" toggle (default on).

Verified the import chain resolves + renders via a same-origin module-load test
(EffectComposer built + a bloom frame rendered, three r170).

Charrette status: 7/8 (only #5's early/late timing tint remains — a notedetect
verdict-field change, outside the highway).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): #5 early/late timing — colour the hit feedback by timing

Surfaces the detector's timing on every hit (the charrette's last item), fully
highway-side: notedetect already dispatches the judgment (timingState/timingError)
on notedetect:hit/miss, so we carry timingState onto the event mark and tint the
hit's spark burst + the ✓ verdict glyph by it — on-time green, early cyan, late
amber. Gracefully falls back to green when no timing is known (pure-provider path),
so it never invents data. Behind the new "Timing feedback" toggle (default on).

Charrette: 8/8 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): add a "Hit sparks" on/off toggle (note-hit particles)

The on-hit spark burst (the particle effect that fires the instant
note_detect confirms a hit) could previously only be removed by dragging
Hit-feedback intensity to 0 — which also kills the strike-line flash and
the scale-punch. Add a dedicated "Hit sparks" toggle (default on) under
3D Highway settings, in the hit-feedback group beside the intensity
slider, that gates ONLY the spark particles; the strike flash and colour
verdict are unaffected.

Wired the same way as the sibling juice toggles: a `sparks` boolean in
BG_DEFAULTS, in _BG_BOOL_KEYS, a window.h3dBgSetSparks setter, the
per-instance _sparks state + settings re-read, and a guard on the
_sparkBurst spawn. Reuses existing Tailwind utility classes, so
assets/plugin.css is unchanged; plugin.json version bumped to 3.28.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(highway_3d): act on tester charrette — strike line, fog readability, AA

Addresses the alpha-tester 3D-highway feedback thread via the design panel's
recommendations:

- Strike line (panel rec 1a): now a HIT-ONLY faint "now" line — flashes green
  on a confirmed hit, no red miss branch (misses already show at the gem: red
  wash + ✗). Moved off the bottom edge to the vertical CENTRE of the string
  field, which was the "incorrectly placed" complaint (it read as the board's
  lower border and fused with open-string gems on a miss). Added a "Strike
  line" on/off toggle (`strikeLine`, default on).

- Horizon readability (#2): the note gems + their outlines are now fog-exempt
  (`material.fog = false` on mStr/mGlow/mStrHitOutline/mHitBright/mWhiteOutline/
  mMissOutline), so upcoming notes punch through the distance fog and stay
  legible as they render in — the board, lane, sustains and scenery keep their
  atmospheric fog, so depth is preserved.

- Cinematic lighting softened: cinematic ambient 0.35 -> 0.45 so the dark stage
  doesn't crush note/fret legibility.

- Anti-aliasing under bloom (perf rec): give the bloom EffectComposer a
  multisampled (WebGL2 MSAA x4) HalfFloat render target. The default target had
  no `samples`, so bloom-on bypassed MSAA — the "too HD / jagged on Windows,
  fine on Mac" report (Mac only won via Retina supersampling). This is the
  highest-value, smallest fix for the jaggies.

plugin.json -> 3.29.0. The renderScale quality-oscillation is core
(static/highway.js) and will be a separate feedBack PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(highway_3d): remove the strike line; sparks-only hit feedback, subtler

Second tester-charrette pass. The strike line (even hit-only/centred from the
last pass) was still too distracting/confusing on a hit, so it's removed
entirely — strings + fret markers already orient the player, and the hit is
fully carried at the gem (bright outline + scale-punch + spark burst) with the
timing-coloured ✓/✗ verdict as the knowledge-of-results channel.

- Deleted the strike-line mesh, its per-frame update, the `strikeLine` setting
  (BG_DEFAULTS / _BG_BOOL_KEYS / setter / settings-load), the settings.html
  toggle, and the now-dead `_strikeLine`/`_ndHitFlash`/`_ndMissFlash` state +
  their verdict-block feeds.
- Made the spark burst subtler now that it's the sole celebration: point size
  1.7→1.0·K, opacity 0.95→0.8, burst count (7+13·hitFx)→(4+7·hitFx), radial
  speed (7+r·20)→(5+r·12)·K, life (0.40+r·0.28)→(0.30+r·0.16)s.
- Toggles for Hit sparks and the ✓/✗ verdict marks already exist in settings
  (kept).

Minimal hit-feedback set now: gem bright + subtle spark (celebration) +
timing-coloured ✓/✗ (the KR) + ambient streak heat. plugin.json -> 3.30.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(highway_3d): hydrate hit-feedback settings controls from saved state

The 7 new juice controls (Hit sparks, Cinematic, Streak, Verdict marks,
Bloom, Timing, Hit-feedback intensity) were hard-coded to their default
markup and never read back from localStorage when the settings panel
reopened — so a saved non-default (e.g. Hit sparks off) showed as the
default (checked) even though the renderer correctly honored it. The
sibling controls in the same panel were already hydrated; this restores
that pattern for the new ones.

Reads h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' coercion.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 11:53:48 +02:00
b206633131 fix(v3): keep Section Map's leftmost section clickable under the rail catcher (#617)
The section_map plugin pins a ~20px clickable bar (#section-map, z-index:5)
to the top of #player. The v3 left-rail hover-catcher (.v3-railzone::before)
is full-height at z-index:30 with pointer-events:auto, so its top-left
corner swallowed every click on the section map's first section — the
left-most section was never clickable on the v3 desktop (macOS/Windows) UI.

Drop the catcher below the 20px bar when the section map is present,
mirroring the existing #section-map ~ #player-hud special-case in
static/style.css. The rail still reveals from anywhere below the bar.

Adds a Playwright regression test (hit-test of the top-left corner) with a
negative control that re-raises the catcher to reproduce the bug.

Fixes #616

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:07:44 +02:00
a57d0e3f85 fix(v3): replace broken window.prompt() in Playlists with in-app uiPrompt modal (#614)
window.prompt() is a silent no-op in the Electron desktop shell, so the
Playlists "New Playlist" and "Rename" buttons and the library's bulk
"add selected songs to a playlist" action did nothing. Route all three
through the existing window.uiPrompt() modal (resolves to the string, or
null on cancel; the handlers were already async). window.confirm() works
in Electron and is left as-is.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:35:30 +02:00
4480ac2732 feat(v3): promote Audio Engine to a first-class sidebar entry (after Settings) (#613)
The desktop Audio Engine plugin (input device selection, VST hosting, pitch
detection, and the new config Reset/repair UI) was reachable only via the
generic Plugins gallery — per-plugin manifest nav entries aren't surfaced in
the v3 sidebar unless the plugin is promoted. Add it to PROMOTED_PLUGINS
anchored after Settings, plus the matching NAV registry entry so the slot
resolves its label/screen. Desktop-only by construction: the slot is filled
only when /api/plugins reports audio_engine installed, so the web app shows
no dead entry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:16:36 +02:00
3b2d83d406 feat(folder_library): Folder Library core plugin (#610)
Adds the bundled Folder Library plugin (browse the DLC library by its on-disk
folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter,
live search), wired into the classic v2 toolbar and the v3 Songs page.

Includes the screen.js IIFE dedup (unified surface factory) and review fixes:
path-traversal guard on /song/move, folder-delete data-loss fix, plural
/api/plugins/<id> namespace, loose-folder song recognition, error-text escaping,
v3 setLibView null-guard, and tests.

Co-authored-by: Kyle <kyle.j.t@live.co.uk>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:03:54 +02:00
d1f7f12293 fix(v3): add "Show 'Up Next'" toggle so the player pill can be turned off (#612)
The v0.3.0 player chrome's persistent upcoming-section pill (#v3-upnext,
drawn by static/v3/player-chrome.js's updateUpNext) shipped with no off
switch: it always showed during playback whenever a section was upcoming,
overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up
Next' section card" checkbox (a different, in-canvas widget demoted to
default-off precisely because this pill is the canonical readout). Users
reading the pill as that same setting saw "disabled in settings but still
there."

Add a real core toggle, following the autoplayExit idiom:
- static/app.js: client-only `showUpNext` localStorage pref (absence =
  enabled), _showUpNextEnabled()/setShowUpNext(), loadSettings()
  hydration, and a read-only window.feedBack.showUpNext getter. Disabling
  mid-playback hides the pill immediately.
- static/v3/index.html: a "Show 'Up Next'" switch in the Gameplay tab.
- static/v3/player-chrome.js: gate updateUpNext() on the pref.
- static/v3/settings.js: add showUpNext to RESET_MAP.gameplay.local.

Default ON, so behaviour is unchanged for existing users. v3-only (the
pill is v3 core chrome); no Tailwind rebuild.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:16:31 +02:00
6dbcc5861b fix(player): keep play/pause button in sync when a JUCE reroute aborts autoplay's play() (#611)
On the first song after a fresh load on desktop, the audio engine is often
still starting when the song loads, so the song begins on the HTML5 <audio>
element and the engine-reroute watcher then migrates it to the JUCE backing
transport. The reroute's first step is a deliberate audio.pause(), which
rejects autoplay's in-flight togglePlay() audio.play() with an AbortError —
even though playback continues on JUCE.

togglePlay()'s catch then reset isPlaying=false and the button to "Play"
while the song kept playing: the button showed Play during playback, so it
took two clicks to actually pause (one to resync the flag, one to pause).
The reroute already guards the <audio> 'play'/'pause' DOM listeners with
window._juceRerouteInProgress; this extends the same guard to togglePlay()'s
catch and the count-in catch, so a play() rejection caused by the reroute's
own pause doesn't clobber the button. A genuine failure (outside a reroute)
still resets correctly.

Adds a regression test that drives togglePlay() through a reroute-aborted
play() and asserts the button stays Pause; it fails without the guard.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:16:28 +02:00
75 changed files with 11063 additions and 258 deletions
+3
View File
@@ -27,6 +27,9 @@ plugins/achievements/__pycache__/
!plugins/highway_3d/
!plugins/highway_3d/**
plugins/highway_3d/__pycache__/
!plugins/folder_library/
!plugins/folder_library/**
plugins/folder_library/__pycache__/
!plugins/app_tour_library/
!plugins/app_tour_library/**
!plugins/app_tour_settings/
+25 -10
View File
@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
- **AZ fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx``dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable).
@@ -23,16 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`renderPromotedNav` checks `/api/plugins`), so it appears only when the
editor is loaded. The displayed label comes from the plugin's manifest
`nav.label`.
- **"Audio Engine" promoted to a first-class v3 sidebar item.** The bundled
desktop audio plugin (`id: audio_engine` — native audio I/O, VST hosting,
amp modeling, pitch detection) now gets its own sidebar entry in the **HOME**
group, immediately below **Settings**, via the existing `PROMOTED_PLUGINS`
mechanism in `static/v3/shell.js` (new `audio` waveform icon), instead of
being reachable only through the generic Plugins gallery. Gated on the plugin
actually being installed (`renderPromotedNav` checks `/api/plugins`), so it
appears only in desktop builds where the plugin is bundled. The displayed
label comes from the plugin's manifest `nav.label` (`"Audio"`). Test:
`tests/browser/audio-engine-nav.spec.ts`.
- **Guitar Pro → notation importer (`lib/gp2notation.py`)** (feedBack#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `<stem>.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_<id>.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for compound/irregular meters (6/8 → `[3,3]`, 9/8 → `[3,3,3]`, 5/8 → `[2,3]`, 7/8 → `[2,2,3]` — cf. the feedBack#261 denominator pitfalls); beats carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (bar-indexed tempo map, per-beat rhythm durations, `_note_midi`) so notation lines up with the RS XML the highway plays — with one deliberate divergence: double dots advance time ×1.75 (vs. the RS-XML walk's single-dot ×1.5 approximation) so a written `dot: 2` agrees with the emitted beat times; sharing the walk itself stays tracked in feedBack#618. Tests: `tests/test_gp2notation.py`.
- **Legacy keys → notation lifter (`scripts/lift_keys_notation.py`)** (feedBack#825 WS4c, epic #828). One-time batch converter that lifts existing **directory-form** piano/keys sloppaks from the legacy guitar wire encoding (`midi = s*24 + f`) into real Sloppak Notation Format files (sloppak-spec §5.3). Candidates are arrangements whose name matches `\b(keys|piano|keyboard|synth)\b` (case-insensitive); each gets a `notation_<id>.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) when present, else the gap to the next onset in the same hand — quantized to the nearest plain/single-dotted `{1,2,4,8,16,32}` denominator at the local tempo, floored at a 32nd. Hands are split heuristically: onsets within 10 ms form a group; a group spanning > 12 semitones splits at its largest internal interval gap (low side → `lh`), otherwise the whole group goes by mean pitch vs middle C — single-staff output when everything lands on one hand. Idempotent (arrangements already carrying `notation:` are skipped; an orphan `notation_<id>.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before write. Honest caveat: the manifest is round-tripped through PyYAML (`safe_load` + `safe_dump(sort_keys=False)`) — key order survives, YAML comments/custom formatting do not (the script warns when comments are present). Zip-form `.sloppak` files are reported and skipped. Tests: `tests/test_lift_keys_notation.py`.
- **Notation schema v1 freeze — completeness batch** (feedBack#822, epic #828). Adds the low-hanging-fruit fields ahead of content production: top-level credits `rights`/`lyricist`/`arranger`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes**`grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `<pedal start|change|stop>` mapping — no separate `ped` field. A new "v1 non-features" spec subsection pins the accepted limitations (microtonal, figured bass, mid-measure key/time/clef changes, `ott`/`barline`/ornaments/`trem`/`glis`) as additive-v1.x territory. `lib/notation.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; the validator stays permissive by design.
@@ -45,6 +47,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where``query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards key on the **decoded** `localFilename` (`data-fn`), and `/api/stats/best` is server-canonicalized to that same decoded key (`server.py` `_canonical_song_filename`). So `repaintAccuracy`'s `data-fn !== key` check rejected every card and `state.accuracy[encoded]` was `undefined`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** The v0.3.0 player chrome's persistent upcoming-section pill (`#v3-upnext`, drawn by `static/v3/player-chrome.js`'s `updateUpNext()`) shipped with no off switch, so it always showed during playback whenever a section was upcoming — overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up Next' section card" checkbox (a *different*, in-canvas widget that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked``_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
@@ -59,6 +73,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, feedBack feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes feedBack-desktop#110.
- **Perf**: reduce per-frame allocations in the 2D highway chord + lyric render paths. `_ensureChordRenderCache` now also caches `sortedNotes` / `nonZeroNotes` / `nonZeroFrets` / `allMuted` / `hasMultipleNotes` (computed once per chord, invalidated on `src` / `_inverted` / `chordTemplates` change — the third key catches a stale `isOpen`-derived classification when the WS `chord_templates` message lands after the final `chords` chunk), so `drawChords` no longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (no `chordPositions.filter` × 2 per frame). `drawLyrics` memoizes `ctx.measureText` results in a two-level `Map<fontSize, Map<text, width>>` so cache hits don't allocate a composite string key. Lit-sustain shimmer in `drawSustains` swaps the 4 per-note-per-frame `Math.random()` calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic per `createHighway()` instance, since the seed includes that instance's `_frameIdx`) indexed by `(frameIdx + n.s + ⌊n.t·60⌋)`, visually indistinguishable and allocation-free.
- **Perf**: the load-adaptive render scale (`_adaptRenderScale`, #654) no longer visibly hunts up/down on passages that hover near the frame budget (testers saw "quality going up and down" with the 3D highway). Downscaling stays prompt to protect the frame rate, but **upscaling is now lazy**: a smaller step (×1.06 vs ×1.1) on a longer cooldown (`_AUTO_UPSCALE_COOLDOWN_MS` 2500 ms vs the 600 ms adjust cooldown), reset on any downscale, and gated by a predictive guard — it only upscales when the projected cost *after* the step (≈ cost × step², since draw cost tracks the pixel count) still clears the high budget. The scale therefore settles just inside the 712 ms deadband instead of oscillating across it. No new public API; the `_autoScaleMin` "Min res" floor is unchanged.
### Removed
- **`c` library hotkey ("Convert to .sloppak") removed from core.** Core hardcoded a plugin-specific shortcut: a documentation-only `registerShortcut({ key: 'c', scope: 'library' })` no-op plus a `c → button.sloppak-convert-btn` entry in the library keydown handler that fired the Sloppak Converter plugin's button. Per the plugins-own-their-behavior principle, core no longer ships this hotkey — the convert button still works by click, and the Sloppak Converter plugin can register its own `c` shortcut via `window.registerShortcut()` if keyboard access is wanted. The `f` (favorite) and `e` (edit) library hotkeys, which drive core buttons, are unchanged. Help-modal/registry tests in `tests/browser/keyboard-shortcuts.spec.ts` updated to drop the `c` assertions.
+1
View File
@@ -552,6 +552,7 @@ a local pointer + code map.
- **Storage** — `localStorage` for all user preferences
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
## Backend Conventions
-1
View File
@@ -32,7 +32,6 @@
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
| [Folder Organizer](https://github.com/Elit3d/slopsmith-plugin-folder-organizer) | Organize your sloppak DLC songs into a folder tree view, grouped by subfolder name | `git clone ...slopsmith-plugin-folder-organizer.git folder-organizer` |
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
+246
View File
@@ -0,0 +1,246 @@
# Host Theme Contract — design proposal
**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI
**Trigger:** a plugin UI feature accidentally "carved itself into a single theme."
## 1. Problem
A results-card feature in the `note_detect` plugin (a glow-ring hero button + a
gradient-filled accuracy number) was built and visually verified against **only the
default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately
glow-less, near-monochrome design language — the glow ring and the colour gradient
simply **vanished**. The colours adapted (everything used CSS custom-property tokens),
but the **visual devices themselves did not port**, because nothing in the system says
"this theme does / doesn't do glow rings."
### Root cause (three findings)
1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients;
esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels +
drop-shadows. Tokens made *colour* portable; they never made a *device* portable.
2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon
but a **hard drop-shadow** in metal — the metal skin is already repurposing a
device-named slot to express a different language. The cure is to finish that move:
name slots by intent, with "off" (`none`) a legal value.
3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places;
on esports' amber accent that's a contrast failure. And `--nd-accent2` was
**double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved
amber→near-white and washed out.
A process gap compounds it: **verification covered one skin**, so the regression was
invisible until a user switched themes. And this recurs ecosystem-wide — other plugins
ship their own independent skin systems too.
## 2. Current state (two disconnected systems)
| System | What it is | Limits |
| --- | --- | --- |
| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults***devices are an ownerless gap.** |
| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
## 3. Goals / non-goals
- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not
yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally.
- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing.
- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but
**implement** the contract.
- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
## 4. The contract — three layers
### Layer 1 — Semantic colour **roles** (always present)
The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
resolves — themed or not. Roles:
**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on
`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**.
The existing `--fbv-*` vars stay **internal plumbing**`theme-core.js` uses them only to
recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`;
they are **not** part of this contract and plugins must not read them. (Implementation may seed
`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.)
**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching
today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional
alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their
device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except**
where noted.
**Normative role tokens** (all `--fb-*`, all always present):
| Role | Token | Notes |
| --- | --- | --- |
| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural |
| text / dim | `--fb-text` `--fb-text-dim` | |
| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end |
| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both |
| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). |
| **focus (new)** | `--fb-focus-ring` | focus indicator independent of `accent`, so focus stays visible when `accent ≈ surface` |
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
A theme declares its design *language* by filling intent-named slots (all `--fb-*`-prefixed,
same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`,
`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` is legal.
Two **composite recipes** carry the load:
- **EMPHASIS** — how this theme makes a primary action special:
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of
fill/border/halo non-`none`), so a primary action is never visually flat.
- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill`
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
`background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible
even where clip-text is unsupported.
> These generalize the interim per-skin tokens already shipped in `note_detect`
> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`).
### Layer 3 — JS read API + reconciliation
**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved
token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to
draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of
booleans. On the existing `window.feedBack` bus:
- `feedBack.theme.get()``{ id, isThemed, tokens }` where `tokens` is the **resolved** map of
every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device,
e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean).
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
CSS-authored decorative motion.
- `theme:changed` event → `{ id, tokens }`.
**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously**
and is valid at any time — before any theme is applied it returns the default/unthemed roles
(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh);
`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial
hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()`
on mount, then subscribe to `theme:changed` — never assume an order between your mount and the
first theme apply.
**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin
**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and
**owns only its accent + its devices**, selecting the device via the recipe. A host theme then
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
never imposes a device the active theme neutralizes.
**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*`
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
renders into a **shadow root or iframe** is responsible for bridging — copy the resolved
`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't
cross those boundaries).
## 5. Consumption pattern (the rule for feature authors)
> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw
> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live
> in slots; the theme owns the slots.
```css
.hero-cta {
background: var(--fb-emph-fill);
border: var(--fb-emph-border);
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
color: var(--fb-emph-on); /* never hardcoded #fff again */
border-radius: var(--fb-corner-radius);
}
.accuracy-number {
/* Always-legible solid base; survives no-clip-text support too. */
color: rgb(var(--fb-accent));
}
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
invisible. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
.accuracy-number {
background: var(--fb-acc-text-fill);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
}
```
**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g.
`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the
always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a
Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the
`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps
runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`).
- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is
named; one central rule in the contract sheet sets it to `none` under
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
uses `feedBack.theme.prefersReducedMotion()` — §4.3.)
- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }`
for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
`v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked
cleanup — "one rule" describes the end state, not day one.
- **On-fill contrast:** every `--fb-on-*` is required and **lintable**
(`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`).
Contrast is the theme's job, computed once — not re-judged per feature.
## 7. Verification gate (prevent recurrence)
- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key
surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin ×
key states** (rest / hover / focus / reduced-motion).
- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis
present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring +
fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime +
a guard test so the matrix can't silently go stale.
**Definition-of-done for any theme-touching UI change** (the few items that would have caught this):
expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device*
stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast.
## 8. Back-compat & rollout
All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new
`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those
reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and
`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today.
**Workstream (sub-tasks):**
1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
## 9. Cross-apply status (already done)
- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens
(the Layer-2 prototype) and verified across neon/esports/metal.
- The **canvas share-image card** — re-checked across all three skins: **theme-robust**
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*.
## 10. Open questions
- Should plugin skins eventually become *selectable host themes* (one picker), or stay
plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
- Component-recipe **bundles** (per named component) are the richer end-state; intent-named
slots are the right seed. When/whether to graduate.
*(Resolved during review and folded into the sections above: the token namespace + value grammar
and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2);
JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
host-owned contract stylesheet outside Tailwind (§6).)*
+345
View File
@@ -0,0 +1,345 @@
# Folder Library — AI Agent Guide
A FeedBack (fee[dB]ack) plugin that adds a **Folders** nav screen showing your `.sloppak` / `.feedpak` DLC songs grouped by the folder tree on disk. Create, rename, and delete folders (including **nested subfolders**) directly in the UI, move songs by drag-and-drop, and browse with sort and metadata filters.
> The host app is **FeedBack** (formerly "Slopsmith"). The frontend talks to the host through `window.feedBack`; `window.slopsmith` is a back-compat alias the host still exposes (`window.slopsmith = window.feedBack` in `static/app.js`). New code should prefer `window.feedBack`.
> ⚠️ **Status — bundled core plugin.** This plugin began as a standalone plugin and is now a bundled core plugin. `screen.js` has been unified into a **single surface factory** driving two entry points: the v3 library Folder view (host chrome — host search `#v3-search`/`#lib-filter`, host filter params, renders into `#lib-folder-tree`) and the classic v2 standalone Folders nav-tab (its own `#fb-search` + toolbar, renders into `#fb-tree`). **Folder search works on both surfaces** — typing in the relevant search box re-renders the tree. **Loose-folder songs** (directories with audio + an arrangement XML) are recognised as songs via the host `loosefolder.is_loose_song` predicate, so they appear in the tree alongside `.sloppak`/`.feedpak` bundles. Folder management, nested subfolders, collapsible folders + expand/collapse-all, drag-and-drop, move-song, sort, filters, and the hover metadata badges are wired on both surfaces; verify against a running build before relying on any of it.
## File Structure
```
plugin.json Plugin manifest — id, name, nav entry, file declarations ("bundled": true core plugin)
routes.py FastAPI backend — recursive DLC scan, folder tree + filters, folder/song mutations, two-level cache
screen.html Plugin screen content — injected by the host into the plugin div automatically
screen.js Frontend logic — recursive folder tree, search, sort, filters, drag-and-drop, modals
README.md User-facing docs
```
## Architecture
This plugin follows the standard FeedBack plugin pattern (see the repo-root `CLAUDE.md` for the full plugin system reference).
- **Backend** (`routes.py`) — registers routes under `GET/POST /api/plugins/folder_library/`. Uses `context["get_dlc_dir"]()`, `context["extract_meta"]()`, and `context["log"]`. Scans `<dlc>/sloppak/` if it exists, otherwise `<dlc>/`. Recursively walks the tree and handles create/rename/delete folder and move-song operations on slash-separated folder paths.
- **Frontend** (`screen.js`) — plain vanilla JS in an IIFE. Fetches the tree from the backend on screen load, recursively renders collapsible folder sections (any depth) and song rows or cards (grid view). Uses `window.feedBack.on('screen:changed', ...)` (via the `window.slopsmith` alias) to trigger load when the user navigates here. Calls `window.playSong(filename)` on song click with the full relative path from the DLC root.
- **No dependencies** — no npm, no build step. Tailwind utility classes available globally from the host; the plugin uses only core-guaranteed utilities and inline styles, so it ships **no** `styles` manifest key.
## Critical Layout Lessons (Hard-Won)
These are non-obvious behaviours of the FeedBack desktop app (Electron) that took significant debugging to discover. They still apply unchanged.
### 1. Do NOT put an outer wrapper div in screen.html
The host automatically creates `<div id="plugin-folder_library" class="screen">` and injects `screen.html` content inside it. If you add your own outer div with `class="screen"`, you get a nested screen element which gets `display:none` applied, hiding all content.
**Wrong:**
```html
<div id="plugin-folder_library" class="screen">
<div>toolbar</div>
<div>content</div>
</div>
```
**Correct:**
```html
<!-- no outer wrapper — the host provides it -->
<div>toolbar</div>
<div>content</div>
```
### 2. The .screen CSS class sets display:none by default
`.screen { display: none }` and `.screen.active { display: block }`. There is no height set. The screen div gets its height purely from its content. Do not try to set height via CSS classes — use inline styles or JS if needed.
### 3. The host navbar is position:fixed with z-index:50
The navbar sits at `top:0, z-index:50`. Plugin toolbars must use `position:fixed; top:64px; z-index:40` to sit below the navbar. Use a solid `background-color` (not Tailwind bg classes — those may not apply correctly) to prevent content showing through.
### 4. Content must have padding-top to clear the fixed toolbar
Since the toolbar is `position:fixed`, it floats above the content. The content container needs enough `padding-top` (~120px) to ensure the first item isn't hidden behind the toolbar — the host navbar (64px) plus the plugin toolbar height (~56px). Adding more toolbar buttons increases this height, so if content is clipped, increase the padding further.
### 5. Electron blocks window.prompt() and window.confirm()
The desktop app is built on Electron, which throws `Error: prompt() is not supported`. Use a custom inline modal instead. See `_showModal()` in `screen.js` — it returns a Promise and supports both text input and confirm modes.
### 6. The nav plugin dropdown has z-index:50 and blocks clicks
When navigating to a plugin screen via the Plugins dropdown, the dropdown stays open and sits on top of the screen. Call `_closeDropdown()` on screen load to dismiss it. The dropdown element id is `plugin-dropdown`.
### 7. playSong() expects a relative path from the DLC root
`window.playSong()` expects the path relative to the DLC root with forward slashes, e.g. `sloppak/CH/Artist - Title.sloppak`. Not just the filename. The backend builds this in `_meta()` via `"/".join(p.relative_to(dlc).parts)` and returns it as each song's `filename`.
### 8. FastAPI POST routes need `from fastapi import Request`
Routes that receive a JSON body must import `Request` from fastapi explicitly and use `async def route(request: Request)` with `body = await request.json()`. Missing this import crashes the server on plugin load.
### 9. Plugin id must be consistent everywhere
The plugin id (`folder_library`) must match in:
- `plugin.json``"id"` and `"nav.screen"`
- `screen.js``PLUGIN_ID` constant and `API` constant (`/api/plugins/folder_library`)
- `routes.py``APIRouter(prefix="/api/plugins/folder_library")`
A mismatch in any of these causes silent failures (blank screen, 404 API calls).
### 10. Use inline styles for grid layout, not Tailwind
Tailwind's `grid` and `grid-cols-*` classes may not apply reliably inside the plugin div. Use `element.style.cssText` with explicit `display:grid; grid-template-columns:...` for the grid container.
## Key Conventions
- **IIFE + `'use strict'`** — all frontend code wrapped in `(function(){ 'use strict'; ... })();`
- **localStorage prefixes** — plugin keys are prefixed `fo:` (e.g. `fo:view`, `fo:sort`, `fo:filters`); host-library-synced filter state uses `fo:lib:`. Open-folder state is tracked by **folder path** (so nested folders each remember their own state).
- **Safe storage access** — all `localStorage` reads/writes wrapped in try/catch
- **Logging** — backend uses `context["log"]`, never `print()`
- **Sibling imports** — use `context["load_sibling"]("name")` not bare `import name` (none needed today; keep this in mind if you add helper modules)
## Song Formats
The plugin treats both `.sloppak` and `.feedpak` as songs (`_is_song()` in `routes.py`). `feedpak` is the published name for the same on-disk format the codebase still calls `sloppak` internally — see the repo-root `CLAUDE.md`. Both file form (`.sloppak`/`.feedpak` zip) and directory form (`*.sloppak/` folder) are recognized.
## Backend Routes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/plugins/folder_library/tree` | Returns the folder tree. Accepts optional filter query params (below) applied server-side. |
| POST | `/api/plugins/folder_library/folder/create` | Body: `{name, parent?}` — creates a subfolder; `parent` (slash path) nests it inside an existing folder, omit/empty for top level |
| POST | `/api/plugins/folder_library/folder/rename` | Body: `{old, new}``old` is a slash path, `new` is a bare name; renames within the same parent |
| POST | `/api/plugins/folder_library/folder/delete` | Body: `{name}` (slash path) — moves all songs at any depth to the scan root, then removes the folder |
| POST | `/api/plugins/folder_library/song/move` | Body: `{filename, folder}` — moves a song to `folder` (slash path; empty = scan root / "Unsorted") |
### `/tree` filter query params
All optional, applied server-side over the cached full tree by `_apply_tree_filters()`. Comma-separated, case-insensitive:
- `arrangements_has`, `arrangements_lacks` — include/exclude by arrangement name
- `stems_has`, `stems_lacks` — include/exclude by stem name
- `has_lyrics``""` (any), `"1"`, or `"0"`
- `tunings` — comma-separated tuning names to include
The frontend forwards the host library's active filter params here (via `window.feedBackLibFilterParams()` when present, with `window.slopsmithLibFilterParams()` as a legacy fallback) so the Folders view can stay in sync with the main library filters, falling back to its own filter panel state otherwise.
### Path safety
`_safe_name()` rejects empty names, leading/trailing whitespace, the characters `\ / : * ? " < > |`, and `.`/`..`. `_safe_path()` applies `_safe_name()` to every slash-separated segment, so traversal (`..`) and absolute paths are rejected before any filesystem op. Always validate user-supplied folder paths through these before touching disk.
## Tree Shape
`/tree` returns:
```json
{
"folders": [
{
"name": "CH",
"path": "CH",
"songs": [ /* song objects */ ],
"children": [
{ "name": "Live", "path": "CH/Live", "songs": [], "children": [] }
]
}
],
"root_songs": [ /* songs sitting directly in the scan root shown as "Unsorted" */ ]
}
```
Folder nodes are **recursive**: each has `name`, `path` (slash-separated, relative to the scan root), `songs`, and `children`. The frontend renders any depth — `_findFolderByPath()`, `_countDeep()`, and `_countFoldersDeep()` walk the `children` arrays.
## Song Metadata Format
Each song object (built by `_meta()`):
```json
{
"filename": "sloppak/CH/Artist - Title.sloppak",
"title": "Title",
"artist": "Artist",
"album": "Album Name",
"duration": 213.5,
"year": 1993,
"tuning": "E Standard",
"added": 1748132400.0,
"arrangements": ["Lead", "Rhythm", "Bass"],
"stems": ["Drums", "Bass", "Vocals"],
"lyrics": true
}
```
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
### extract_meta returns arrangements/stems as objects, not strings
`context["extract_meta"]()` returns arrangements as a list of objects `{index, name, notes}`, not plain strings; stems similarly. `_meta()` normalizes to `.name`:
```python
raw_arr = raw.get("arrangements") or []
m["arrangements"] = [
a["name"] if isinstance(a, dict) else str(a)
for a in raw_arr
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
]
```
`lyrics` is coerced to a bool from several possible keys (`lyrics`, `hasLyrics`, `has_lyrics`, …). If you add new metadata fields from `extract_meta`, check the raw shape before assuming it's a plain value.
## Two-Level Cache
`routes.py` keeps two caches inside `setup()`:
- **`_meta_cache`** — expensive `extract_meta()` results keyed by absolute POSIX path. **Never cleared.** When files move (rename/delete/move), the keys are rewritten in-place so the warm data survives the operation.
- **`_cache`** — the assembled tree structure (`folders` / `root_songs`). Cleared by `_invalidate()` on **every** mutation so the next `/tree` rebuilds it — but the rebuild is fast because `_meta_cache` is still warm.
`filename` and `added` are deliberately **not** stored in `_meta_cache` (they depend on the file's current location) — they're recomputed on every `_meta()` call and merged onto the cached copy. When you add a mutation route, mirror the existing key-rewrite logic (see `rename_folder`, `delete_folder`, `move_song`) so the metadata cache stays valid.
## Folder Scan Logic
`routes.py` scans recursively starting at `<dlc>/sloppak/` (or `<dlc>/` if no `sloppak` subdir exists):
- Files/dirs matching `.sloppak` or `.feedpak` → song entries (root-level ones go to `root_songs`, shown as "Unsorted")
- Subdirectories → recursive folder nodes with their own `songs` + `children`
- Dot-prefixed entries are skipped; empty folders are still included (shown with a 0 count)
To add more grouping options (by artist, album, etc.), build an alternative projection over the scanned songs rather than the on-disk tree.
## Library provider (future, not implemented)
This plugin surfaces folders as a dedicated **view** over the existing library;
it does not (yet) register itself as a selectable library **source/provider**.
If you want a "Folders" entry to appear in the host's main library-source
picker (mapping top-level folder → "artist", subfolder → "album"), implement a
provider exposing the source-aware contract (`query_page`, `query_artists`,
`query_stats`, `tuning_names`) and register it in `setup()` via
`context["register_library_provider"](...)`, unregistering on teardown. (An
earlier inert `FolderLibraryProvider` scaffold was removed — it was never wired
and only duplicated the scan logic; re-add it only alongside real registration
and tests.)
## View Modes (List / Grid)
The toolbar has a list/grid toggle. Current view is stored in `localStorage` under `fo:view` (`'list'` or `'grid'`).
- **List view** — `_songRow()`, rendered inside a `ml-5 space-y-0` div
- **Grid view** — `_songCard()`, rendered inside a CSS grid div (`auto-fill, minmax(150px,1fr)`)
- Both the folder and unsorted section renderers branch on `_view` to pick the right renderer and container
- Album art is fetched via `/api/song/<encoded-path>/art` where each path segment is individually `encodeURIComponent`-encoded. On error the `<img>` is hidden and a placeholder SVG is shown
- The collapse/expand toggle restores `display:grid` (not just `display:''`) when reopening a folder in grid mode — always check this when changing toggle logic
### Lazy folder rendering
Folders do **not** render their song list on initial load. The folder renderer sets a `_listPopulated` flag and only populates the list the first time a folder is opened, keeping the initial render fast with large libraries. When search is active all folders are forced open and populated immediately (search overrides lazy loading).
## Sort System
The toolbar has a sort select (`#fb-sort`) and a direction toggle (`#fb-sort-dir`). State is stored under `fo:sort` and `fo:sortDir`.
- `_sort``'default' | 'title' | 'artist' | 'duration' | 'year' | 'tuning' | 'added'`
- `_sortDir``'asc' | 'desc'`
- `_sortSongs(songs)` returns a sorted copy; direction is applied by reversing after sort. Returns the array unchanged when `_sort === 'default'`.
- The sort direction button is dimmed (`opacity: 0.35`) and non-interactive when sort is `'default'`.
## Filter System
Client-side filters are stored under `fo:filters` as a JSON object. (The server `/tree` endpoint can also filter — see Backend Routes — used to sync with the host library.)
### Filter state shape
```js
_filters = {
arrangements: { Lead: 'on', Bass: 'exclude', Rhythm: 'off' },
stems: { Drums: 'off' },
lyrics: 'off', // 'off' | 'on' | 'exclude'
tunings: ['E Standard', 'Eb Standard'],
}
```
Each arrangement/stem value is `'off' | 'on' | 'exclude'`.
### Include vs exclude logic
`_matchFilters(song)` uses **OR logic for includes, AND logic for excludes**:
- **Include (`'on'`)** — song passes if it has *at least one* selected arrangement/stem. More includes widens the result set.
- **Exclude (`'exclude'`)** — each excluded tag independently removes songs that have it. More excludes narrows the result set.
This matches standard multi-select filter UX (Spotify/library style).
### Data-driven filter panel
All filter sections are built from the actual library data — nothing is hardcoded:
- `_getArrangements()` — unique arrangement names sorted by frequency (most common first), then alphabetically
- `_getStems()` — same pattern for stem names
- `_getAvailableFilters()` — returns `{ arrangements, stems, lyrics, tuning }` booleans gating the lyrics/tuning sections
Non-standard arrangement names (e.g. `"Bonus"`) appear as pills automatically — no constants to update. The stems section only appears if at least one song has stems data.
### Split pill UI
`_makeSplitPill(label, state, onChange)` renders a two-zone pill:
- Left zone (label) — toggles `'off' ↔ 'on'` (include, blue)
- Right zone (`✕`) — toggles `'off' ↔ 'exclude'` (exclude, red)
The filter badge (`#fb-filter-badge`) shows the active filter count via `_activeFilterCount()`.
## Hover Badges
Each song row/card has two hidden hover-reveal layers, built once and toggled via CSS `max-height` + `opacity` transitions.
### `_badge(text, active, type)`
Renders a single metadata badge. Type controls the inactive colour:
| type | inactive border | inactive text |
|---|---|---|
| `'arrangement'` | amber `#92400e` | amber `#fcd34d` |
| `'stem'` | violet `#5b21b6` | violet `#c4b5fd` |
| `'lyrics'` | rose `#9f1239` | rose `#fda4af` |
| `'tuning'` | teal `#0f766e` | teal `#5eead4` |
Active state is always blue (`#1d4ed8` fill, `#3b82f6` border, white text) regardless of type.
### `_buildSongBadges(song)`
Builds the badge row (arrangements, stems, lyrics, tuning), deduplicating within each category. Clicking a badge toggles that filter on/off and re-renders. Returns `null` if the song has no filterable metadata.
### `_buildSongDateInfo(song)`
Builds a separate plain-text hover line showing `year · date added` (e.g. `1993 · 24 May 2026`), `#cbd5e1` text. Always shown on hover regardless of filter state.
### Reveal / hide
```js
_revealBadges(el) // max-height:120px, opacity:1, margin-top:4px
_hideBadges(el) // max-height:0, opacity:0, margin-top:0
```
Both badge layers (badges + date-info) are wired to the same `mouseenter`/`mouseleave` events on the row or card element.
## Drag-and-Drop
Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTML5 DnD API. HTML5 DnD blocks wheel events and gives unreliable edge positions inside Electron — pointer events give full control.
- `_makeDraggable(el, song, folderName)` — attaches a `mousedown` listener. A drag goes "live" only after the pointer moves more than `_DRAG_THRESH` (5 px), preventing accidental drags on clicks.
- Once live, a ghost `div` follows the cursor. Auto-scroll activates when the pointer is within `_DRAG_ZONE` (150 px) of the viewport top/bottom.
- `_makeDropTarget(el, targetFolder)` — sets `data-dropFolder` so an element can receive drops. Both folder headers and song-list containers are drop targets — including **nested** folders (drop onto a subfolder header moves the song there).
- `_dragFindTarget(x, y)` — uses `document.elementsFromPoint` to find the topmost element with `data-dropFolder` under the cursor.
- **Esc to cancel** — `_onDragKeyDown` calls `_endPointerDrag()` on `Escape`, removing the ghost and clearing state without dropping.
- On a successful drop, `_executeDrop()` does an **optimistic UI update** (moves the song in the in-memory tree and re-renders) then calls `/song/move`. On API failure it reloads the full tree.
- A one-time `click` capture listener after mouseup suppresses the post-drag click so it doesn't trigger playback.
## Modal Behaviour
`_showModal(msg, withInput, defaultVal)` is the custom modal used for all prompts and confirms (Electron blocks `window.prompt()` / `window.confirm()`). It returns a Promise.
- `_confirm(msg)` — resolves `true` on OK, `null` on cancel
- `_prompt(msg, default)` — resolves the trimmed input string on OK, `null` on cancel
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
- **Enter confirms** — submits, equivalent to OK
## Roadmap
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache.
Not yet implemented, in rough priority order:
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
- **Bulk move** — multi-select songs and move them all at once.
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
- **Custom themes** — switchable colour schemes.
- **Favoriting songs** — likely a new backend route plus a `fo:favorites` localStorage key.
- **Editing song metadata** — edit title, artist, album etc. in-plugin; needs new backend write routes.
- **Folders as a library source** — register a library provider so a "Folders" entry appears in the host's main library-source picker (see "Library provider (future)" above).
+100
View File
@@ -0,0 +1,100 @@
# Folder Library — FeedBack Plugin
![Core plugin](https://img.shields.io/badge/fee%5BdB%5Dack-core%20plugin-blue)
![Platform](https://img.shields.io/badge/platform-fee%5BdB%5Dack-darkblue)
A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC songs into a folder tree, grouped by the folders on disk. Browse your whole library visually with album art, nest folders as deep as you like, switch between list and grid layouts, and manage folders without ever leaving the app.
---
## Screenshots
![Grid view](assets/grid-view.webp)
*Grid view — album art cards with title and artist*
![Grid search](assets/grid-search.png)
*Live search filters instantly across all folders*
![List view](assets/list-view.png)
*List view — compact rows with album art thumbnails and duration*
![New folder](assets/new-folder.png)
*Create and manage folders directly in the UI*
---
> **Status — migrating to core.** Folder Library is being reworked from a standalone plugin into a bundled core plugin, and several previously-shipped features are not currently wired up in core (see the Roadmap). The list below reflects what works today; if something here is wrong, it's because this rework is still in progress.
## Features
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
- **Album art** — pulls art automatically for every song in both views
- **One-click playback** — click any song to start playing immediately
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
- **Folder management** — create, rename, and delete folders without leaving the plugin
- **Nested subfolders** — organize as deep as you want; create a subfolder inside any folder, expand/collapse a whole branch in one click
- **Collapsible folders** — expand/collapse individual folders, plus Expand All / Collapse All
- **Move songs** — reassign any song to a different folder on the fly; press `Esc` to cancel
- **Drag-and-drop** — drag songs between folders (including into nested folders) with smooth auto-scroll; press `Esc` to cancel
- **Fast with big libraries** — folder song lists render lazily and metadata is cached so reopening folders is instant
---
## Installation
Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`), so there's nothing to install — the **Folders** screen appears in the navbar under **Plugins** automatically.
---
## Usage
| Action | How |
|--------|-----|
| Switch to grid view | Click the grid icon in the toolbar |
| Switch to list view | Click the list icon in the toolbar |
| Play a song | Click any song row or card |
| Sort songs | Use the sort dropdown in the toolbar |
| Toggle sort direction | Click the arrow button next to the sort dropdown |
| Open filters | Click the filter icon in the toolbar |
| Filter by arrangement/stem | Open filters → click a pill to include; click `✕` to exclude |
| Clear all filters | Open filters → click "Clear all" |
| Create a folder | Click the folder+ icon in the toolbar |
| Create a subfolder | Hover a folder header → click the new-subfolder icon |
| Rename a folder | Hover the folder header → click the pencil icon |
| Delete a folder | Hover the folder header → click the trash icon (songs move up to Unsorted) |
| Move a song | Hover the song row → click the folder icon |
| Drag a song to a folder | Click and hold a song → drag to a folder header or body (nested folders work too) |
| Cancel a drag | Press `Esc` while holding a song |
| Cancel a move dialog | Press `Esc` in the move prompt |
| Expand / collapse a folder | Click the folder header |
| Expand / collapse all subfolders | Use the expand/collapse-children buttons on a folder with subfolders |
---
## Changelog
Folder Library started life as a standalone plugin with its own version line, but it's now a **bundled core plugin** that ships with FeedBack. Its changes are tracked alongside the app in the repo-root [CHANGELOG.md](../../CHANGELOG.md), and it versions with the app rather than on its own. The **Features** section above reflects what's in the current build.
---
## Roadmap
- [ ] Auto play song on hover (with an on/off toggle)
- [ ] Bulk move — select multiple songs and move them at once
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
- [ ] Custom themes — switch between colour schemes to match your style
- [ ] Favoriting songs
- [ ] Editing song metadata
---
## Contributing
Pull requests are welcome. For major changes please open an issue first to discuss what you'd like to change.
1. Fork the repo
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Commit your changes
4. Push to the branch and open a pull request
+10
View File
@@ -0,0 +1,10 @@
{
"id": "folder_library",
"name": "Folder Library",
"version": "1.8.0",
"bundled": true,
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
"screen": "screen.html",
"script": "screen.js",
"routes": "routes.py"
}
+440
View File
@@ -0,0 +1,440 @@
"""
Folder Library plugin — routes.py
Surfaces the DLC folder structure as a navigable tree and provides in-app
folder management (create / rename / delete) and song moves. Every filesystem
mutation is confined to DLC_DIR and validated against path traversal.
"""
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
import shutil
import re
# ── Pure, testable helpers ─────────────────────────────────────────────────
_UNSAFE_NAME_RE = re.compile(r'[\\/:*?"<>|]')
def _safe_name(name: str) -> bool:
"""A single path segment is safe: no separators, no traversal dot-names,
no surrounding whitespace, no characters illegal across filesystems."""
if not name or name.strip() != name:
return False
if _UNSAFE_NAME_RE.search(name):
return False
if name in (".", ".."):
return False
return True
def _safe_path(path_str: str) -> bool:
"""A slash-separated path is safe iff every segment is a safe name."""
if not path_str:
return False
return all(_safe_name(p) for p in path_str.split("/"))
def _is_within(root: Path, candidate: Path) -> bool:
"""True iff ``candidate`` resolves to a location inside ``root`` (after
normalising ``..`` and symlinks). Containment backstop for file moves so a
crafted filename can't escape DLC_DIR even past the segment validator."""
try:
candidate.resolve().relative_to(root.resolve())
return True
except (ValueError, OSError):
return False
def _path_to_dir(root: Path, folder_path: str) -> Path:
"""Resolve a slash-separated folder path relative to ``root``."""
result = root
for part in folder_path.split("/"):
result = result / part
return result
def _load_is_loose_song():
"""The host's authoritative loose-folder predicate (lib/loosefolder.py),
imported lazily so the plugin still loads if it's ever unavailable. A
loose-folder song is a directory carrying audio + an arrangement XML rather
than a ``.sloppak`` bundle, so the plain suffix check below misses it."""
try:
from loosefolder import is_loose_song
return is_loose_song
except Exception:
return None
_IS_LOOSE_SONG = _load_is_loose_song()
def _is_song(p: Path) -> bool:
"""A song carrier is a ``.sloppak`` / ``.feedpak`` file or directory-form
bundle (extension on the leaf name), or a host-recognised loose-folder song
directory — so loose-folder charts surface in the tree like any other song
instead of being walked into as if they were ordinary folders."""
if p.suffix.lower() in (".sloppak", ".feedpak"):
return True
if _IS_LOOSE_SONG is not None and p.is_dir():
try:
return bool(_IS_LOOSE_SONG(p))
except Exception:
return False
return False
def setup(app, context):
log = context["log"]
router = APIRouter(prefix="/api/plugins/folder_library")
# ── Two-level cache ────────────────────────────────────────────────
# _meta_cache — expensive extract_meta() results keyed by abs path
# (as_posix() string). Never cleared; keys are updated
# in-place when files are moved so the data stays valid.
# _cache — tree structure ("folders" / "root_songs"). Cleared on
# every mutation so the next /tree request rebuilds it —
# but that rebuild is now fast because _meta_cache is warm.
_cache = {} # "tree" → JSONResponse-ready dict
_meta_cache = {} # abs_posix_path → extracted meta (no filename/added)
def _invalidate():
"""Clear the tree structure cache only. _meta_cache is preserved."""
_cache.clear()
def _dlc_root() -> Path | None:
try:
return Path(context["get_dlc_dir"]())
except Exception:
return None
def _scan_root(dlc: Path) -> Path:
sloppak = dlc / "sloppak"
return sloppak if sloppak.exists() else dlc
def _meta(p: Path, dlc: Path) -> dict:
# filename and added are always computed fresh — they change when files move.
try:
filename = "/".join(p.relative_to(dlc).parts)
except ValueError:
filename = p.name
added = None
try:
added = p.stat().st_mtime
except Exception:
pass
# Return cached extracted metadata if available.
cache_key = p.as_posix()
if cache_key in _meta_cache:
m = dict(_meta_cache[cache_key]) # shallow copy
m["filename"] = filename
m["added"] = added
return m
# Cache miss — run the expensive extract.
m = {"title": None, "artist": None, "album": None, "duration": None,
"year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False}
try:
raw = context["extract_meta"](p)
if raw:
m["title"] = raw.get("title") or raw.get("name")
m["artist"] = raw.get("artist") or raw.get("artistName")
m["album"] = raw.get("album") or raw.get("albumName")
m["duration"] = raw.get("duration")
m["year"] = raw.get("year")
m["tuning"] = raw.get("tuning")
# arrangements — objects with a "name" key e.g. [{name:"Lead",...}, ...]
raw_arr = raw.get("arrangements") or []
if isinstance(raw_arr, (list, tuple)):
m["arrangements"] = [
a["name"] if isinstance(a, dict) else str(a)
for a in raw_arr
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
]
# stems — may also be objects with a "name" key, same as arrangements
raw_stems = raw.get("stems") or []
for _key in ("stems", "stem_types", "available_stems", "stemTypes"):
_v = raw.get(_key)
if _v:
raw_stems = _v
break
if isinstance(raw_stems, (list, tuple)):
m["stems"] = [
a["name"] if isinstance(a, dict) else str(a)
for a in raw_stems
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
]
# lyrics — try common key variants
for _key in ("lyrics", "hasLyrics", "has_lyrics", "lyric", "hasLyric"):
_val = raw.get(_key)
if _val is not None:
if isinstance(_val, str):
m["lyrics"] = _val.lower() not in ("", "false", "no", "0")
else:
m["lyrics"] = bool(_val)
break
except Exception as exc:
log.debug("meta failed for %s: %s", p.name, exc)
if not m["title"]:
m["title"] = p.stem
_meta_cache[cache_key] = m # store without filename/added
result = dict(m)
result["filename"] = filename
result["added"] = added
return result
def _scan_dir(path: Path, root: Path, dlc: Path) -> dict:
"""Recursively scan a directory and return a folder node."""
songs = []
children = []
try:
for entry in sorted(path.iterdir(), key=lambda p: p.name.lower()):
if entry.name.startswith("."):
continue
if _is_song(entry):
songs.append(_meta(entry, dlc))
elif entry.is_dir():
children.append(_scan_dir(entry, root, dlc))
except PermissionError:
log.warning("permission denied: %s", path)
try:
rel = path.relative_to(root)
folder_path = "/".join(rel.parts)
except ValueError:
folder_path = path.name
return {
"name": path.name,
"path": folder_path,
"songs": songs,
"children": children,
}
def _apply_tree_filters(tree, arrangements_has="", arrangements_lacks="",
stems_has="", stems_lacks="", has_lyrics="", tunings=""):
"""Filter a cached tree dict by arrangement/stem/lyrics/tuning params.
The cache always holds the full unfiltered tree; this is applied per-request."""
def _split(s):
return [x.strip().lower() for x in s.split(",") if x.strip()] if s else []
arr_has = _split(arrangements_has)
arr_lacks = _split(arrangements_lacks)
st_has = _split(stems_has)
st_lacks = _split(stems_lacks)
tun_set = set(_split(tunings))
lyr = None if has_lyrics == "" else (has_lyrics == "1")
if not any([arr_has, arr_lacks, st_has, st_lacks, tun_set, lyr is not None]):
return tree # no filters active — return as-is
def _song_ok(s):
arrs = [a.lower() for a in (s.get("arrangements") or [])]
stms = [x.lower() for x in (s.get("stems") or [])]
if arr_has and not any(a in arrs for a in arr_has): return False
if arr_lacks and any(a in arrs for a in arr_lacks): return False
if st_has and not any(x in stms for x in st_has): return False
if st_lacks and any(x in stms for x in st_lacks): return False
if lyr is not None and bool(s.get("lyrics")) != lyr: return False
if tun_set and (s.get("tuning") or "").lower() not in tun_set: return False
return True
def _filter_node(node):
return {
"name": node["name"],
"path": node["path"],
"songs": [s for s in node["songs"] if _song_ok(s)],
"children": [_filter_node(c) for c in node.get("children", [])],
}
return {
"folders": [_filter_node(f) for f in tree["folders"]],
"root_songs": [s for s in tree["root_songs"] if _song_ok(s)],
}
@router.get("/tree")
def get_tree(
arrangements_has: str = "",
arrangements_lacks: str = "",
stems_has: str = "",
stems_lacks: str = "",
has_lyrics: str = "",
tunings: str = "",
):
if "tree" not in _cache:
dlc = _dlc_root()
if not dlc or not dlc.exists():
return JSONResponse({"folders": [], "root_songs": [],
"error": "DLC directory not found"})
root = _scan_root(dlc)
log.info("folder_library: scanning %s", root)
folders = []
root_songs = []
try:
for entry in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if entry.name.startswith("."):
continue
if _is_song(entry):
root_songs.append(_meta(entry, dlc))
elif entry.is_dir():
folders.append(_scan_dir(entry, root, dlc))
except PermissionError:
return JSONResponse({"folders": [], "root_songs": [],
"error": "Permission denied"})
_cache["tree"] = {"folders": folders, "root_songs": root_songs}
result = _apply_tree_filters(
_cache["tree"], arrangements_has, arrangements_lacks,
stems_has, stems_lacks, has_lyrics, tunings,
)
return JSONResponse(result)
@router.post("/folder/create")
async def create_folder(request: Request):
body = await request.json()
name = (body.get("name") or "").strip()
parent = (body.get("parent") or "").strip()
if not _safe_name(name):
return JSONResponse({"error": "Invalid folder name"}, status_code=400)
if parent and not _safe_path(parent):
return JSONResponse({"error": "Invalid parent path"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
root = _scan_root(dlc)
parent_dir = _path_to_dir(root, parent) if parent else root
if parent and not parent_dir.exists():
return JSONResponse({"error": "Parent folder not found"}, status_code=404)
target = parent_dir / name
if target.exists():
return JSONResponse({"error": "Folder already exists"}, status_code=400)
try:
target.mkdir(parents=False)
_invalidate()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/folder/rename")
async def rename_folder(request: Request):
body = await request.json()
old = (body.get("old") or "").strip()
new = (body.get("new") or "").strip()
if not _safe_path(old) or not _safe_name(new):
return JSONResponse({"error": "Invalid folder name"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
root = _scan_root(dlc)
src = _path_to_dir(root, old)
dst = src.parent / new # rename within the same parent
if not src.exists():
return JSONResponse({"error": "Folder not found"}, status_code=404)
if dst.exists():
return JSONResponse({"error": "Name already taken"}, status_code=400)
try:
# Pre-compute meta cache key updates (keys change because the
# folder path changes — all files under src get a new prefix).
old_prefix = src.as_posix() + "/"
new_prefix = dst.as_posix() + "/"
meta_updates = {
key: new_prefix + key[len(old_prefix):]
for key in list(_meta_cache)
if key.startswith(old_prefix)
}
src.rename(dst)
_invalidate()
for old_key, new_key in meta_updates.items():
if old_key in _meta_cache:
_meta_cache[new_key] = _meta_cache.pop(old_key)
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/folder/delete")
async def delete_folder(request: Request):
body = await request.json()
name = (body.get("name") or "").strip()
if not _safe_path(name):
return JSONResponse({"error": "Invalid folder path"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
root = _scan_root(dlc)
target = _path_to_dir(root, name)
if not target.exists():
return JSONResponse({"error": "Folder not found"}, status_code=404)
try:
# Relocate every song (at any depth) up to the scan root BEFORE
# removing the folder. Colliding filenames are de-duplicated so a
# name clash never leaves a song behind to be destroyed by rmtree
# (the folder is advertised as "moves its songs to Unsorted").
for song_path in sorted(target.rglob("*")):
if not song_path.exists():
continue # a parent song-dir was already relocated
if not _is_song(song_path):
continue
old_key = song_path.as_posix()
dest = root / song_path.name
if dest.exists():
stem, suffix = song_path.stem, song_path.suffix
n = 1
while dest.exists():
dest = root / f"{stem} ({n}){suffix}"
n += 1
song_path.rename(dest)
if old_key in _meta_cache:
_meta_cache[dest.as_posix()] = _meta_cache.pop(old_key)
shutil.rmtree(target)
_invalidate()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/song/move")
async def move_song(request: Request):
body = await request.json()
filename = (body.get("filename") or "").strip()
dest_folder = (body.get("folder") or "").strip()
# Validate the source path like the folder ops, AND confirm it resolves
# inside DLC_DIR — without this a filename such as "../../etc/passwd"
# would be renamed (moved) into the served library and become readable.
if not filename or not _safe_path(filename):
return JSONResponse({"error": "Invalid filename"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
src = dlc / Path(*filename.split("/"))
if not _is_within(dlc, src):
return JSONResponse({"error": "Invalid filename"}, status_code=400)
if not src.exists():
return JSONResponse({"error": "Song not found"}, status_code=404)
root = _scan_root(dlc)
if dest_folder:
if not _safe_path(dest_folder):
return JSONResponse({"error": "Invalid folder path"}, status_code=400)
dst_dir = _path_to_dir(root, dest_folder)
if not dst_dir.exists():
return JSONResponse({"error": "Destination folder not found"}, status_code=404)
else:
dst_dir = root
dst = dst_dir / src.name
if dst.exists():
return JSONResponse({"error": "File already exists at destination"}, status_code=400)
try:
old_key = src.as_posix()
src.rename(dst)
if old_key in _meta_cache:
_meta_cache[dst.as_posix()] = _meta_cache.pop(old_key)
_invalidate()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
app.include_router(router)
log.info("folder_library routes registered")
+159
View File
@@ -0,0 +1,159 @@
<!-- Folder Browser — screen.html
Slopsmith injects this into a div#plugin-folder_library.screen automatically.
Do NOT add an outer wrapper div with class="screen". -->
<!-- ── toolbar ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-2 px-4 py-3 border-b border-dark-400 flex-wrap"
style="position:fixed; top:64px; left:0; right:0; z-index:40; background-color:#0f1117; border-bottom: 1px solid #1f2937;">
<h2 class="text-base font-semibold text-white mr-1">Folders</h2>
<!-- search -->
<div class="relative flex-1 min-w-40 max-w-xs">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"/>
</svg>
<input id="fb-search" type="text" placeholder="Search songs…"
class="w-full pl-8 pr-3 py-1.5 rounded bg-dark-500 border border-dark-400
text-sm text-gray-200 placeholder-gray-500
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"/>
</div>
<!-- new folder -->
<button id="fb-new-folder" title="New parent folder"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
<path fill-rule="evenodd" d="M10 9a1 1 0 011 1v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1H8a1 1 0 110-2h1v-1a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
</button>
<!-- expand all -->
<button id="fb-expand-all" title="Expand all"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" class="w-4 h-4">
<path d="M5 8l5 5 5-5"/>
<path d="M5 4l5 5 5-5" opacity=".4"/>
</svg>
</button>
<!-- collapse all -->
<button id="fb-collapse-all" title="Collapse all"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" class="w-4 h-4">
<path d="M5 12l5-5 5 5"/>
<path d="M5 16l5-5 5 5" opacity=".4"/>
</svg>
</button>
<!-- sort -->
<select id="fb-sort" title="Sort songs within folders"
style="padding:4px 8px; border-radius:6px; border:1px solid #374151;
background:#1f2937; color:#d1d5db; font-size:12px; cursor:pointer; outline:none;">
<option value="default">Default</option>
<option value="title">Title</option>
<option value="artist">Artist</option>
<option value="duration">Duration</option>
<option value="year">Year</option>
<option value="tuning">Tuning</option>
<option value="added">Recently Added</option>
</select>
<!-- sort direction -->
<button id="fb-sort-dir" title="Ascending"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg id="fb-sort-dir-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" class="w-4 h-4">
<path d="M5 12l5-5 5 5"/>
</svg>
</button>
<!-- view toggle -->
<button id="fb-view-list" title="List view"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd"
d="M3 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3z"
clip-rule="evenodd"/>
</svg>
</button>
<button id="fb-view-grid" title="Grid view"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
</button>
<!-- filters -->
<button id="fb-filter" title="Filters"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors"
style="position:relative;">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd"
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"
clip-rule="evenodd"/>
</svg>
<span id="fb-filter-badge"
style="display:none; position:absolute; top:-2px; right:-2px; min-width:14px; height:14px;
padding:0 3px; border-radius:7px; background:#3b82f6; color:#fff;
font-size:9px; font-weight:700; line-height:14px; text-align:center;
box-sizing:border-box;"></span>
</button>
<!-- reload -->
<button id="fb-reload" title="Reload"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"/>
</svg>
</button>
<span id="fb-status" class="text-xs text-gray-500 ml-1"></span>
</div>
<!-- ── tree ─────────────────────────────────────────────────────────── -->
<div id="fb-tree" class="px-2 py-2" style="padding-top: 120px;"></div>
<!-- ── filter backdrop ───────────────────────────────────────────────── -->
<div id="fb-filter-backdrop"
style="display:none; position:fixed; inset:0; z-index:44;"></div>
<!-- ── filter panel ──────────────────────────────────────────────────── -->
<div id="fb-filter-panel"
style="display:none; position:fixed; top:64px; right:0; bottom:0; width:300px;
z-index:45; background:#0f1117; border-left:1px solid #1f2937;
flex-direction:column; overflow:hidden;"></div>
<!-- ── custom modal ──────────────────────────────────────────────────── -->
<div id="fb-modal" style="display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
<div style="background:#1e2130; border:1px solid #374151; border-radius:8px;
padding:24px; width:360px; max-width:90vw; box-shadow:0 20px 60px rgba(0,0,0,0.5);">
<p id="fb-modal-msg" style="color:#e5e7eb; font-size:14px; margin:0 0 16px 0;
white-space:pre-wrap; line-height:1.5;"></p>
<input id="fb-modal-input" type="text"
style="display:none; width:100%; box-sizing:border-box; padding:8px 12px;
background:#111827; border:1px solid #374151; border-radius:6px;
color:#e5e7eb; font-size:14px; outline:none; margin-bottom:16px;"
placeholder=""/>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button id="fb-modal-cancel"
style="padding:6px 16px; border-radius:6px; border:1px solid #374151;
background:transparent; color:#9ca3af; font-size:13px; cursor:pointer;">
Cancel
</button>
<button id="fb-modal-ok"
style="padding:6px 16px; border-radius:6px; border:none;
background:#3b82f6; color:#fff; font-size:13px; cursor:pointer; font-weight:500;">
OK
</button>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.27.0",
"version": "3.30.2",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+617 -12
View File
@@ -1083,6 +1083,22 @@
const FOCUS_D = 600 * K;
const CAM_LERP_BASE = 0.02;
// Base vertical field of view (deg). THREE's PerspectiveCamera fov is the
// VERTICAL angle; horizontal follows from the aspect ratio. At a normal
// ~16:9 pane this gives a ~102° horizontal cone. On an ultra-wide pane
// (top/bottom 2-player split → full-width/half-height → ~32:9) that
// horizontal cone balloons past 130° and squeezes the fixed-width neck into
// a central sliver. The optional horizontal-FOV-hold path below counters
// that by lowering the effective vertical fov as the pane widens.
const BASE_VFOV = 70;
// Horizontal-FOV-hold ("Hor+") defaults. At/under HORPLUS_START_ASPECT the
// effective vertical fov equals BASE_VFOV (exact no-op); past it the
// vertical fov drops to keep the horizontal cone ~constant so the neck
// fills a wide pane. HORPLUS_MIN_VFOV floors the result on pathological
// aspects. Engaged only via the window.__h3dAspectTune bridge (default off).
const HORPLUS_START_ASPECT = 16 / 9;
const HORPLUS_MIN_VFOV = 28;
// Zoom-dependent framing — height (h*) and depth (dist*) multipliers
// applied to cam.position. Interpolated by `dist`:
// NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer.
@@ -1095,6 +1111,16 @@
const CAM_FRAME_H_FAR = 1.00;
const CAM_FRAME_D_NEAR = 0.575;
const CAM_FRAME_D_FAR = 0.60;
// Fret-row fit guard. The heat-coloured fret-number row is a band drawn
// BELOW the board (at sY(lowest) - S_GAP*1.4). The lower-third framing
// anchors the board CENTRE, not that row, so a tight zoom on a centred span
// (worst mid-neck — fine pushed to either end of the neck) drops the row off
// the bottom edge. Tilt can't add vertical room there (it would only trade a
// bottom clip for a top clip), so camUpdate dollies the camera back just
// enough to bring the row back into frame — auto-sized, capped, hysteretic.
const FRET_ROW_FIT_NDC_MIN = -0.86; // keep the row anchor at/above this NDC y (>-1 = on screen)
const FRET_ROW_FIT_DEADBAND = 0.06; // headroom past the min before the dolly relaxes (anti-hunt)
const FRET_ROW_FIT_BOOST_MAX = 1.6; // cap the pull-back so the zoom can't pop (never dolly back > +60%)
// Camera-X targeting (issue #34). The visible AHEAD = 4.0 s window is
// far too coarse for picking where the camera should sit — a single
@@ -1581,6 +1607,257 @@
ss.isCanvasFocused(highwayCanvas));
}
// A/B toggle for the wide-pane horizontal-FOV-hold. Flips
// window.__h3dAspectTune.enabled so the running app can switch between the
// current framing (off, the baseline) and the Hor+ framing (on) with one
// keypress, across all panes at once. Registered once per session via a
// module-level guard (it toggles a shared global, so per-instance
// registration would stack duplicate handlers and cancel itself out); it's
// a harmless debug control, so it is never unregistered. No-ops where the
// core shortcut API isn't present (older core / borrowed contexts).
let _abShortcutRegistered = false;
function _registerAspectAbShortcut() {
if (_abShortcutRegistered) return;
if (typeof window.registerShortcut !== 'function') return;
_abShortcutRegistered = true;
try {
window.registerShortcut({
key: 'A', // uppercase e.key → produced with Shift held (Shift+A)
description: '3D Highway: toggle wide-pane framing A/B (Shift+A)',
scope: 'player',
handler: () => {
const t = _aspectTune();
t.enabled = !t.enabled;
try { console.log('[h3d] wide-pane framing', t.enabled ? 'ON' : 'OFF'); } catch (e) {}
// Surface the live tuner panel whenever the feature is on,
// hide it when off. Built lazily on first use.
_ensureAspectPanel();
_setAspectPanelVisible(t.enabled);
_syncAspectPanel();
},
});
} catch (e) {
_abShortcutRegistered = false; // allow a later retry if it threw
}
}
// ── Wide-pane framing: live tuner bridge + panel ──────────────────────────
// window.__h3dAspectTune is the single source of truth the renderer reads
// each frame (see effectiveVfov + camUpdate). The defaults reproduce the
// current framing exactly (enabled:false). Values persist to localStorage so
// a tuning session survives reloads; the floating panel (Shift+A) writes the
// same object live. All of this is a debug aid — none of it runs unless the
// user opts in.
// Versioned key: the first iteration shipped a broken default (enabled:true,
// baseVfov:30) and may have persisted it. Bumping the key ignores that stale
// state so the corrected default-off config actually takes effect.
const _ASPECT_LS = 'h3d_aspect_tune2';
// Working defaults. Default OFF, so out of the box this is an exact no-op —
// every pane renders byte-for-byte as before (effectiveVfov returns
// BASE_VFOV and the pose nudges gate off). The config is also coherent when
// a tester turns it ON via Shift+A: baseVfov == BASE_VFOV so normal ~16:9
// panes (single-player, most 2x2) stay at 70° even enabled, and only panes
// wider than startAspect (2.25) engage the Hor+ hold; blend:1 makes that
// hold actually take effect; minVfovDeg (28) sits below baseVfov so the floor
// is a real floor. The pose nudges are the in-progress wide-pane look a
// tester sees once enabled. localStorage overrides all of this per machine.
const _ASPECT_DEFAULTS = {
enabled: false, baseVfov: BASE_VFOV, startAspect: 2.25, hfovDeg: null,
blend: 1, minVfovDeg: HORPLUS_MIN_VFOV, splitOnly: false,
heightMul: 0.30, distMul: 0.95, pitchAdd: -1.5, lookDepthMul: 1,
};
// Slider specs (numeric fields). Checkboxes (enabled/splitOnly) + the hfov
// override are handled separately in the panel builder. Ranges are wide on
// purpose — this is a tuning aid, the no-op default sits mid-range.
const _ASPECT_FIELDS = [
{ k: 'baseVfov', label: 'Base vFOV°', min: 18, max: 90, step: 1 },
{ k: 'startAspect', label: 'Start aspect', min: 1.0, max: 4.0, step: 0.05 },
{ k: 'blend', label: 'Blend', min: 0, max: 1, step: 0.05 },
{ k: 'minVfovDeg', label: 'Min vFOV°', min: 10, max: 60, step: 1 },
{ k: 'heightMul', label: 'Height ×', min: 0.1, max: 2.5, step: 0.05 },
{ k: 'distMul', label: 'Dolly ×', min: 0.2, max: 3.0, step: 0.05 },
{ k: 'pitchAdd', label: 'Pitch +', min: -40, max: 40, step: 0.5 },
// Aims the camera further down the neck (>1) or pulls the aim back (<1).
// This is the lever that flattens the mid-distance "hump" toward a
// straight gradual recede.
{ k: 'lookDepthMul', label: 'Look depth', min: 0.2, max: 3.0, step: 0.05 },
];
let _aspectPanelEl = null; // the floating panel root (built once)
let _aspectPanelRO = null; // readout <div>
let _aspectPanelRAF = 0; // readout poll handle
// Get-or-create the live bridge object, seeded from defaults + localStorage.
function _aspectTune() {
let t = window.__h3dAspectTune;
if (!t || typeof t !== 'object') {
t = Object.assign({}, _ASPECT_DEFAULTS);
try {
const raw = localStorage.getItem(_ASPECT_LS);
if (raw) Object.assign(t, JSON.parse(raw));
} catch (e) {}
window.__h3dAspectTune = t;
}
return t;
}
function _aspectPersist() {
try {
const t = _aspectTune(), out = {};
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; });
localStorage.setItem(_ASPECT_LS, JSON.stringify(out));
} catch (e) {}
}
function _ensureAspectPanel() {
if (_aspectPanelEl || typeof document === 'undefined') return;
const t = _aspectTune();
const wrap = document.createElement('div');
wrap.id = 'h3d-aspect-tuner';
wrap.style.cssText = [
'position:fixed', 'top:64px', 'right:12px', 'z-index:99999',
'width:230px', 'padding:10px 12px', 'border-radius:8px',
'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)',
'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5',
'font:11px/1.35 system-ui,sans-serif', 'user-select:none',
'pointer-events:auto',
].join(';');
const title = document.createElement('div');
title.textContent = 'Wide-pane framing (A/B)';
title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;';
wrap.appendChild(title);
// enabled + splitOnly checkboxes
[['enabled', 'Enabled (Shift+A)'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => {
const row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = !!t[k]; cb.dataset.k = k;
cb.addEventListener('change', () => {
_aspectTune()[k] = cb.checked; _aspectPersist();
if (k === 'enabled') _setAspectPanelVisible(cb.checked);
});
const span = document.createElement('span'); span.textContent = lbl;
row.appendChild(cb); row.appendChild(span); wrap.appendChild(row);
});
// numeric sliders
_ASPECT_FIELDS.forEach((f) => {
const row = document.createElement('div');
row.style.cssText = 'margin:5px 0;';
const head = document.createElement('div');
head.style.cssText = 'display:flex;justify-content:space-between;';
const lab = document.createElement('span'); lab.textContent = f.label;
const val = document.createElement('span');
val.style.cssText = 'color:#8fb6ff;font-variant-numeric:tabular-nums;';
head.appendChild(lab); head.appendChild(val); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step;
sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _ASPECT_DEFAULTS[f.k];
sl.dataset.k = f.k;
sl.style.cssText = 'width:100%;';
const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); };
show();
sl.addEventListener('input', () => {
_aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist();
});
row.appendChild(sl); wrap.appendChild(row);
});
// hfov override (checkbox enables a slider; off → hfovDeg=null = auto)
{
const row = document.createElement('div'); row.style.cssText = 'margin:5px 0;';
const head = document.createElement('label');
head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = Number.isFinite(t.hfovDeg);
const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°';
head.appendChild(cb); head.appendChild(lbl); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1;
sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102;
sl.disabled = !cb.checked;
sl.style.cssText = 'width:100%;';
cb.addEventListener('change', () => {
sl.disabled = !cb.checked;
_aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null;
_aspectPersist();
});
sl.addEventListener('input', () => {
if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); }
});
row.appendChild(sl); wrap.appendChild(row);
}
// live readout
_aspectPanelRO = document.createElement('div');
_aspectPanelRO.style.cssText = 'margin-top:6px;padding-top:6px;border-top:1px solid rgba(120,150,200,0.25);color:#9fb;font-variant-numeric:tabular-nums;';
_aspectPanelRO.textContent = 'aspect — · vFOV —';
wrap.appendChild(_aspectPanelRO);
// buttons
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:6px;margin-top:8px;';
const mkBtn = (txt, fn) => {
const b = document.createElement('button');
b.textContent = txt;
b.style.cssText = 'flex:1;padding:4px 0;border-radius:5px;border:1px solid rgba(120,150,200,0.4);background:rgba(40,60,90,0.6);color:#cfe0f5;cursor:pointer;font:11px system-ui;';
b.addEventListener('click', fn);
return b;
};
btnRow.appendChild(mkBtn('Reset', () => {
const t2 = _aspectTune();
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; });
t2.enabled = true; // keep panel up after reset
_aspectPersist(); _syncAspectPanel();
}));
btnRow.appendChild(mkBtn('Copy', () => {
const t2 = _aspectTune(), out = {};
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; });
const json = JSON.stringify(out, null, 2);
try { console.log('[h3d] wide-pane framing values:\n' + json); } catch (e) {}
try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {}
}));
wrap.appendChild(btnRow);
document.body.appendChild(wrap);
_aspectPanelEl = wrap;
_aspectPanelEl.style.display = 'none';
}
// Push current bridge values back into the panel controls (after Reset or an
// external edit). Cheap; only runs on demand.
function _syncAspectPanel() {
if (!_aspectPanelEl) return;
const t = _aspectTune();
_aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => {
cb.checked = !!t[cb.dataset.k];
});
_aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => {
const k = sl.dataset.k;
if (Number.isFinite(t[k])) sl.value = t[k];
sl.dispatchEvent(new Event('input')); // refresh the value label
});
}
function _setAspectPanelVisible(on) {
_ensureAspectPanel();
if (!_aspectPanelEl) return;
_aspectPanelEl.style.display = on ? 'block' : 'none';
window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish
if (on && !_aspectPanelRAF) {
const tick = () => {
if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; }
const ro = window.__h3dAspectReadout;
if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) {
_aspectPanelRO.textContent =
'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°';
}
_aspectPanelRAF = requestAnimationFrame(tick);
};
_aspectPanelRAF = requestAnimationFrame(tick);
}
}
/* ======================================================================
* Background animations (issue #13)
*
@@ -1768,7 +2045,7 @@
return _bgBandsCache;
}
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true, hitFx: 0.7, sparks: true, cinematic: true, verdictMarks: true, timingFx: true, streakFx: true, bloom: true };
// User-selectable, persistable bg styles — must mirror settings.html's
// VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective
// style reached only via _venueSceneOverride (the viz-picker Venue flow), so
@@ -2115,7 +2392,7 @@
// means (fall back to default rather than silently flipping to
// false). Add new boolean keys to BG_DEFAULTS and they pick this
// up via the dispatch below.
const _BG_BOOL_KEYS = new Set(['reactive', 'showFretOnNote', 'cameraLockLow', 'inlayLabelsVisible', 'sectionLabelsOnHighway', 'sectionHudVisible', 'nutHeadstockVisible', 'tuningLabelsVisible', 'projectionVisible', 'chordDiagramVisible', 'fpsVisible', 'toneHudVisible', 'fretDividersVisible', 'slideArrowApproachVisible', 'slideArrowNeckVisible', 'slideArrowChainPreviewVisible']);
const _BG_BOOL_KEYS = new Set(['reactive', 'showFretOnNote', 'cameraLockLow', 'inlayLabelsVisible', 'sectionLabelsOnHighway', 'sectionHudVisible', 'nutHeadstockVisible', 'tuningLabelsVisible', 'projectionVisible', 'chordDiagramVisible', 'fpsVisible', 'toneHudVisible', 'fretDividersVisible', 'slideArrowApproachVisible', 'slideArrowNeckVisible', 'slideArrowChainPreviewVisible', 'sparks', 'cinematic', 'verdictMarks', 'timingFx', 'streakFx', 'bloom']);
function _bgCoerceBool(val, fallback) {
if (val === 'true' || val === '1') return true;
if (val === 'false' || val === '0') return false;
@@ -2125,7 +2402,7 @@
// hysteresis; zoomSmoothing the zoom dead zone; tiltSmoothing the
// vertical-tilt deadband + correction strength. All three slider-
// shaped settings share the same parse + clamp behaviour.
const _BG_FLOAT_KEYS = new Set(['intensity', 'cameraSmoothing', 'zoomSmoothing', 'tiltSmoothing', 'cameraLockZoom', 'textSize', 'vibrancy', 'glow', 'chordDiagramSize', 'sectionHudSize', 'toneHudSize']);
const _BG_FLOAT_KEYS = new Set(['intensity', 'cameraSmoothing', 'zoomSmoothing', 'tiltSmoothing', 'cameraLockZoom', 'textSize', 'vibrancy', 'glow', 'chordDiagramSize', 'sectionHudSize', 'toneHudSize', 'hitFx']);
function _bgCoerce(key, val) {
if (_BG_FLOAT_KEYS.has(key)) {
const n = parseFloat(val);
@@ -2259,6 +2536,13 @@
window.h3dBgSetTextSize = (v) => _bgWriteGlobal('textSize', v);
window.h3dBgSetVibrancy = (v) => _bgWriteGlobal('vibrancy', v);
window.h3dBgSetGlow = (v) => _bgWriteGlobal('glow', v);
window.h3dBgSetHitFx = (v) => _bgWriteGlobal('hitFx', v);
window.h3dBgSetSparks = (v) => _bgWriteGlobal('sparks', !!v);
window.h3dBgSetCinematic = (v) => _bgWriteGlobal('cinematic', !!v);
window.h3dBgSetVerdictMarks = (v) => _bgWriteGlobal('verdictMarks', !!v);
window.h3dBgSetTimingFx = (v) => _bgWriteGlobal('timingFx', !!v);
window.h3dBgSetStreakFx = (v) => _bgWriteGlobal('streakFx', !!v);
window.h3dBgSetBloom = (v) => _bgWriteGlobal('bloom', !!v);
window.h3dBgSetToneHudVisible = (v) => _bgWriteGlobal('toneHudVisible', !!v);
window.h3dBgSetToneHudPosition = (v) => _bgWriteGlobal('toneHudPosition', v);
window.h3dBgSetToneHudSize = (v) => _bgWriteGlobal('toneHudSize', v);
@@ -3318,6 +3602,42 @@
let _fpsEma = 0;
let _fpsDisplay = 0;
let _fpsLastSampleT = 0;
// The FPS readout is pinned top-right of the highway overlay — the same
// corner the v3 player chrome stacks its persistent "Up Next" pill and
// live-performance HUD into, on a higher layer that paints over the
// canvas. So out of the box the readout sits *behind* that chrome and
// can't be read (exactly when you've turned it on to judge perf). Rather
// than relocate it (testers look top-right), we drop it just BELOW
// whichever of that chrome is showing. Refs are resolved once and cached
// — never a per-frame querySelector (see CLAUDE.md "never run DOM queries
// on a per-frame path") — and re-resolved only when a node detaches.
let _v3HudEls = null;
// Returns the bottom edge (in overlay-canvas px, which are 1:1 CSS px on
// this overlay) of the lowest visible top-right v3 chrome element, or 0
// when none apply (classic v2 UI, or all hidden). Only called while the
// FPS readout is actually drawn, so the layout reads cost nothing in the
// common (counter-off) case.
function _v3TopRightChromeBottom() {
if (typeof document === 'undefined' || !highwayCanvas) return 0;
// Only the v3 chrome stacks persistent HUD elements over the canvas's
// top-right. Gate on the documented detector so this is a strict no-op
// in classic v2 (where 'hud-time' also exists but sits elsewhere).
if (!(window.feedBack && window.feedBack.uiVersion === 'v3')) return 0;
if (!_v3HudEls || _v3HudEls.some((el) => el && !el.isConnected)) {
_v3HudEls = ['v3-upnext', 'v3-live-performance-hud', 'hud-time']
.map((id) => document.getElementById(id));
}
const top = highwayCanvas.getBoundingClientRect().top;
let maxBottom = 0;
for (const el of _v3HudEls) {
// offsetParent === null ⇒ display:none (a `.hidden` pill/HUD) or
// not laid out — don't duck under something that isn't shown.
if (!el || el.offsetParent === null) continue;
const b = el.getBoundingClientRect().bottom - top;
if (b > maxBottom) maxBottom = b;
}
return maxBottom;
}
let _diagChord = null;
// Chord diagram render cache. Keys: static layout inputs joined as a
// string. Values: OffscreenCanvas (or <canvas>) rendered at opacity=1
@@ -3445,6 +3765,11 @@
// that CSS-box drift and re-frame, instead of the user having to
// un/re-maximize the window.
let _appliedW = 0, _appliedH = 0;
// Last pane aspect (w/h) handed to the camera, cached so camUpdate can
// recompute the horizontal-FOV-hold each frame (and react to live
// __h3dAspectTune edits) without waiting for a resize. 0 until first
// applySize().
let _paneAspect = 0;
// True once applySize() has pinned the .h3d-wrap overlay to the
// highway canvas's offset box. Stays false while the canvas has no
// layout yet (init() can run before #highway has a real box, where
@@ -3552,6 +3877,19 @@
// linear blend every frame.
let vibrancy = BG_DEFAULTS.vibrancy;
let glowMul = BG_DEFAULTS.glow;
let _hitFx = BG_DEFAULTS.hitFx;
let _sparks = BG_DEFAULTS.sparks;
let _cinematic = BG_DEFAULTS.cinematic;
let _verdictMarks = BG_DEFAULTS.verdictMarks;
let _timingFx = BG_DEFAULTS.timingFx;
let _streakFx = BG_DEFAULTS.streakFx;
let _bloom = BG_DEFAULTS.bloom;
let _composer = null, _bloomPass = null, _bloomLoad = null, _bloomW = 0, _bloomH = 0;
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
const _SPARK_N = 256;
const _sparkSeen = new Map(); // note-key -> expiry; one burst per hit
let _juiceLastT = 0; // frame-dt clock for the juice layer
let _streakHits = 0, _streakHeat = 0; // #7 consecutive-hit escalation
let fpsVisible = BG_DEFAULTS.fpsVisible;
let fretDividersVisible = BG_DEFAULTS.fretDividersVisible;
let chordDiagramVisible = BG_DEFAULTS.chordDiagramVisible;
@@ -4035,6 +4373,11 @@
let tgtX = xFretMid(CAM_LOCK_CENTER_FRET), curX = xFretMid(CAM_LOCK_CENTER_FRET);
let tgtDist = CAM_DIST_BASE, curDist = CAM_DIST_BASE;
// Dolly-back multiplier applied to the curDist lerp target by camUpdate's
// fret-row fit guard. 1 = no extra pull-back (the common case); rises
// toward FRET_ROW_FIT_BOOST_MAX only when a tight, centred zoom would push
// the fret-number row past the bottom edge, then relaxes back to 1.
let _fretRowFitBoost = 1;
// Last committed lowFretBonus contribution baked into tgtDist
// (see candidateDist block — bonus is applied on top of the
// hysteresis-gated base).
@@ -5905,17 +6248,30 @@
scene = new T.Scene();
scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2);
cam = new T.PerspectiveCamera(70, 1, 0.01, FOG_END * 3);
cam = new T.PerspectiveCamera(BASE_VFOV, 1, 0.01, FOG_END * 3);
ambLight = new T.AmbientLight(0xffffff, 0.85);
scene.add(ambLight);
dirLight = new T.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(40 * K, 120 * K, 80 * K);
scene.add(dirLight);
_applyCinematic();
fretG = new T.Group(); scene.add(fretG);
tuningLblG = new T.Group(); scene.add(tuningLblG);
noteG = new T.Group(); scene.add(noteG);
// Hit sparks (#3): a pooled additive Points cloud; a small burst fires at a
// gem on a verified hit (spawned in the verdict block, advanced in the render loop).
_sparkPos = new Float32Array(_SPARK_N * 3); _sparkCol = new Float32Array(_SPARK_N * 3);
_sparkVel = new Float32Array(_SPARK_N * 3); _sparkLife = new Float32Array(_SPARK_N);
{
const sg = new T.BufferGeometry();
sg.setAttribute('position', new T.BufferAttribute(_sparkPos, 3).setUsage(T.DynamicDrawUsage));
sg.setAttribute('color', new T.BufferAttribute(_sparkCol, 3).setUsage(T.DynamicDrawUsage));
const sm = new T.PointsMaterial({ size: 1.0 * K, vertexColors: true, transparent: true, opacity: 0.8, depthWrite: false, blending: T.AdditiveBlending, sizeAttenuation: true });
_sparkPts = new T.Points(sg, sm); _sparkPts.frustumCulled = false; _sparkPts.renderOrder = 8;
scene.add(_sparkPts);
}
beatG = new T.Group(); scene.add(beatG);
lblG = new T.Group(); scene.add(lblG);
@@ -6134,6 +6490,13 @@
transparent: true, opacity: 1.0, depthWrite: false,
}));
mHitBrightArrays = mHitBright.map(m => [m, m, m, m, mEdgeTransparent, mEdgeTransparent]);
// Readability (#2 / charrette): the note gems + their outlines punch THROUGH
// the distance fog so upcoming notes stay legible as they render in at the
// horizon. The board, lane, sustains and background scenery keep their
// atmospheric fog — only the note-defining materials are exempted, so the
// highway still reads as deep while the notes never dissolve into the haze.
[mWhiteOutline, mMissOutline].forEach(m => { if (m) m.fog = false; });
[mStr, mGlow, mStrHitOutline, mHitBright].forEach(arr => arr && arr.forEach(m => { if (m) m.fog = false; }));
// Outline materials render at a lower renderOrder than the body.
// The body is rendered on top with opacity:1 on hit/miss, which
// fully covers the outline center — only the fringe that extends
@@ -7191,7 +7554,7 @@
color: '#66c7ff',
});
}
return { s: note.s, f: note.f, noteTime: d.noteTime, labels };
return { s: note.s, f: note.f, noteTime: d.noteTime, labels, timingState: d.timingState || null };
};
const _ndPushMark = (arr, d) => {
const mark = _ndNormalizeMark(d);
@@ -7347,6 +7710,14 @@
textSize = _bgReadSetting(panelKey, 'textSize');
vibrancy = _bgReadSetting(panelKey, 'vibrancy');
glowMul = _bgReadSetting(panelKey, 'glow');
_hitFx = _bgReadSetting(panelKey, 'hitFx');
_sparks = _bgReadSetting(panelKey, 'sparks');
_cinematic = _bgReadSetting(panelKey, 'cinematic');
_verdictMarks = _bgReadSetting(panelKey, 'verdictMarks');
_timingFx = _bgReadSetting(panelKey, 'timingFx');
_streakFx = _bgReadSetting(panelKey, 'streakFx');
_bloom = _bgReadSetting(panelKey, 'bloom');
_applyCinematic();
fpsVisible = _bgReadSetting(panelKey, 'fpsVisible');
fretDividersVisible = _bgReadSetting(panelKey, 'fretDividersVisible');
chordDiagramVisible = _bgReadSetting(panelKey, 'chordDiagramVisible');
@@ -7785,6 +8156,85 @@
: d;
return parseInt(s.slice(1), 16);
}
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
// surround to pop against; strengthen the key light for modelling.
// Toggle via the 'cinematic' setting so it's directly comparable.
function _applyCinematic() {
if (!ambLight || !dirLight) return;
ambLight.intensity = _cinematic ? 0.45 : 0.85;
dirLight.intensity = _cinematic ? 1.15 : 0.8;
}
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
// late amber. Falls back to green when timing is unknown (pure-provider path).
function _timingHex(ts) {
if (!_timingFx || !ts || ts === 'OK') return 0x22ff88;
if (ts === 'EARLY') return 0x35d6ff;
if (ts === 'LATE') return 0xffb84d;
return 0x22ff88;
}
function _sparkBurst(x, y, z, hex, count) {
if (!_sparkPts || count <= 0) return;
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
let made = 0;
for (let i = 0; i < _SPARK_N && made < count; i++) {
if (_sparkLife[i] > 0) continue;
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
}
}
function _sparkUpdate(dt) {
if (!_sparkPts) return;
const grav = 55 * K; let any = false;
for (let i = 0; i < _SPARK_N; i++) {
if (_sparkLife[i] <= 0) continue;
const j = i * 3;
_sparkLife[i] -= dt;
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
any = true;
_sparkVel[j + 1] -= grav * dt;
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
const fade = 1 - Math.min(1, dt * 3.2);
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
}
_sparkPts.geometry.attributes.position.needsUpdate = true;
_sparkPts.geometry.attributes.color.needsUpdate = true;
_sparkPts.visible = any;
}
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
// the composer once ready, or null (caller falls back to a direct render).
function _bloomEnsure() {
if (_composer) return _composer;
if (_bloomLoad || !ren || !scene || !cam) return null;
const A = '/static/vendor/three/addons/';
_bloomLoad = Promise.all([
import(A + 'postprocessing/EffectComposer.js'),
import(A + 'postprocessing/RenderPass.js'),
import(A + 'postprocessing/UnrealBloomPass.js'),
import(A + 'postprocessing/OutputPass.js'),
]).then(([EC, RP, UB, OP]) => {
try {
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
// survives the bloom path — EffectComposer's default target has no
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
// DPR1 displays that have no supersampling cushion).
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
const comp = new EC.EffectComposer(ren, _bloomRT);
comp.addPass(new RP.RenderPass(scene, cam));
_bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
comp.addPass(_bloomPass);
comp.addPass(new OP.OutputPass());
comp.setSize(w, h);
_bloomW = w; _bloomH = h; _composer = comp;
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); _composer = null; }
}).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e));
return null;
}
function buildBoard() {
// Dispose before clearing (traverse: nut/headstock may live in a Group).
while (fretG.children.length) {
@@ -12585,6 +13035,7 @@
// blocks, so _showHit can be a const and _ndGood is available for the
// sustain trail (which renders even when skipBody=true for slide targets).
let _ndGood = false; // true when provider confirms hit/active
let _hitPunch = 1; // #3 per-gem scale-punch on a fresh hit
let _ndState = null; // 'hit'|'active'|'miss'|null; null → fall back to proximity heuristic
let _ndCs = null; // raw provider response — truthy when provider returned a verdict
let _ndCsIsObj = false; // typeof _ndCs === 'object'
@@ -12760,12 +13211,27 @@
// hit/active → green outline (mHitBright[s]) + green lateral faces;
// miss → magenta-red outline (mMissOutline) + dark lateral faces; front/back stay transparent.
if (_ndCs) {
const _vAlpha = (_ndCsIsObj && typeof _ndCs.alpha === 'number') ? _ndCs.alpha : 1;
if (_ndState === 'miss') {
_ndOutline = mMissOutline;
_ndFaceMat = mMissEdgeArrays;
_streakHits = 0; // #7 break the streak (heat eases down)
if (_verdictMarks) _ndLabels.push({ x, y: y + NH * 1.7, z: noteZ + 0.02, labels: [{ text: '✗', color: '#ff5a7a' }] }); // #6
} else if (_ndGood) {
_ndOutline = mHitBright[s] ?? mGlow[s];
_ndFaceMat = mHitBrightArrays[s] ?? null;
_hitPunch = 1 + 0.22 * _hitFx * _vAlpha; // #3 scale-punch (biggest at strike, eases)
if (_verdictMarks) { const _tc = _timingHex(_ndMatchedMark && _ndMatchedMark.timingState); _ndLabels.push({ x, y: y + NH * 1.7, z: noteZ + 0.02, labels: [{ text: '✓', color: '#' + _tc.toString(16).padStart(6, '0') }] }); } // #6 + #5
if (_sparks && _hitFx > 0 && _vAlpha > 0.5) {
const _spk = s + '|' + n.f + '|' + n.t.toFixed(2);
if (!(_sparkSeen.get(_spk) > now)) {
_sparkSeen.set(_spk, now + 1.0);
if (_sparkSeen.size > 600) _sparkSeen.clear();
_streakHits++;
const _heatMul = _streakFx ? (1 + 0.85 * _streakHeat) : 1; // #7 escalate
_sparkBurst(x, y, noteZ, _timingHex(_ndMatchedMark && _ndMatchedMark.timingState), Math.round((4 + 7 * _hitFx) * _heatMul));
}
}
}
}
@@ -12878,6 +13344,7 @@
} else {
core.scale.set(rimXY, rimXY, 2.5 * rimZ);
}
if (_hitPunch !== 1) core.scale.multiplyScalar(_hitPunch); // #3 hit scale-punch
// Fret digits on fretted (n.f > 0) flying notes deliberately
// omitted: the showFretOnNote setting and its UI helper text
// promise digits on the fretboard ghost only, never on the
@@ -13720,13 +14187,81 @@
ctx.restore();
}
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
// camera should use for the given pane aspect. With the bridge off (or
// absent), or at/under the start aspect, it returns the base vertical
// fov unchanged — an exact no-op, so normal panes render identically to
// before. Past the start aspect it lowers the vertical fov to keep the
// horizontal cone ~constant, so the neck fills an ultra-wide pane
// instead of collapsing into a central sliver. Pure + finite-guarded.
function effectiveVfov(aspect, tune) {
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
? tune.startAspect : HORPLUS_START_ASPECT;
if (aspect <= start) return base;
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
const DEG = Math.PI / 180;
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
// cone the base vertical fov produces at the start aspect.
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
? tune.hfovDeg * DEG
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
// Vertical fov that reproduces that horizontal cone at this aspect.
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
if (!Number.isFinite(vfov)) return base;
return Math.max(floor, Math.min(base, vfov));
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
// _aspectTune() returns the live bridge object, seeded from defaults
// + localStorage on first read so a persisted tuning session applies
// on load without opening the panel. Every field is finite-coerced.
// When disabled (or splitOnly and not in a split) the tune is treated
// as null, so effectiveVfov returns the base vertical fov and cam.fov
// is restored to it. The fov write is guarded on an actual change so
// a steady pane costs nothing.
const _aspTune = _aspectTune();
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
const _vfov = effectiveVfov(_paneAspect, _tune);
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
// Publish a live readout for the tuner panel (only while it's open,
// so the steady path stays allocation-free). Last pane to render wins
// the slot — fine, all panes share the same aspect in a split layout.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
_ro.aspect = _paneAspect; _ro.vfov = _vfov;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled);
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
? _tune.lookDepthMul : 1;
curX += (tgtX - curX) * lerp;
curDist += (tgtDist - curDist) * lerp;
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
curDist += (tgtDist * _fretRowFitBoost - curDist) * lerp;
const dist = curDist * aspectScale;
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
@@ -13738,6 +14273,9 @@
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K;
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// Optional wide-pane pose nudges (default identity → no-op).
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
@@ -13746,7 +14284,7 @@
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
const _freeCam = window.__h3dCamCtl;
const _lookAtZ = -FOCUS_D * 0.35;
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
@@ -13767,7 +14305,7 @@
// This lets the camera adapt to any panel aspect ratio automatically.
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY, -FOCUS_D * 0.35); // tentative look — needed for project()
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
@@ -13796,7 +14334,39 @@
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY, _lookAtZ);
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
// Project the fret-number-row band (just below the lowest string, at
// the play line) with the final camera. If it sits below the safe
// bottom line, dolly back (raise _fretRowFitBoost → applied to the
// curDist lerp target next frame) until it clears; relax lazily once
// there's comfortable headroom. Asymmetric + deadbanded so it
// converges without hunting, and capped so the zoom can't pop. It
// cooperates with the tilt loop above rather than fighting it: pulling
// back shrinks the scene, the tilt loop keeps the board centre anchored
// at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped
// while the free-cam (Camera Director) owns the view.
if (_freeCam && _freeCam.enabled) {
if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1;
} else {
cam.updateMatrixWorld();
const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4;
_probe.set(curX, _rowY, 0.5 * K);
_probe.project(cam); // _probe.y → NDC; < -1 = off the bottom
const _rowNdcY = _probe.y;
if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) {
// Row below the safe line → pull back promptly, proportional to
// the deficit so it converges in a few frames without overshoot.
const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY;
_fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX,
_fretRowFitBoost + Math.min(0.05, _need * 0.4));
} else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND
&& _fretRowFitBoost > 1) {
// Comfortable headroom → relax the dolly back toward normal, lazily.
_fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01);
}
}
}
@@ -13861,6 +14431,10 @@
cam.aspect = w / h;
cam.updateProjectionMatrix();
aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5));
// Cache the pane aspect for the horizontal-FOV-hold in camUpdate.
// cam.fov itself is owned by camUpdate (not set here) so live
// __h3dAspectTune edits apply every frame without a resize.
_paneAspect = cam.aspect;
_appliedW = w; _appliedH = h;
}
@@ -14012,6 +14586,8 @@
for (const g of _ownedSharedGeos) g?.dispose?.();
_ownedSharedGeos.length = 0;
txtCache = {};
if (_sparkPts) { try { _sparkPts.geometry.dispose(); _sparkPts.material.dispose(); } catch (e) {} _sparkPts = null; }
if (_composer) { try { _composer.dispose(); if (_bloomPass && _bloomPass.dispose) _bloomPass.dispose(); } catch (e) {} _composer = null; _bloomPass = null; }
if (ren) { ren.dispose(); ren = null; }
scene = cam = noteG = beatG = lblG = fretG = tuningLblG = null;
ambLight = dirLight = null;
@@ -14052,7 +14628,7 @@
pFretColMarker = null;
_fretMarkerWaveCache.clear();
gNote = gSus = gBeat = gTapChevron = null;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; nStr = NSTR; _oobStringWarned = false;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; _fretRowFitBoost = 1; nStr = NSTR; _oobStringWarned = false;
_lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET);
_lookaheadFretSpan = DEFAULT_LOOKAHEAD_FRET_SPAN;
_lookaheadCamPrevNow = null;
@@ -14102,6 +14678,7 @@
}
_destroyed = _isReady = false;
_isFocused = true;
_registerAspectAbShortcut(); // session-global A/B toggle (self-guarded)
const myToken = ++_initToken;
highwayCanvas = canvas;
_invertedCached = !!(bundle && bundle.inverted);
@@ -14337,7 +14914,27 @@
}
bcCtrl.render();
}
pbBeg(6); ren.render(scene, cam); pbEnd(6);
{
const _jNow = performance.now();
const _jdt = _juiceLastT === 0 ? 1 / 60 : Math.min(0.05, (_jNow - _juiceLastT) / 1000);
_juiceLastT = _jNow;
_sparkUpdate(_jdt);
_streakHeat += (Math.min(1, _streakHits / 16) - _streakHeat) * 0.08; // #7 ease heat
}
{
const comp = (_bloom && !_ssActive()) ? _bloomEnsure() : null;
if (comp) {
const bsz = canvasSize(highwayCanvas);
if (bsz && bsz.w > 0 && bsz.h > 0 && (bsz.w !== _bloomW || bsz.h !== _bloomH)) {
comp.setSize(bsz.w | 0, bsz.h | 0); _bloomW = bsz.w | 0; _bloomH = bsz.h | 0;
}
if (ren.toneMapping !== T.ACESFilmicToneMapping) ren.toneMapping = T.ACESFilmicToneMapping;
pbBeg(6); comp.render(); pbEnd(6);
} else {
if (ren.toneMapping !== T.NoToneMapping) ren.toneMapping = T.NoToneMapping;
pbBeg(6); ren.render(scene, cam); pbEnd(6);
}
}
if (lyricsCtx && lyricsCanvas) {
lyricsCtx.clearRect(0, 0, lyricsCanvas.width, lyricsCanvas.height);
// Capture the actual lyrics-banner bottom so overlay cards
@@ -14391,7 +14988,13 @@
const _fpsBoxW = Math.ceil(_fpsMetrics.width) + _fpsPadX * 2;
const _fpsBoxH = 14 + _fpsPadY * 2;
const _fpsE = 8;
const _fpsBaseY = Math.round(Math.max(_fpsE + H * 0.06, lyricsBottom + _fpsE));
// Keep it top-right but below the v3 Up Next pill / live HUD
// (whichever is showing) so the readout is never occluded.
const _fpsBaseY = Math.round(Math.max(
_fpsE + H * 0.06,
lyricsBottom + _fpsE,
_v3TopRightChromeBottom() + _fpsE,
));
const _fpsX = W - 8 - _fpsBoxW;
const _fpsY = _fpsBaseY + cornerStack['tr'];
lyricsCtx.fillStyle = 'rgba(0,0,0,0.55)';
@@ -14494,6 +15097,8 @@
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
_lastHwW = 0; _lastHwH = 0;
_appliedW = 0; _appliedH = 0;
_paneAspect = 0;
if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); }
_wrapPinned = false;
_unsubscribeFocus(); teardown();
highwayCanvas = null;
+103
View File
@@ -577,6 +577,80 @@
<span>Glowy</span>
</div>
</div>
<div class="mt-4">
<label for="h3d-hitfx" class="text-xs font-medium text-gray-400 mb-1 block">
Hit feedback intensity: <span id="h3d-hitfx-label">0.70</span>
</label>
<input type="range" id="h3d-hitfx" min="0" max="1" step="0.05" value="0.70"
oninput="document.getElementById('h3d-hitfx-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
onchange="window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
class="w-full accent-accent">
<p class="text-[10px] text-gray-500 mt-1">
How much "juice" a nailed note gets — the strike-line flash and the
spark burst at the hit line. <em>0</em> = colour verdict only (no sparks).
</p>
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-sparks" class="text-xs font-medium text-gray-400">
Hit sparks
<span class="block text-[10px] text-gray-500 font-normal">The particle burst that pops off a note the instant it's detected as a hit. Turn off for a calmer highway — the strike-line flash and colour verdict stay.</span>
</label>
<input type="checkbox" id="h3d-sparks" checked
onchange="window.h3dBgSetSparks && window.h3dBgSetSparks(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-cinematic" class="text-xs font-medium text-gray-400">
Cinematic lighting
<span class="block text-[10px] text-gray-500 font-normal">Darker stage so the glowing notes pop against it. Turn off for the brighter, flatter look.</span>
</label>
<input type="checkbox" id="h3d-cinematic" checked
onchange="window.h3dBgSetCinematic && window.h3dBgSetCinematic(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-streakfx" class="text-xs font-medium text-gray-400">
Streak feedback
<span class="block text-[10px] text-gray-500 font-normal">A clean run quietly "heats up" — bigger sparks the longer you stay accurate. Eases back on a miss.</span>
</label>
<input type="checkbox" id="h3d-streakfx" checked
onchange="window.h3dBgSetStreakFx && window.h3dBgSetStreakFx(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-verdictmarks" class="text-xs font-medium text-gray-400">
Accessible verdict marks (✓ / ✗)
<span class="block text-[10px] text-gray-500 font-normal">Adds a shape mark to each hit/miss so the result doesn't rely on the green/red colour pair alone.</span>
</label>
<input type="checkbox" id="h3d-verdictmarks" checked
onchange="window.h3dBgSetVerdictMarks && window.h3dBgSetVerdictMarks(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-bloom" class="text-xs font-medium text-gray-400">
Glow bloom
<span class="block text-[10px] text-gray-500 font-normal">Real light-bleed around the glowing notes and hit flash (higher fidelity). Turns itself off in split-screen. If your machine struggles, turn this off first.</span>
</label>
<input type="checkbox" id="h3d-bloom" checked
onchange="window.h3dBgSetBloom && window.h3dBgSetBloom(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-timingfx" class="text-xs font-medium text-gray-400">
Timing feedback
<span class="block text-[10px] text-gray-500 font-normal">Colours a hit by your timing — on-time green, a touch <span style="color:#35d6ff">early (cyan)</span> or <span style="color:#ffb84d">late (amber)</span> — so you can feel where you sit in the beat.</span>
</label>
<input type="checkbox" id="h3d-timingfx" checked
onchange="window.h3dBgSetTimingFx && window.h3dBgSetTimingFx(this.checked)"
class="accent-accent mt-0.5">
</div>
</div>
</details>
</div>
@@ -1299,6 +1373,35 @@
if (thsi) thsi.value = String(toneHudSize);
if (thslbl) thslbl.textContent = toneHudSize.toFixed(2);
// Hit-feedback "juice" controls — hydrate from saved state so the
// panel reflects persistence on reopen (the renderer already reads
// these via _bgReadSetting; without this the controls always showed
// their default markup, misrepresenting a saved non-default). Reads
// h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
// hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' rules.
try {
const _bgBool = (k, def) => {
const v = localStorage.getItem('h3d_bg_' + k);
return v == null ? def : !(v === 'false' || v === '0');
};
const _setChk = (id, on) => { const el = document.getElementById(id); if (el) el.checked = on; };
_setChk('h3d-sparks', _bgBool('sparks', true));
_setChk('h3d-cinematic', _bgBool('cinematic', true));
_setChk('h3d-streakfx', _bgBool('streakFx', true));
_setChk('h3d-verdictmarks', _bgBool('verdictMarks', true));
_setChk('h3d-bloom', _bgBool('bloom', true));
_setChk('h3d-timingfx', _bgBool('timingFx', true));
const _hf = document.getElementById('h3d-hitfx');
if (_hf) {
let v = parseFloat(localStorage.getItem('h3d_bg_hitFx'));
if (!isFinite(v)) v = 0.70;
v = Math.max(0, Math.min(1, v));
_hf.value = String(v);
const _hfl = document.getElementById('h3d-hitfx-label');
if (_hfl) _hfl.textContent = v.toFixed(2);
}
} catch (_) { /* storage blocked — controls keep their default markup */ }
// (3D Highway palette picker removed — string colors are now set
// via the core "Highway String Colors" UI above, which drives both
// the 2D and 3D highways. The bg-settings 'palette' key still exists
+10 -9
View File
@@ -51,15 +51,16 @@
sources = sources.filter((s) => s
&& !/midi/i.test(String(s.providerId || ''))
&& !/^midi-input/i.test(String(s.label || '')));
// De-dupe by display label — the desktop engine enumerates the same
// device under several driver types, so the same name can repeat.
const seen = new Set();
sources = sources.filter((s) => {
const key = String(s.label || '').toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// No label de-dupe here. The audio-input capability already
// collapses exact duplicates by logicalSourceKey
// (_visibleInputSources), so nothing it returns shares a key. A
// device that enumerates under several driver types (ASIO / Windows
// Audio / DirectSound) has a DISTINCT key per type and is now
// labelled with its driver type (e.g. "Focusrite (ASIO)") — each is
// a real, separately-selectable input the user must be able to see.
// The old bare-label collapse also kept whichever variant sorted
// first, which could silently drop the one that was actually
// `selected` below.
const selected = sources.find((s) => s && s.selected) || null;
return { sources, selected };
} catch (_) { return { sources: [], selected: null }; }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.1",
"version": "1.3.2",
"bundled": true,
"private": false,
"script": "screen.js",
@@ -18,6 +18,8 @@
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_LABEL_H = 12; // px height of each drum label
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
var _TUNER_IN_TUNE_THRESHOLD = 2;
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
@@ -321,9 +323,34 @@
currentAngle += (targetAngle - currentAngle) * lf;
_setNeedle(currentAngle);
// Stop once the needle has settled on its target — a static needle
// needs no repaint. update() re-kicks the loop when a new reading
// moves the target, so this idles the always-on tuner (no signal /
// steady pitch) instead of pinning a core at 60 fps forever.
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
currentDrumY = targetDrumY; currentAngle = targetAngle;
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
_setNeedle(currentAngle);
rafId = null;
return;
}
rafId = requestAnimationFrame(_animate);
}
// Restart the loop only when there's actually something to animate toward
// (a new target). Reset lastTime so the first frame after an idle gap
// doesn't take one big easing step.
function _kick() {
if (rafId === null
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
lastTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────
@@ -360,6 +387,7 @@
bulbEl.style.backgroundColor = '#2a1010';
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
_kick(); // animate back to rest, then the loop self-stops
return;
}
@@ -376,6 +404,7 @@
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
}
_kick(); // a new reading moved the target → run until it settles
}
function destroy() {
+13
View File
@@ -620,8 +620,20 @@
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
_applyTickStates();
// No signal and both the glow and strobe drift have fully settled →
// idle the loop. update() re-kicks it on the next note.
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
_mt3RafId = null;
_mt3LastTime = null;
return;
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
// ── MODE button ───────────────────────────────────────────────
@@ -677,6 +689,7 @@
_renderNote(' ');
_applyAccidental();
}
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
@@ -354,10 +354,19 @@
if (_smoothedCents > 0) { speed = -speed; }
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
} else if (_currentCents === 0) {
// Fully decelerated and no live signal → idle the loop instead of
// rescheduling forever. update() re-kicks it on the next note.
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
}
_rafId = requestAnimationFrame(_animateStrobe);
// ── Helper: derive octave number from frequency ───────────────
@@ -439,6 +448,7 @@
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
_currentCents = hasNote ? cents : 0;
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
+12
View File
@@ -142,9 +142,20 @@ window._tunerViz_strobe = function (container) {
strobeEl.style.opacity = '0';
}
// Idle the loop when there's no live signal — the strobe only needs to
// paint while a note is sounding. update() re-kicks it on the next note,
// so a silent tuner stops repainting instead of spinning at 60 fps.
if (!strobeActive) { rafId = null; return; }
rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (rafId === null) {
lastAnimateTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────────
@@ -188,6 +199,7 @@ window._tunerViz_strobe = function (container) {
const inTune = Math.abs(cents) < 5;
strobeEl.style.opacity = inTune ? '1' : '0.6';
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
_kick();
}
function destroy() {
@@ -122,6 +122,26 @@
plungerEl.style.left = _leftPct.toFixed(2) + '%';
plungerEl.style.top = _topPct.toFixed(2) + '%';
// No live signal and the plunger has eased back to its resting centre
// → idle the loop. update() re-kicks it on the next note.
if (_currentNote === null && !_plungerDipped
&& Math.abs(targetLeft - _leftPct) < 0.05) {
_leftPct = targetLeft;
plungerEl.style.left = _leftPct.toFixed(2) + '%';
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (_rafId !== null) return;
// Already parked at rest with no signal → nothing to animate, stay idle.
if (_currentNote === null && !_plungerDipped
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
_lastTime = null;
_rafId = requestAnimationFrame(_animate);
}
@@ -130,6 +150,7 @@
_currentNote = note;
_currentCents = note === null ? 0 : cents;
if (!_plungerDipped) { noteEl.textContent = note || ''; }
_kick(); // a new reading may move the plunger → ensure the loop runs
}
function destroy() {
+910 -22
View File
File diff suppressed because it is too large Load Diff
+642 -15
View File
@@ -460,6 +460,25 @@ function _shortcutDispatchBlocked(e) {
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);
}
@@ -1033,6 +1052,11 @@ async function showScreen(id) {
const audio = document.getElementById('audio');
const stopTime = _audioTime();
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || 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 highway.stop()/audio unload, while getSongInfo() and
// the position (stopTime) are still live.
if (hadPlayableSong) _snapshotResumeSession(stopTime);
highway.stop();
// Cancel any queued seeks, in-flight shim closures, AND active
// count-in timers before stopping playback so none of these paths
@@ -1082,7 +1106,7 @@ const _LIB_VIEW_KEY = 'feedBack.libView';
const _LIB_SORT_KEY = 'feedBack.libSort';
const _LIB_FORMAT_KEY = 'feedBack.libFormat';
const _LIB_PROVIDER_KEY = 'feedBack.libProvider';
const _LIB_VIEW_VALUES = new Set(['grid', 'tree']);
const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
const _LIB_SORT_VALUES = new Set([
'artist', 'artist-desc', 'title', 'title-desc',
'recent', 'year-desc', 'year', 'tuning',
@@ -1760,8 +1784,20 @@ function setLibView(view) {
document.getElementById('lib-tree').classList.toggle('hidden', view !== 'tree');
document.querySelectorAll('.lib-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid'));
document.querySelectorAll('.lib-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree'));
document.querySelectorAll('.lib-nontree-ctrl').forEach(el => el.classList.toggle('hidden', view === 'tree'));
document.getElementById('view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
document.getElementById('view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
// Folder view
const folderTreeEl = document.getElementById('lib-folder-tree');
if (folderTreeEl) folderTreeEl.classList.toggle('hidden', view !== 'folder');
const folderCtrlEl = document.getElementById('lib-folder-controls');
if (folderCtrlEl) folderCtrlEl.classList.toggle('hidden', view !== 'folder');
// The folder-view toolbar button only exists in the classic (v2) markup;
// setLibView also runs at v3 startup where it's absent, so guard it (the
// grid/tree buttons above predate this and exist on both paths).
const folderBtnEl = document.getElementById('view-folder-btn');
if (folderBtnEl) folderBtnEl.className = `px-3 py-2.5 text-sm transition ${view === 'folder' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
if (libView === 'folder' && view !== 'folder') window.folderLibrary?.unload?.();
if (view !== 'grid') stopInfiniteScroll();
_libEpoch++;
// View toggle changes which container `_libNavItems` resolves
@@ -1774,11 +1810,32 @@ function setLibView(view) {
async function loadLibrary(page) {
if (libView === 'grid') {
await loadGridPage(page !== undefined ? page : currentPage);
} else {
} else if (libView === 'tree') {
await loadTreeView();
} else if (libView === 'folder') {
if (window.folderLibrary) await window.folderLibrary.load();
}
// v3 Songs page manages its own view state independently of libView — if
// lib-folder-tree is visible, the folder library must also react to filter changes.
if (libView !== 'folder' && window.folderLibrary) {
const treeEl = document.getElementById('lib-folder-tree');
if (treeEl && !treeEl.classList.contains('hidden')) {
await window.folderLibrary.load();
}
}
}
// ── Folder Library: filter bridge ─────────────────────────────────────────
// Serialises the active lib filter state as URL params so the plugin can pass
// them to /api/plugins/folder_library/tree — the same pattern grid and tree
// views use when sending filter params to their own backend endpoints.
window.feedBackLibFilterParams = function() {
var p = new URLSearchParams();
_applyLibFiltersToParams(p);
return p.toString();
};
async function _fetchJsonOrThrow(url) {
const resp = await fetch(url);
const raw = await resp.text();
@@ -3359,6 +3416,10 @@ async function loadSettings() {
if (leftyEl) leftyEl.checked = 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).
@@ -4482,6 +4543,9 @@ async function rescanLibrary() {
_treeStats = null;
_tuningNames = null; // re-fetch on next drawer open
loadLibrary();
// Tell the v3 Songs grid the library changed so it reloads instead of
// keeping a cached (e.g. pre-DLC, empty) grid until an app restart.
if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' });
}
}, 1000);
}
@@ -4510,6 +4574,9 @@ async function fullRescanLibrary() {
_treeStats = null;
_tuningNames = null; // re-fetch on next drawer open
loadLibrary();
// Tell the v3 Songs grid the library changed so it reloads instead of
// keeping a cached (e.g. pre-DLC, empty) grid until an app restart.
if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' });
}
}, 1000);
}
@@ -5851,6 +5918,32 @@ Object.defineProperty(window.feedBack, 'autoplayExit', {
get: _autoplayExitEnabled, configurable: true,
});
// ── "Up Next" pill (global option, default ON) ────────────────────────
// Gates the v3 player chrome's persistent upcoming-section pill
// (#v3-upnext, driven by player-chrome.js's updateUpNext). Client-only
// localStorage pref (`showUpNext`); absence of the key means enabled.
// player-chrome.js reads window.feedBack.showUpNext each tick and hides
// the pill when off.
function _showUpNextEnabled() {
try { return localStorage.getItem('showUpNext') !== '0'; } catch (_) { return true; }
}
// Settings checkbox setter (onchange="setShowUpNext(this.checked)").
window.setShowUpNext = function (on) {
try { localStorage.setItem('showUpNext', on ? '1' : '0'); } catch (_) { /* private mode */ }
const el = document.getElementById('setting-show-upnext');
if (el && el.checked !== !!on) el.checked = !!on;
// Reflect immediately when disabling mid-playback; the chrome's rAF
// loop (~6 Hz) re-shows it when re-enabled and a section is upcoming.
if (!on) {
const pill = document.getElementById('v3-upnext');
if (pill) pill.classList.add('hidden');
}
};
// Read-only view for the player chrome (and any plugin) to gate the pill.
Object.defineProperty(window.feedBack, 'showUpNext', {
get: _showUpNextEnabled, configurable: true,
});
// "Countdown before song" (Gameplay tab). Mirrored to localStorage by
// loadSettings so the song-start path can read it synchronously here — no
// async /api/settings fetch on the play hot path. Defaults off.
@@ -5900,16 +5993,245 @@ let _pendingAutostart = false;
window.feedBack.on('song:ready', () => {
if (!_pendingAutostart) return;
_pendingAutostart = false;
if (!_autoplayExitEnabled() || isPlaying) return;
if (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);
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
}
// 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) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
return;
}
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
if (_countdownBeforeSongEnabled()) {
// The count-in (~2.5s) gives the credits their on-screen dwell.
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
} else if (authors.length) {
// No count-in window — hold the credits a couple seconds, then start.
// _cancelCountIn() and changeArrangement() both clear _creditsTimer, so
// a teardown / arrangement switch during the hold cancels this play.
_creditsTimer = setTimeout(() => {
_creditsTimer = null;
// If playback doesn't actually start (e.g. HTML5 autoplay rejection),
// song:play never fires — clear the credits promptly rather than
// waiting for the backstop. On success the song:play listener owns it.
Promise.resolve(togglePlay())
.then(() => { if (!isPlaying) hideSongCreditsOverlay(); })
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
}, _CREDITS_HOLD_MS);
} else {
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
}
});
// ── Resume last session ────────────────────────────────────────────────────
// Leaving a song snapshots where you were — song, arrangement, position, and
// speed — so an exit (especially an accidental one, now that Escape reliably
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
// The snapshot is offered back through a non-blocking "Resume" pill; it never
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
// (This is the player-session slice; the broader nav/state-resume work — e.g.
// returning to a song after wandering into Settings → Tone Builder — is a
// separate, larger track.)
const _RESUME_KEY = 'feedBack.resumeSession';
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
let _pendingResume = null; // {position, speed}, consumed at song:ready
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
function _curPlaybackSpeed() {
try {
return window._juceMode
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
: (document.getElementById('audio')?.playbackRate || 1);
} catch (_) { return 1; }
}
// Snapshot the live session. Called from showScreen()'s teardown before
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
function _snapshotResumeSession(position) {
try {
if (!currentFilename) return;
const si = (window.highway && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const dur = Number(si.duration) || 0;
const pos = Number(position) || 0;
// Only worth resuming a song you were genuinely mid-way through — not a
// glance at the first seconds, and not one that already basically ended.
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
const snap = {
f: currentFilename,
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
? si.arrangement_index : undefined,
t: pos,
sp: _curPlaybackSpeed(),
title: si.title || '',
artist: si.artist || '',
ts: Date.now(),
};
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
// A fresh snapshot earns one offer — undo any earlier dismissal.
_resumePillDismissed = false;
} catch (_) { /* storage unavailable — resume is best-effort */ }
}
function _readResumeSession() {
try {
const raw = localStorage.getItem(_RESUME_KEY);
if (!raw) return null;
const snap = JSON.parse(raw);
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
return snap;
} catch (_) { return null; }
}
function _clearResumeSession() {
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
}
// Re-enter the snapshotted song and restore arrangement + position + speed.
async function resumeLastSession() {
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return false; }
_hideResumePill();
try {
await playSong(snap.f, snap.a, {
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
});
} catch (err) {
// A transient load/connect failure must not strand the user: keep the
// snapshot so the pill can re-offer it on the next non-player screen,
// rather than consuming the only copy before the song actually loaded.
console.warn('[app] resume failed to load; keeping snapshot:', err);
_pendingResume = null;
return false;
}
_clearResumeSession(); // consumed only after a successful load
return true;
}
window.resumeLastSession = resumeLastSession;
if (window.feedBack) window.feedBack.resumeLastSession = resumeLastSession;
// Consume a pending resume once the chart is ready: restore speed, seek to the
// saved position, then (if autoplay is on) start from there. playSong() does
// NOT arm autostart for a resume load, so the two never fight over playback.
window.feedBack.on('song:ready', () => {
const pend = _pendingResume;
if (!pend) return;
_pendingResume = null;
try {
if (pend.speed && pend.speed > 0) {
const slider = document.getElementById('speed-slider');
if (slider) slider.value = String(Math.round(pend.speed * 100));
setSpeed(pend.speed);
}
} catch (_) { /* speed restore is best-effort */ }
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] resume failed:', err));
});
// A song that finishes on its own has nothing to resume — and we never want to
// offer "resume" for a song the user just completed.
window.feedBack.on('song:ended', _clearResumeSession);
// ── Resume pill (non-blocking "continue where you left off") ────────────────
// Self-contained, inline-styled, body-appended so it works identically in the
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
// the player screen, never blocks, and a dismiss forgets the current snapshot
// for the session.
function _hideResumePill() {
const el = document.getElementById('fb-resume-pill');
if (el) el.remove();
}
function _maybeShowResumePill() {
const active = document.querySelector('.screen.active');
if (active && active.id === 'player') { _hideResumePill(); return; }
if (_resumePillDismissed) return;
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return; }
if (document.getElementById('fb-resume-pill')) return; // already shown
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
const pill = document.createElement('div');
pill.id = 'fb-resume-pill';
pill.setAttribute('role', 'status');
pill.style.cssText = [
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
'display:flex', 'align-items:center', 'gap:10px',
'max-width:min(90vw,360px)', 'padding:10px 12px',
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
].join(';');
const text = document.createElement('div');
text.style.cssText = 'flex:1;min-width:0';
const t1 = document.createElement('div');
t1.textContent = 'Resume practice';
t1.style.cssText = 'font-weight:600;color:#fff';
const t2 = document.createElement('div');
t2.textContent = label;
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
text.appendChild(t1); text.appendChild(t2);
const resumeBtn = document.createElement('button');
resumeBtn.type = 'button';
resumeBtn.textContent = 'Resume ▸';
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
const dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.setAttribute('aria-label', 'Dismiss');
dismissBtn.textContent = '✕';
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
pill.appendChild(text);
pill.appendChild(resumeBtn);
pill.appendChild(dismissBtn);
(document.body || document.documentElement).appendChild(pill);
}
if (window.feedBack) window.feedBack._maybeShowResumePill = _maybeShowResumePill;
// Exposed for tests/debugging (mirrors window._panels / _getCurrentContext).
window._snapshotResumeSession = _snapshotResumeSession;
window._readResumeSession = _readResumeSession;
window._clearResumeSession = _clearResumeSession;
// Drive the pill off screen transitions (hide over the player, offer it
// elsewhere) plus a one-shot check on first load for a prior-session snapshot.
window.feedBack.on('screen:changed', (ev) => {
const id = (ev && ev.detail && ev.detail.id) || (ev && ev.id);
if (id === 'player') _hideResumePill();
else _maybeShowResumePill();
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded',
() => { try { _maybeShowResumePill(); } catch (_) {} }, { once: true });
} else {
try { _maybeShowResumePill(); } catch (_) {}
}
// Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The
// editor's "Loop in 3D" button stashes a pending loop + return context, then
// calls playSong(). Once the chart is ready (playSong's own clearLoop() has
@@ -6063,8 +6385,17 @@ async function playSong(filename, arrangement, options) {
currentFilename = filename;
// A fresh load arms autoplay; a pending auto-exit from the previous
// song is no longer relevant.
_pendingAutostart = true;
// 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) {
_pendingResume = options.resume;
_pendingAutostart = false;
} else {
_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
@@ -6098,6 +6429,11 @@ let _arrBusyTimeout = null;
async function changeArrangement(index) {
if (currentFilename) {
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
// incoming (still-loading) arrangement. hideSongCreditsOverlay() clears
// the timer, the song:play listener, and the overlay node.
hideSongCreditsOverlay();
window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
const wasPlaying = isPlaying;
const time = _audioTime();
@@ -6270,6 +6606,14 @@ async function togglePlay() {
} catch (err) {
if (sessionGen !== _audioSeekGen) return;
if (attempt !== _playAttemptGen) return;
// An engine reroute (HTML5 -> JUCE) deliberately pauses the <audio>
// element mid-migration, which rejects this in-flight play() with an
// AbortError even though playback continues on the JUCE transport.
// The reroute owns isPlaying / the button while it runs (same guard
// the <audio> 'play'/'pause' listeners use); resetting here would
// leave the button showing Play while the song keeps playing — the
// "two clicks to pause on the first song after a fresh load" bug.
if (window._juceRerouteInProgress) return;
console.error('[app] audio.play() rejected:', err);
isPlaying = false;
setPlayButtonState(false);
@@ -6328,6 +6672,135 @@ function closeCurrentSong() {
window.closeCurrentSong = closeCurrentSong;
if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
// of leaving immediately. Auto-exit on song-end and a results screen's own
// Close never prompt — they call closeCurrentSong() directly, which stays the
// unguarded actual-exit.
function _exitConfirmEnabled() {
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
}
// Settings checkbox setter (onchange="setConfirmExitSong(this.checked)").
window.setConfirmExitSong = function (on) {
try { localStorage.setItem('confirmExitSong', on ? '1' : '0'); } catch (_) { /* private mode */ }
const el = document.getElementById('setting-confirm-exit');
if (el && el.checked !== !!on) el.checked = !!on;
};
let _exitConfirmOpen = false; // guard against stacking confirm modals
// User-initiated request to leave the player. Honors the confirm toggle; the
// actual exit is always closeCurrentSong() (origin-aware teardown).
function requestExitSong() {
if (!_exitConfirmEnabled()) { closeCurrentSong(); return; }
if (_exitConfirmOpen) return; // already asking
_openExitConfirm();
}
window.requestExitSong = requestExitSong;
if (window.feedBack) window.feedBack.requestExitSong = requestExitSong;
// A *true* modal (role="dialog" aria-modal="true" + .feedBack-modal) so the
// Escape/Space carve-outs classify it as a focus trap — they won't fire
// player-back / play-pause while it's up. Opening it PAUSES the song so it
// isn't running (or being scored) behind the prompt; Stay resumes exactly what
// we paused. Escape matches every other modal (and the generic _confirmDialog):
// it *dismisses* the prompt → Stay → drops you back into the (resumed) song —
// so a second Escape does NOT leave. Leaving is the explicit, default-focused
// "Leave" button, so Space/Enter (or click) is the keyboard "just get me out".
function _openExitConfirm() {
_exitConfirmOpen = true;
// Freeze the song while the user decides: cancel any pending count-in (so it
// can't start playback behind the modal) and pause if we're playing. Stay
// resumes only what we paused (wasPlaying), and only if the same song is
// still live on the player — guarding a teardown/seek/end behind the prompt.
_cancelCountIn();
const _resumeGen = _audioSeekGen;
const _wasPlaying = isPlaying;
if (_wasPlaying) Promise.resolve(togglePlay()).catch(() => {});
const overlay = document.createElement('div');
overlay.id = 'fb-exit-confirm';
overlay.className = 'feedBack-modal';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Leave this song?');
overlay.style.cssText = [
'position:fixed', 'inset:0', 'z-index:200', 'display:flex',
'align-items:center', 'justify-content:center',
'background:rgba(0,0,0,0.6)',
'font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
].join(';');
const card = document.createElement('div');
card.style.cssText = [
'max-width:min(92vw,360px)', 'padding:18px 18px 14px',
'background:#111827', 'color:#e5e7eb',
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:12px',
'box-shadow:0 12px 40px rgba(0,0,0,0.5)', 'text-align:left',
].join(';');
const h = document.createElement('div');
h.textContent = 'Leave this song?';
h.style.cssText = 'font-size:16px;font-weight:700;color:#fff;margin-bottom:6px';
const p = document.createElement('div');
p.textContent = 'You can pick up where you left off from the Resume pill.';
p.style.cssText = 'opacity:0.75;margin-bottom:16px';
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end';
const stayBtn = document.createElement('button');
stayBtn.type = 'button';
stayBtn.textContent = 'Stay';
stayBtn.style.cssText = 'padding:8px 14px;border:1px solid rgba(148,163,184,0.3);border-radius:8px;background:transparent;color:#e5e7eb;cursor:pointer';
const leaveBtn = document.createElement('button');
leaveBtn.type = 'button';
leaveBtn.textContent = 'Leave';
leaveBtn.style.cssText = 'padding:8px 14px;border:0;border-radius:8px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
let settled = false;
function close(leave) {
if (settled) return;
settled = true;
_exitConfirmOpen = false;
document.removeEventListener('keydown', onKey, true);
overlay.remove();
if (leave) { closeCurrentSong(); return; }
// Stay → resume exactly what we paused, but only if the session is still
// the same live song on the player (not torn down / ended / seeked away
// behind the modal). If the user was already paused, leave them paused.
if (_wasPlaying && !isPlaying &&
_audioSeekGen === _resumeGen &&
document.querySelector('.screen.active')?.id === 'player') {
Promise.resolve(togglePlay()).catch(() => {});
}
}
// Capture-phase so this dialog owns Escape and it can't fall through to the
// player-scope back shortcut. Escape = Stay (dismiss the prompt and resume
// the song) — consistent with every other modal, so a second Escape does
// NOT leave. Space/Enter stay on native activation of the focused button
// (Leave by default), so the keyboard "leave" is Space/Enter.
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(false); }
}
document.addEventListener('keydown', onKey, true);
leaveBtn.addEventListener('click', () => close(true));
stayBtn.addEventListener('click', () => close(false));
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(false); });
row.appendChild(stayBtn);
row.appendChild(leaveBtn);
card.appendChild(h);
card.appendChild(p);
card.appendChild(row);
overlay.appendChild(card);
(document.body || document.documentElement).appendChild(overlay);
// Trap Tab within the dialog (Stay ↔ Leave) so focus can't fall back to the
// player controls underneath while it's open.
_trapFocusInModal(overlay);
// Default focus on "Leave" so Space/Enter leaves immediately.
leaveBtn.focus();
}
window._openExitConfirm = _openExitConfirm; // exposed for tests/debugging
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
const SPEED_SNAP_THRESHOLD = 0.02;
let _speedPresetsWired = false;
@@ -8241,12 +8714,24 @@ function _installSectionPracticeDismiss() {
// inside #section-practice-control so it never self-closes. Listeners added
// mid-dispatch don't fire for the opening click, so there's no immediate
// close race.
//
// The click listener uses the CAPTURE phase: the v3 player rail's icon
// buttons call e.stopPropagation() in their click handler (player-chrome.js
// wireRail), which kills bubbling before it reaches document. A bubble-phase
// outside-click dismiss would therefore never fire when the user clicks a
// rail icon (Plugins, Audio, …) to open another popover, leaving this
// popover stranded open on top of it. Capture runs before the target's
// handler, so the stopPropagation can't swallow it. This mirrors the audio
// mixer popover (audio-mixer.js), which dismisses outside-clicks the same
// way. (Esc stays bubble-phase — no rail handler stops keydown propagation,
// so it already reaches us, and capturing it would reorder it ahead of the
// player's Escape-to-exit handling.)
document.addEventListener('click', (e) => {
if (!_sectionPracticePopoverOpen()) return;
const ctrl = document.getElementById('section-practice-control');
if (ctrl && ctrl.contains(e.target)) return;
_closeSectionPracticePopover();
});
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _sectionPracticePopoverOpen()) _closeSectionPracticePopover();
});
@@ -8851,10 +9336,27 @@ let _countOverlay = null;
let _countInGen = 0;
let _countInTimer = null;
let _countInRaf = 0;
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
// highway when a song is loaded, alongside the count-in. Torn down together
// with the count-in via _cancelCountIn().
let _creditsOverlay = null;
let _creditsTimer = null;
let _creditsHideOnPlay = null;
let _creditsMaxTimer = null;
const _CREDITS_HOLD_MS = 3000;
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
// a count-in handoff that never plays). This hard cap guarantees the credits
// never linger over the highway. Generous enough to outlast a normal count-in.
const _CREDITS_MAX_MS = 12000;
function _cancelCountIn() {
_countInGen++;
_countingIn = false;
hideCountOverlay();
// The credits overlay rides the count-in lifecycle (and its no-count-in
// hold timer), so a teardown — leaving the player, loading another song —
// must clear it too, or it lingers on the next screen.
hideSongCreditsOverlay();
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
}
@@ -8872,6 +9374,92 @@ function hideCountOverlay() {
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
}
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
const _CREDIT_ROLE_VERBS = {
charter: 'Charted by',
transcriber: 'Transcribed by',
arranger: 'Arranged by',
editor: 'Edited by',
mixer: 'Mixed by',
engineer: 'Engineered by',
proofreader: 'Proofread by',
};
function _creditLineLabel(role) {
if (!role) return '';
const key = String(role).trim().toLowerCase();
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
}
// Show the feedpak contributor credits over the highway. `authors` is the
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
// Anchored to the lower third (bottom-center) so it never collides with the
// vertically-centered count-in number, and pointer-events-none so it never
// intercepts clicks. No-op when there are no contributors to show.
function showSongCreditsOverlay(authors) {
if (!Array.isArray(authors) || authors.length === 0) return;
if (!_creditsOverlay) {
_creditsOverlay = document.createElement('div');
_creditsOverlay.className = 'song-credits-overlay';
document.body.appendChild(_creditsOverlay);
}
// Build via DOM + textContent — author names are untrusted pack data and
// must never be interpolated as HTML.
_creditsOverlay.replaceChildren();
const card = document.createElement('div');
card.className = 'song-credits-card';
const eyebrow = document.createElement('div');
eyebrow.className = 'song-credits-eyebrow';
eyebrow.textContent = 'Credits';
card.appendChild(eyebrow);
const title = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.title) || '';
if (title) {
const heading = document.createElement('div');
heading.className = 'song-credits-heading';
heading.textContent = title;
card.appendChild(heading);
}
for (const a of authors) {
if (!a || !a.name) continue;
const row = document.createElement('div');
row.className = 'song-credits-line';
const label = _creditLineLabel(a.role);
if (label) {
const lab = document.createElement('span');
lab.className = 'song-credits-role';
lab.textContent = label + ' ';
row.appendChild(lab);
}
const nm = document.createElement('span');
nm.className = 'song-credits-name';
nm.textContent = a.name;
row.appendChild(nm);
card.appendChild(row);
}
_creditsOverlay.appendChild(card);
// Arm the backstop so the overlay self-clears even if playback never starts
// / never emits song:play. song:play (or any teardown) clears it earlier.
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
}
function hideSongCreditsOverlay() {
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
if (_creditsHideOnPlay) {
window.feedBack.off('song:play', _creditsHideOnPlay);
_creditsHideOnPlay = null;
}
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
}
async function startCountIn(opts = {}) {
if (_countingIn) return;
_countingIn = true;
@@ -8998,6 +9586,10 @@ async function startCountIn(opts = {}) {
setPlayButtonState(true);
}).catch((err) => {
if (gen !== _countInGen) return;
// An engine reroute's deliberate pause aborts this play()
// while playback continues on JUCE — don't reset the
// button (mirrors the togglePlay guard).
if (window._juceRerouteInProgress) return;
// Same rationale as togglePlay: don't claim playback
// started if the Promise rejected.
console.error('[app] audio.play() rejected after count-in:', err);
@@ -9579,7 +10171,7 @@ registerShortcut({
key: 'Escape',
description: 'Back to library',
scope: 'player',
handler: () => showScreen(_playerOriginScreen || 'home')
handler: () => requestExitSong()
});
registerShortcut({
@@ -9691,9 +10283,14 @@ function openEditModal(songData, openerEl) {
<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 onclick="saveEditModal('${encodeURIComponent(songData.f)}')"
<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>
@@ -9729,6 +10326,16 @@ function openEditModal(songData, openerEl) {
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', () => {
@@ -9737,17 +10344,34 @@ function openEditModal(songData, openerEl) {
}
// 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 (e.target === modal || e.target.closest('[data-edit-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 });
}
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.
function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
return clickTarget === modalEl && downOnBackdrop === true;
}
function previewEditArt(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
@@ -9768,6 +10392,9 @@ async function saveEditModal(encodedFilename) {
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(),
}),
});
+28 -2
View File
@@ -248,6 +248,7 @@ function createHighway() {
let _frameMsEMA = 0; // smoothed frame interval (for the HUD)
let _lastFramePerf = 0;
let _lastAutoAdjustAt = 0;
let _lastUpscaleAt = 0; // separate, longer clock for UPscaling (lazy)
let _perfHud = null;
let _hudOn = false; // cached highwayPerfHud flag (re-read ~2x/sec, not per-frame)
let _hudFlagAt = 0;
@@ -266,6 +267,11 @@ function createHighway() {
return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN;
})();
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.
const _AUTO_UPSCALE_COOLDOWN_MS = 2500;
let _inverted = localStorage.getItem('invertHighway') === 'true';
let _lefty = localStorage.getItem('lefty') === '1';
let _lastChordOnFretLine = null; // chord object currently shown on fret line
@@ -1291,9 +1297,22 @@ function createHighway() {
const eff = _effectiveRenderScale();
let next = _autoScale;
if (_drawMsEMA > _DRAW_BUDGET_HI_MS && eff > _autoScaleMin) {
// Over budget — downscale promptly to protect the frame rate, and reset
// the upscale clock so we don't immediately bounce back up.
next = _autoScale * 0.85;
} else if (_drawMsEMA < _DRAW_BUDGET_LO_MS && eff < 1) {
next = _autoScale * 1.1;
_lastUpscaleAt = nowP;
} else if (_drawMsEMA < _DRAW_BUDGET_LO_MS && eff < 1
&& nowP - _lastUpscaleAt >= _AUTO_UPSCALE_COOLDOWN_MS) {
// Headroom — upscale LAZILY: a smaller step on a longer cooldown, and
// only when the projected cost AFTER the step (cost scales ~with the
// pixel count, i.e. step²) still clears the high budget. That predictive
// guard is what stops the up→over-budget→down ping-pong testers saw: the
// scale settles just inside the deadband instead of oscillating across it.
const step = 1.06;
if (_drawMsEMA * step * step < _DRAW_BUDGET_HI_MS) {
next = _autoScale * step;
_lastUpscaleAt = nowP;
}
}
// Clamp so _renderScale * _autoScale stays within [_autoScaleMin, 1].
// Cap `lo` at 1: when the floor exceeds the manual ceiling (e.g. quality
@@ -3511,6 +3530,13 @@ function createHighway() {
// matchesArrangement on this rather than the
// arrangement name.
hasNotation: Boolean(msg.has_notation),
// Feedpak contributor credits (manifest
// `authors:`, spec §5.4): [{name, role}].
// Only real feedpak plays carry these; loose/
// archive sources and synthetic highway uses
// (minigames) get []. app.js shows a credits
// overlay on song load when this is non-empty.
authors: Array.isArray(msg.authors) ? msg.authors : [],
};
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
}
+7 -1
View File
@@ -103,10 +103,13 @@
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
<button id="view-folder-btn" onclick="setLibView('folder')" class="px-3 py-2.5 text-sm transition" title="Folder view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg>
</button>
</div>
<!-- Grid controls -->
<select id="lib-sort" onchange="sortLibrary()"
class="lib-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
class="lib-nontree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
@@ -147,6 +150,9 @@
<div id="lib-tree" class="space-y-2 hidden">
<!-- Tree populated by JS -->
</div>
<div id="lib-folder-tree" class="space-y-1 hidden">
<!-- Folder tree populated by JS when Folders source is active -->
</div>
</section>
<!-- ══ Filters drawer (feedBack#129/#69/#22) ═════════════════════ -->
+90
View File
@@ -863,3 +863,93 @@ html { scroll-behavior: smooth; }
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
border-radius: 0.25rem;
}
/* Feedpak contributor credits shown over the highway when a song loads
(manifest `authors:`, spec §5.4). Anchored to the upper third so it sits
ABOVE the vertically-centered count-in number; click-through. */
.song-credits-overlay {
position: fixed;
left: 0;
right: 0;
top: 15%;
/* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and
the count-in number (z-[100]) so the credits stay prominent through the
whole load → count-in → play window. */
z-index: 205;
display: flex;
justify-content: center;
pointer-events: none;
animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.song-credits-card {
position: relative;
min-width: 16rem;
max-width: min(90vw, 34rem);
padding: 1.4rem 2.5rem 1.5rem;
text-align: center;
background:
radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%),
linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%);
border: 1px solid rgb(129 140 248 / 0.28);
border-radius: 1rem;
box-shadow:
0 18px 50px rgb(0 0 0 / 0.55),
0 0 0 1px rgb(0 0 0 / 0.35),
inset 0 1px 0 rgb(255 255 255 / 0.07);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* Accent bar across the top edge of the card. */
.song-credits-card::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 3.25rem;
height: 3px;
border-radius: 0 0 3px 3px;
background: linear-gradient(90deg, #38bdf8, #818cf8);
box-shadow: 0 0 12px rgb(99 102 241 / 0.7);
}
.song-credits-eyebrow {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgb(165 180 252 / 0.9);
margin-bottom: 0.4rem;
}
.song-credits-heading {
font-size: 1.3rem;
font-weight: 800;
color: #f8fafc;
margin-bottom: 0.7rem;
letter-spacing: 0.01em;
text-shadow: 0 1px 8px rgb(0 0 0 / 0.5);
}
.song-credits-line {
font-size: 1.1rem;
line-height: 1.55;
color: #e2e8f0;
}
.song-credits-role {
color: rgb(148 163 184 / 0.95);
font-weight: 500;
}
.song-credits-name {
font-weight: 700;
color: #ffffff;
}
@keyframes song-credits-fade-in {
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
+55 -6
View File
@@ -343,7 +343,10 @@
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
<div id="settings" class="screen">
<div class="fb-settings">
<!-- fb-selectable: Settings is read-only content (paths, device names,
version, diagnostics, About) the user copies — opt the whole panel
back in under the v3 non-select default. See static/v3/v3.css. -->
<div class="fb-settings fb-selectable">
<button onclick="showScreen('home')" class="fb-settings-back">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Home
</button>
@@ -505,6 +508,34 @@
</label>
</div>
</div>
<!-- "Up Next" pill -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Show &ldquo;Up Next&rdquo;</div>
<div class="fb-srow-desc">Display the upcoming-section pill in the top-right of the player during playback.</div>
</div>
<div class="fb-srow-control">
<label class="fb-switch">
<input type="checkbox" id="setting-show-upnext" checked onchange="setShowUpNext(this.checked)">
<span class="fb-switch-track"></span>
</label>
</div>
</div>
<!-- Ask before leaving a song -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Ask before leaving a song</div>
<div class="fb-srow-desc">Confirm before Escape (or the player&rsquo;s ✕) exits a song. Off by default &mdash; Escape leaves instantly. With it on, a confirm appears; Space, Enter, or &ldquo;Leave&rdquo; exits, while Escape dismisses it.</div>
</div>
<div class="fb-srow-control">
<label class="fb-switch">
<input type="checkbox" id="setting-confirm-exit" onchange="setConfirmExitSong(this.checked)">
<span class="fb-switch-track"></span>
</label>
</div>
</div>
</div>
</div>
@@ -797,7 +828,13 @@
<!-- Top HUD — persistent (song info, time, Up Next) -->
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between items-start px-5 py-4 pointer-events-none z-20">
<div class="text-sm leading-tight">
<!-- fb-selectable: now-playing song metadata (title / artist /
arrangement / tuning) is copy-worthy content, not chrome.
pointer-events-auto: the #player-hud parent is pointer-events-none
(so the HUD doesn't eat clicks meant for the highway), which would
also block the mouse from reaching this text to select it — opt
just this block back into hit-testing. -->
<div class="text-sm leading-tight fb-selectable pointer-events-auto">
<div><span id="hud-artist" class="text-gray-300"></span><span id="hud-title" class="text-white font-semibold"></span></div>
<div id="hud-arrangement" class="text-gray-500 text-xs mt-0.5"></div>
<div id="hud-tuning" class="text-gray-500 text-xs mt-0.5"></div>
@@ -818,9 +855,12 @@
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
</div>
<div id="v3-upnext" class="v3-upnext hidden">
<span class="text-gray-400">Up Next:</span>
<span id="v3-upnext-name" class="v3-upnext-name"></span>
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
<div class="v3-upnext-row">
<span class="text-gray-400">Up Next:</span>
<span id="v3-upnext-name" class="v3-upnext-name"></span>
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
</div>
<div class="v3-upnext-bar"><div id="v3-upnext-bar-fill" class="v3-upnext-bar-fill"></div></div>
</div>
</div>
</div>
@@ -886,6 +926,15 @@
<option value="0.5">Low</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="min-scale-label">Min res</span>
<select id="min-scale-select" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="v3-pop-select" aria-labelledby="min-scale-label" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
<option value="0.25">25%</option>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1">Full</option>
</select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="scoreboard-label">Scoreboard</span>
<select id="scoreboard-select" onchange="setScoreboard(this.value)" class="v3-pop-select" aria-labelledby="scoreboard-label" title="Highway scoreboard">
@@ -985,7 +1034,7 @@
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="v3-pop-btn hidden" title="Return to the editor where you left off">↩ Editor</button>
</span>
</div>
<button onclick="showScreen('home')" class="v3-pop-close" title="Close player">✕ Close player</button>
<button onclick="requestExitSong()" class="v3-pop-close" title="Close player">✕ Close player</button>
</div>
</div>
+15
View File
@@ -172,6 +172,8 @@
function updateUpNext() {
const pill = $('v3-upnext');
if (!pill) return;
// Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON).
if (window.feedBack && window.feedBack.showUpNext === false) { pill.classList.add('hidden'); return; }
const hw = window.highway;
const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null;
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null;
@@ -185,6 +187,19 @@
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
if (nm) nm.textContent = next.name || '—';
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
// Progress bar: fraction of the current section elapsed toward `next`.
// Previous boundary is the last section at/before now (else song start).
const fill = $('v3-upnext-bar-fill');
if (fill) {
let prevT = 0;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
else break;
}
const span = next.time - prevT;
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
fill.style.width = (prog * 100).toFixed(1) + '%';
}
pill.classList.remove('hidden');
}
+42 -5
View File
@@ -28,6 +28,22 @@
} catch (e) { return null; }
}
// Content-dependent playlist cover: a custom uploaded cover wins; otherwise
// the playlist's own song art — the icon when empty, one cover for a few
// songs, a 2×2 mosaic at 4+. `art_urls` / `cover_url` come from /api/playlists.
function playlistCoverHtml(p) {
const box = 'w-full aspect-square rounded-lg overflow-hidden bg-fb-bg/50 mb-3';
const img = (u, cls) => '<img src="' + esc(u) + '" alt="" class="' + cls + '" onerror="this.style.visibility=\'hidden\'">';
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
if (!arts.length) {
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>';
}
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
@@ -96,15 +112,14 @@
(lists.length
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
'<div class="w-full aspect-square rounded-lg bg-fb-bg/50 mb-3 flex items-center justify-center text-fb-textDim">' +
(p.system_key ? '🔖' : '🎵') + '</div>' +
playlistCoverHtml(p) +
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>'
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
'</div>';
root.querySelector('#v3-pl-new')?.addEventListener('click', async () => {
const name = (window.prompt('Playlist name?') || '').trim();
const name = ((await window.uiPrompt({ title: 'New Playlist', label: 'Playlist name', okLabel: 'Create', placeholder: 'My Playlist' })) || '').trim();
if (!name) return;
await jsend('POST', '/api/playlists', { name });
renderPlaylists();
@@ -126,8 +141,11 @@
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
(isSystem ? '' :
'<div class="flex gap-2 shrink-0">' +
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button></div>') +
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden"></div>') +
'</div>' +
(pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
@@ -137,7 +155,7 @@
const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
const name = (window.prompt('Rename playlist', pl.name) || '').trim();
const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
if (!name) return;
await jsend('PATCH', '/api/playlists/' + pid, { name });
renderPlaylistDetail(pid);
@@ -147,6 +165,25 @@
await fetch('/api/playlists/' + pid, { method: 'DELETE' });
renderPlaylists();
});
// Custom cover: pick an image → upload as a data URL → the playlist card
// shows it (overriding the song-art cover). Re-render the detail so the
// Remove-cover button appears; the grid picks up the new cover on return.
const coverFile = root.querySelector('#v3-pl-cover-file');
root.querySelector('#v3-pl-cover')?.addEventListener('click', () => coverFile && coverFile.click());
coverFile?.addEventListener('change', () => {
const f = coverFile.files && coverFile.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = async (e) => {
await jsend('POST', '/api/playlists/' + pid + '/cover', { image: e.target.result });
renderPlaylistDetail(pid);
};
reader.readAsDataURL(f);
});
root.querySelector('#v3-pl-cover-rm')?.addEventListener('click', async () => {
await fetch('/api/playlists/' + pid + '/cover', { method: 'DELETE' });
renderPlaylistDetail(pid);
});
}
// ── #v3-saved ─────────────────────────────────────────────────────────--
+48 -10
View File
@@ -330,9 +330,16 @@
const editing = !!opts.editing;
document.getElementById('v3-onboarding')?.remove();
// The amp-sim opt-in step (step 5) only exists in the desktop app — the
// pure-web build has no native amp sims to monitor through, so the step
// is skipped there (calibration is the last step at index 5 on web, 6 on
// desktop). See feedBack-desktop#46.
const isDesktop = !!window.feedBackDesktop;
const lastStep = isDesktop ? 6 : 5;
const stepDots = editing ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
[1, 2, 3, 4, 5].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>';
const overlay = document.createElement('div');
@@ -380,8 +387,20 @@
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 5 — calibration offer (first-run only).
// Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first).
// Hidden div is always present in the DOM; setStep only navigates to
// it on desktop. See feedBack-desktop#46.
'<div id="v3-ob-step5" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">How do you want to hear yourself?</label>' +
'<p class="text-sm text-fb-textDim mb-3">fee[dB]ack can run your guitar through built-in <span class="text-fb-text">amp simulations</span> (NAM / IRs / plugins) so you hear a processed tone. If you already play through your <span class="text-fb-text">own amp or rig</span>, leave this off — youll get clean, silent monitoring and never an idle buzz.</p>' +
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
'<input type="checkbox" id="v3-ob-ampsims" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
'<span class="text-sm text-fb-text">Use in-app amp simulations' +
'<span class="block text-xs text-fb-textDim mt-1">Loads your saved tone chain for monitoring. You can change this any time in the desktop Audio settings.</span></span>' +
'</label>' +
'<p class="text-xs text-fb-textDim mt-2">Leave it unticked if you monitor through your own gear. This is off by default.</p></div>' +
// Step 6 — calibration offer (first-run only).
'<div id="v3-ob-step6" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
@@ -441,7 +460,7 @@
function setStep(n) {
step = n;
errEl.classList.add('hidden');
for (let i = 1; i <= 5; i++) {
for (let i = 1; i <= 6; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
}
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
@@ -454,12 +473,13 @@
: n === 2 ? 'Point us at your songs'
: n === 3 ? 'Feats of Power (optional)'
: n === 4 ? 'Choose your instrument paths'
: n === 5 ? 'How do you want to monitor?'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 5 ? 'Play it now' : 'Next';
submit.textContent = n === 6 ? 'Play it now' : 'Next';
// Skip is offered on the song-directory step (configure later) and
// the calibration challenge.
skipBtn.classList.toggle('hidden', !(n === 2 || n === 5));
// the calibration challenge (the last step).
skipBtn.classList.toggle('hidden', !(n === 2 || n === 6));
refreshSubmit();
}
@@ -695,11 +715,29 @@
// New step: input-device selection + calibration, between
// path selection and the note-detect calibration challenge.
await runInputSetup(selectedPaths);
setStep(5);
setStep(isDesktop ? 5 : 6);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
// Step 5 — "Play it now": leave calibration pending (it completes
if (step === 5) {
// Step 5 (desktop only) — persist the amp-sim opt-in (default OFF
// / own-rig). Best-effort: a failed write must not block onboarding;
// it's settable later from the desktop Audio settings.
submit.disabled = true;
try {
const ampEl = overlay.querySelector('#v3-ob-ampsims');
const useAmpSims = !!(ampEl && ampEl.checked);
try {
await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ use_amp_sims: useAmpSims }),
});
} catch (e) { /* best-effort — settable later */ }
setStep(6);
} finally { refreshSubmit(); }
return;
}
// Step 6 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish({ launchingSong: !!target });
@@ -713,8 +751,8 @@
setStep(3);
return;
}
// Step 5 — skip: Mastery Rank 1 immediately, calibration stays
// replayable from the Progress screen.
// Calibration step (last) — skip: Mastery Rank 1 immediately,
// calibration stays replayable from the Progress screen.
skipBtn.disabled = true;
try {
const res = await fetch('/api/progression/onboarding', {
+1 -1
View File
@@ -29,7 +29,7 @@
gameplay: {
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
local: ['lefty', 'autoplayExit', 'arrangementNamingMode', 'countdownBeforeSong'],
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
after: function () {
// Left-handed is held on the highway object, not re-derived
// from localStorage on load — flip it back to the default.
+1 -2
View File
@@ -50,7 +50,7 @@
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
{ 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: 'audio' },
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
// Not in the sidebar groups, but routable (profile badge → here).
{ key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' },
];
@@ -84,7 +84,6 @@
amp: 'M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm11 4a3 3 0 100 6 3 3 0 000-6zM6.5 8.5h.01M9 8.5h.01',
target: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 4a5 5 0 100 10 5 5 0 000-10zm0 4a1 1 0 100 2 1 1 0 000-2z',
edit: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.1 2.1 0 013 3L12 15l-4 1 1-4 9.5-9.5z',
audio: 'M11 5L6 9H3v6h3l5 4V5zM15.5 8.5a5 5 0 010 7M19 6a9 9 0 010 12',
};
function iconSvg(name) {
const d = ICONS[name] || ICONS.disc;
+905 -99
View File
File diff suppressed because it is too large Load Diff
+211 -2
View File
@@ -4,6 +4,62 @@
* `fb` palette in tailwind.config.js.
*/
/* Text-selection policy (v3)
Accidental drag/double-click selection of app chrome (sidebar, transport, the
note highway/HUD, buttons, labels) makes the UI look broken and is never
useful so default the interface to non-selectable, then opt *content* back
in. v3-only: this sheet loads only on /v3 (v2 is unchanged). The panel's
guardrails are baked in:
- NEVER a `* { user-select:none }` rule it breaks input carets / IME
composition on WebKit (bug 82692); we scope to `html` and re-enable below.
- This is cosmetic only; it protects nothing (DevTools defeats it) and must
never be used to "lock" copy-worthy text away (a11y: keep errors, IDs,
paths, versions, metadata, lyrics selectable incl. in modals/toasts). */
html { -webkit-user-select: none; user-select: none; }
/* Form fields are ALWAYS selectable/editable protects the caret + IME
(including CJK / dead-key composition). The default must never swallow typing.
`.fb-selectable *` forces descendants so a child element's own non-select
can't strand copy-worthy text inside a content island. */
input, textarea, select,
[contenteditable]:not([contenteditable="false"]),
[contenteditable]:not([contenteditable="false"]) * {
-webkit-user-select: text; user-select: text;
}
/* Plugin screens are content surfaces (editor, tabview, lyrics, theory, chord
text, ). Re-enable their mounted subtree by INHERITANCE (no `*`) so the host
policy can't silently make a plugin's copyable text un-selectable including
community / out-of-tree plugins that never adopt `.fb-selectable`. A plugin
that wants its own chrome non-selectable still wins via its own element rule
(which this inherited value doesn't override). */
.screen[id^="plugin-"] { -webkit-user-select: text; user-select: text; }
/* Core read-only content opts back in by CONTAINER (lower-drift than tagging
each value a new setting added later inherits "selectable" for free):
the Settings panel (values, paths, device names, version, diagnostics,
About) and the now-playing song metadata (both tagged `.fb-selectable`).
Plugins re-enable their own copyable regions with this same class
(documented in CLAUDE.md).
The focused, transient surfaces below ALWAYS carry copy-worthy text (errors,
IDs, file paths, device/version strings) per the a11y guardrail, so they're
blanket-opted-in by selector rather than hand-tagged they're single focused
panels, not dense card lists, so re-enabling selection there can't recreate
the across-cards marquee mess the policy prevents:
- modals / dialogs: `.feedBack-modal`, `[role="dialog"]` (confirm, edit-meta,
retune result/error, calibration, filter drawer);
- toasts: `#fb-notify-stack`, `#v3-fb-toast`;
- the library scan banner (`#scan-banner` shows the current file path).
(Dense card lists the library grid, dashboard, profile are intentionally
left non-selectable; copy their text from the now-playing HUD / Settings.) */
.fb-selectable, .fb-selectable *,
.feedBack-modal, .feedBack-modal *,
[role="dialog"], [role="dialog"] *,
#fb-notify-stack, #fb-notify-stack *,
#v3-fb-toast, #v3-fb-toast *,
#scan-banner, #scan-banner * { -webkit-user-select: text; user-select: text; }
/* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */
#tuner-toggle-btn { display: none !important; }
@@ -284,8 +340,9 @@
/* — Up Next pill (top-right, persistent) — */
#player-hud .v3-upnext {
display: flex;
align-items: center;
gap: .5rem;
flex-direction: column;
align-items: stretch;
gap: .35rem;
padding: .45rem .9rem;
border-radius: .75rem;
background: rgba(15, 23, 42, .7);
@@ -295,6 +352,26 @@
pointer-events: auto;
}
#player-hud .v3-upnext.hidden { display: none; }
/* Text row keeps the original inline layout untouched. */
#player-hud .v3-upnext .v3-upnext-row {
display: flex;
align-items: center;
gap: .5rem;
}
/* Progress bar under the text — fills as the current section elapses. */
#player-hud .v3-upnext .v3-upnext-bar {
height: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, .25);
overflow: hidden;
}
#player-hud .v3-upnext .v3-upnext-bar-fill {
height: 100%;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
transition: width .12s linear;
}
/* — Live performance HUD (top-right, read-only) — */
.v3-live-performance-hud {
@@ -425,6 +502,13 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
width: 96px;
pointer-events: auto;
}
/* The Section Map plugin pins a ~20px clickable bar to the very top of #player
(#section-map, z-index:5). The rail catcher above is full-height at z-index:30,
so its top-left corner swallows clicks on the section map's first section. When
the bar is present, drop the catcher below it so the top strip stays clickable;
the rail still reveals from anywhere below the bar. Mirrors the core
`#section-map ~ #player-hud` special-case in static/style.css. */
#section-map ~ #v3-railzone::before { top: 20px; }
.v3-rail {
position: relative;
@@ -1110,3 +1194,128 @@ html.fb-immersive #v3-main > .screen.active {
inset: 0;
overflow: hidden;
}
/* — AZ jump rail (v3 Songs grid; static/v3/songs.js) — */
/* Fixed to the right edge next to the scroller's scrollbar; vertically
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
.v3-azrail {
position: fixed;
right: 2px;
top: 50%;
transform: translateY(-50%);
z-index: 25;
display: flex;
flex-direction: column;
align-items: center;
max-height: 84vh;
padding: 4px 1px;
user-select: none;
-webkit-user-select: none;
touch-action: none; /* let a drag scrub the rail without scrolling the page */
}
.v3-azrail-letter {
appearance: none;
-webkit-appearance: none;
background: none;
border: 0;
color: #94a3b8; /* fb-textDim */
font-size: .62rem;
font-weight: 700;
line-height: 1.05;
padding: 1px 4px;
margin: 0;
cursor: pointer;
border-radius: 4px;
}
.v3-azrail-letter:hover:not([disabled]),
.v3-azrail-letter.is-active {
color: #0ea5e9; /* fb-primary */
}
.v3-azrail-letter:focus-visible {
outline: 2px solid #38bdf8; /* fb-primaryHi */
outline-offset: 1px;
}
.v3-azrail-letter[disabled] {
color: rgba(148, 163, 184, .28);
cursor: default;
}
/* Drag indicator bubble (Android fast-scroll pattern). */
.v3-azbubble {
position: fixed;
right: 2.6rem;
top: 50%;
transform: translateY(-50%);
z-index: 26;
width: 2.6rem;
height: 2.6rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: .7rem;
background: #0ea5e9; /* fb-primary */
color: #f8fafc; /* fb-text */
font-size: 1.15rem;
font-weight: 800;
box-shadow: 0 6px 22px rgba(0, 0, 0, .45);
pointer-events: none;
}
.v3-azrail.hidden,
.v3-azbubble.hidden { display: none; }
/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge.
Tighten it; a collapse-to-anchors pass is a follow-up. */
@media (max-height: 640px) {
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
}
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
#v3-lib-home.hidden { display: none; }
.v3-rep-meter { max-width: 30rem; }
.v3-rep-track {
height: 6px;
border-radius: 999px;
background: rgba(148, 163, 184, .22); /* fb-textDim @ low alpha */
overflow: hidden;
}
.v3-rep-fill {
height: 100%;
border-radius: 999px;
background: #0ea5e9; /* fb-primary */
transition: width .4s ease;
}
/* Horizontal, scroll-snapping shelf of fixed-width cards. */
.v3-kp-row {
display: flex;
gap: .75rem;
overflow-x: auto;
scroll-snap-type: x proximity;
padding-bottom: 6px;
-webkit-overflow-scrolling: touch;
}
.v3-kp-card {
flex: 0 0 8.5rem;
width: 8.5rem;
scroll-snap-align: start;
}
/* — Windowed (virtualized) Songs grid (#636 item 3 stage 2) — */
/* The grid is absolutely positioned inside #v3-songs-gridsizer, whose height is
set to the FULL library (ceil(total/cols)*rowH) so the scrollbar reflects the
whole library while only the visible window's cards are in the DOM. The inline
`top` (set by renderWindow) offsets the window to the first visible row. */
.v3-grid-window {
position: absolute;
left: 0;
right: 0;
top: 0;
}
/* The arrangement-chip row is rendered on EVERY card (even when empty) at a fixed
single-line height uniform card height is what makes the window's
absolute-position math exact. Extra chips are clipped rather than wrapping. */
.v3-card-chips {
height: 1.5rem;
overflow: hidden;
flex-wrap: nowrap;
}
/* Skeleton placeholder shown only if a window's fetch hasn't landed; mirrors a
real card's vertical structure so it occupies an identical row height. */
.v3-card-skel { pointer-events: none; }
@@ -0,0 +1,231 @@
import {
Clock,
HalfFloatType,
NoBlending,
Vector2,
WebGLRenderTarget
} from '../../three.module.min.js';
import { CopyShader } from '../shaders/CopyShader.js';
import { ShaderPass } from './ShaderPass.js';
import { MaskPass } from './MaskPass.js';
import { ClearMaskPass } from './MaskPass.js';
class EffectComposer {
constructor( renderer, renderTarget ) {
this.renderer = renderer;
this._pixelRatio = renderer.getPixelRatio();
if ( renderTarget === undefined ) {
const size = renderer.getSize( new Vector2() );
this._width = size.width;
this._height = size.height;
renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
renderTarget.texture.name = 'EffectComposer.rt1';
} else {
this._width = renderTarget.width;
this._height = renderTarget.height;
}
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.renderTarget2.texture.name = 'EffectComposer.rt2';
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
this.renderToScreen = true;
this.passes = [];
this.copyPass = new ShaderPass( CopyShader );
this.copyPass.material.blending = NoBlending;
this.clock = new Clock();
}
swapBuffers() {
const tmp = this.readBuffer;
this.readBuffer = this.writeBuffer;
this.writeBuffer = tmp;
}
addPass( pass ) {
this.passes.push( pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
insertPass( pass, index ) {
this.passes.splice( index, 0, pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
removePass( pass ) {
const index = this.passes.indexOf( pass );
if ( index !== - 1 ) {
this.passes.splice( index, 1 );
}
}
isLastEnabledPass( passIndex ) {
for ( let i = passIndex + 1; i < this.passes.length; i ++ ) {
if ( this.passes[ i ].enabled ) {
return false;
}
}
return true;
}
render( deltaTime ) {
// deltaTime value is in seconds
if ( deltaTime === undefined ) {
deltaTime = this.clock.getDelta();
}
const currentRenderTarget = this.renderer.getRenderTarget();
let maskActive = false;
for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
const pass = this.passes[ i ];
if ( pass.enabled === false ) continue;
pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
if ( pass.needsSwap ) {
if ( maskActive ) {
const context = this.renderer.getContext();
const stencil = this.renderer.state.buffers.stencil;
//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
stencil.setFunc( context.EQUAL, 1, 0xffffffff );
}
this.swapBuffers();
}
if ( MaskPass !== undefined ) {
if ( pass instanceof MaskPass ) {
maskActive = true;
} else if ( pass instanceof ClearMaskPass ) {
maskActive = false;
}
}
}
this.renderer.setRenderTarget( currentRenderTarget );
}
reset( renderTarget ) {
if ( renderTarget === undefined ) {
const size = this.renderer.getSize( new Vector2() );
this._pixelRatio = this.renderer.getPixelRatio();
this._width = size.width;
this._height = size.height;
renderTarget = this.renderTarget1.clone();
renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
}
setSize( width, height ) {
this._width = width;
this._height = height;
const effectiveWidth = this._width * this._pixelRatio;
const effectiveHeight = this._height * this._pixelRatio;
this.renderTarget1.setSize( effectiveWidth, effectiveHeight );
this.renderTarget2.setSize( effectiveWidth, effectiveHeight );
for ( let i = 0; i < this.passes.length; i ++ ) {
this.passes[ i ].setSize( effectiveWidth, effectiveHeight );
}
}
setPixelRatio( pixelRatio ) {
this._pixelRatio = pixelRatio;
this.setSize( this._width, this._height );
}
dispose() {
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.copyPass.dispose();
}
}
export { EffectComposer };
+104
View File
@@ -0,0 +1,104 @@
import { Pass } from './Pass.js';
class MaskPass extends Pass {
constructor( scene, camera ) {
super();
this.scene = scene;
this.camera = camera;
this.clear = true;
this.needsSwap = false;
this.inverse = false;
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const context = renderer.getContext();
const state = renderer.state;
// don't update color or depth
state.buffers.color.setMask( false );
state.buffers.depth.setMask( false );
// lock buffers
state.buffers.color.setLocked( true );
state.buffers.depth.setLocked( true );
// set up stencil
let writeValue, clearValue;
if ( this.inverse ) {
writeValue = 0;
clearValue = 1;
} else {
writeValue = 1;
clearValue = 0;
}
state.buffers.stencil.setTest( true );
state.buffers.stencil.setOp( context.REPLACE, context.REPLACE, context.REPLACE );
state.buffers.stencil.setFunc( context.ALWAYS, writeValue, 0xffffffff );
state.buffers.stencil.setClear( clearValue );
state.buffers.stencil.setLocked( true );
// draw into the stencil buffer
renderer.setRenderTarget( readBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
renderer.setRenderTarget( writeBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
// unlock color and depth buffer and make them writable for subsequent rendering/clearing
state.buffers.color.setLocked( false );
state.buffers.depth.setLocked( false );
state.buffers.color.setMask( true );
state.buffers.depth.setMask( true );
// only render where stencil is set to 1
state.buffers.stencil.setLocked( false );
state.buffers.stencil.setFunc( context.EQUAL, 1, 0xffffffff ); // draw if == 1
state.buffers.stencil.setOp( context.KEEP, context.KEEP, context.KEEP );
state.buffers.stencil.setLocked( true );
}
}
class ClearMaskPass extends Pass {
constructor() {
super();
this.needsSwap = false;
}
render( renderer /*, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
renderer.state.buffers.stencil.setLocked( false );
renderer.state.buffers.stencil.setTest( false );
}
}
export { MaskPass, ClearMaskPass };
+97
View File
@@ -0,0 +1,97 @@
import {
ColorManagement,
RawShaderMaterial,
UniformsUtils,
LinearToneMapping,
ReinhardToneMapping,
CineonToneMapping,
AgXToneMapping,
ACESFilmicToneMapping,
NeutralToneMapping,
SRGBTransfer
} from '../../three.module.min.js';
import { Pass, FullScreenQuad } from './Pass.js';
import { OutputShader } from '../shaders/OutputShader.js';
class OutputPass extends Pass {
constructor() {
super();
//
const shader = OutputShader;
this.uniforms = UniformsUtils.clone( shader.uniforms );
this.material = new RawShaderMaterial( {
name: shader.name,
uniforms: this.uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader
} );
this.fsQuad = new FullScreenQuad( this.material );
// internal cache
this._outputColorSpace = null;
this._toneMapping = null;
}
render( renderer, writeBuffer, readBuffer/*, deltaTime, maskActive */ ) {
this.uniforms[ 'tDiffuse' ].value = readBuffer.texture;
this.uniforms[ 'toneMappingExposure' ].value = renderer.toneMappingExposure;
// rebuild defines if required
if ( this._outputColorSpace !== renderer.outputColorSpace || this._toneMapping !== renderer.toneMapping ) {
this._outputColorSpace = renderer.outputColorSpace;
this._toneMapping = renderer.toneMapping;
this.material.defines = {};
if ( ColorManagement.getTransfer( this._outputColorSpace ) === SRGBTransfer ) this.material.defines.SRGB_TRANSFER = '';
if ( this._toneMapping === LinearToneMapping ) this.material.defines.LINEAR_TONE_MAPPING = '';
else if ( this._toneMapping === ReinhardToneMapping ) this.material.defines.REINHARD_TONE_MAPPING = '';
else if ( this._toneMapping === CineonToneMapping ) this.material.defines.CINEON_TONE_MAPPING = '';
else if ( this._toneMapping === ACESFilmicToneMapping ) this.material.defines.ACES_FILMIC_TONE_MAPPING = '';
else if ( this._toneMapping === AgXToneMapping ) this.material.defines.AGX_TONE_MAPPING = '';
else if ( this._toneMapping === NeutralToneMapping ) this.material.defines.NEUTRAL_TONE_MAPPING = '';
this.material.needsUpdate = true;
}
//
if ( this.renderToScreen === true ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( writeBuffer );
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
this.fsQuad.render( renderer );
}
}
dispose() {
this.material.dispose();
this.fsQuad.dispose();
}
}
export { OutputPass };
+95
View File
@@ -0,0 +1,95 @@
import {
BufferGeometry,
Float32BufferAttribute,
OrthographicCamera,
Mesh
} from '../../three.module.min.js';
class Pass {
constructor() {
this.isPass = true;
// if set to true, the pass is processed by the composer
this.enabled = true;
// if set to true, the pass indicates to swap read and write buffer after rendering
this.needsSwap = true;
// if set to true, the pass clears its buffer before rendering
this.clear = false;
// if set to true, the result of the pass is rendered to screen. This is set automatically by EffectComposer.
this.renderToScreen = false;
}
setSize( /* width, height */ ) {}
render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
console.error( 'THREE.Pass: .render() must be implemented in derived pass.' );
}
dispose() {}
}
// Helper for passes that need to fill the viewport with a single quad.
const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
// https://github.com/mrdoob/three.js/pull/21358
class FullscreenTriangleGeometry extends BufferGeometry {
constructor() {
super();
this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );
}
}
const _geometry = new FullscreenTriangleGeometry();
class FullScreenQuad {
constructor( material ) {
this._mesh = new Mesh( _geometry, material );
}
dispose() {
this._mesh.geometry.dispose();
}
render( renderer ) {
renderer.render( this._mesh, _camera );
}
get material() {
return this._mesh.material;
}
set material( value ) {
this._mesh.material = value;
}
}
export { Pass, FullScreenQuad };
+99
View File
@@ -0,0 +1,99 @@
import {
Color
} from '../../three.module.min.js';
import { Pass } from './Pass.js';
class RenderPass extends Pass {
constructor( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null ) {
super();
this.scene = scene;
this.camera = camera;
this.overrideMaterial = overrideMaterial;
this.clearColor = clearColor;
this.clearAlpha = clearAlpha;
this.clear = true;
this.clearDepth = false;
this.needsSwap = false;
this._oldClearColor = new Color();
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
let oldClearAlpha, oldOverrideMaterial;
if ( this.overrideMaterial !== null ) {
oldOverrideMaterial = this.scene.overrideMaterial;
this.scene.overrideMaterial = this.overrideMaterial;
}
if ( this.clearColor !== null ) {
renderer.getClearColor( this._oldClearColor );
renderer.setClearColor( this.clearColor, renderer.getClearAlpha() );
}
if ( this.clearAlpha !== null ) {
oldClearAlpha = renderer.getClearAlpha();
renderer.setClearAlpha( this.clearAlpha );
}
if ( this.clearDepth == true ) {
renderer.clearDepth();
}
renderer.setRenderTarget( this.renderToScreen ? null : readBuffer );
if ( this.clear === true ) {
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
}
renderer.render( this.scene, this.camera );
// restore
if ( this.clearColor !== null ) {
renderer.setClearColor( this._oldClearColor );
}
if ( this.clearAlpha !== null ) {
renderer.setClearAlpha( oldClearAlpha );
}
if ( this.overrideMaterial !== null ) {
this.scene.overrideMaterial = oldOverrideMaterial;
}
renderer.autoClear = oldAutoClear;
}
}
export { RenderPass };
+77
View File
@@ -0,0 +1,77 @@
import {
ShaderMaterial,
UniformsUtils
} from '../../three.module.min.js';
import { Pass, FullScreenQuad } from './Pass.js';
class ShaderPass extends Pass {
constructor( shader, textureID ) {
super();
this.textureID = ( textureID !== undefined ) ? textureID : 'tDiffuse';
if ( shader instanceof ShaderMaterial ) {
this.uniforms = shader.uniforms;
this.material = shader;
} else if ( shader ) {
this.uniforms = UniformsUtils.clone( shader.uniforms );
this.material = new ShaderMaterial( {
name: ( shader.name !== undefined ) ? shader.name : 'unspecified',
defines: Object.assign( {}, shader.defines ),
uniforms: this.uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader
} );
}
this.fsQuad = new FullScreenQuad( this.material );
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
if ( this.uniforms[ this.textureID ] ) {
this.uniforms[ this.textureID ].value = readBuffer.texture;
}
this.fsQuad.material = this.material;
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( writeBuffer );
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
this.fsQuad.render( renderer );
}
}
dispose() {
this.material.dispose();
this.fsQuad.dispose();
}
}
export { ShaderPass };
@@ -0,0 +1,415 @@
import {
AdditiveBlending,
Color,
HalfFloatType,
MeshBasicMaterial,
ShaderMaterial,
UniformsUtils,
Vector2,
Vector3,
WebGLRenderTarget
} from '../../three.module.min.js';
import { Pass, FullScreenQuad } from './Pass.js';
import { CopyShader } from '../shaders/CopyShader.js';
import { LuminosityHighPassShader } from '../shaders/LuminosityHighPassShader.js';
/**
* UnrealBloomPass is inspired by the bloom pass of Unreal Engine. It creates a
* mip map chain of bloom textures and blurs them with different radii. Because
* of the weighted combination of mips, and because larger blurs are done on
* higher mips, this effect provides good quality and performance.
*
* Reference:
* - https://docs.unrealengine.com/latest/INT/Engine/Rendering/PostProcessEffects/Bloom/
*/
class UnrealBloomPass extends Pass {
constructor( resolution, strength, radius, threshold ) {
super();
this.strength = ( strength !== undefined ) ? strength : 1;
this.radius = radius;
this.threshold = threshold;
this.resolution = ( resolution !== undefined ) ? new Vector2( resolution.x, resolution.y ) : new Vector2( 256, 256 );
// create color only once here, reuse it later inside the render function
this.clearColor = new Color( 0, 0, 0 );
// render targets
this.renderTargetsHorizontal = [];
this.renderTargetsVertical = [];
this.nMips = 5;
let resx = Math.round( this.resolution.x / 2 );
let resy = Math.round( this.resolution.y / 2 );
this.renderTargetBright = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
this.renderTargetBright.texture.name = 'UnrealBloomPass.bright';
this.renderTargetBright.texture.generateMipmaps = false;
for ( let i = 0; i < this.nMips; i ++ ) {
const renderTargetHorizontal = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetHorizontal.texture.name = 'UnrealBloomPass.h' + i;
renderTargetHorizontal.texture.generateMipmaps = false;
this.renderTargetsHorizontal.push( renderTargetHorizontal );
const renderTargetVertical = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i;
renderTargetVertical.texture.generateMipmaps = false;
this.renderTargetsVertical.push( renderTargetVertical );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// luminosity high pass material
const highPassShader = LuminosityHighPassShader;
this.highPassUniforms = UniformsUtils.clone( highPassShader.uniforms );
this.highPassUniforms[ 'luminosityThreshold' ].value = threshold;
this.highPassUniforms[ 'smoothWidth' ].value = 0.01;
this.materialHighPassFilter = new ShaderMaterial( {
uniforms: this.highPassUniforms,
vertexShader: highPassShader.vertexShader,
fragmentShader: highPassShader.fragmentShader
} );
// gaussian blur materials
this.separableBlurMaterials = [];
const kernelSizeArray = [ 3, 5, 7, 9, 11 ];
resx = Math.round( this.resolution.x / 2 );
resy = Math.round( this.resolution.y / 2 );
for ( let i = 0; i < this.nMips; i ++ ) {
this.separableBlurMaterials.push( this.getSeperableBlurMaterial( kernelSizeArray[ i ] ) );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// composite material
this.compositeMaterial = this.getCompositeMaterial( this.nMips );
this.compositeMaterial.uniforms[ 'blurTexture1' ].value = this.renderTargetsVertical[ 0 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture2' ].value = this.renderTargetsVertical[ 1 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture3' ].value = this.renderTargetsVertical[ 2 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture4' ].value = this.renderTargetsVertical[ 3 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture5' ].value = this.renderTargetsVertical[ 4 ].texture;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = 0.1;
const bloomFactors = [ 1.0, 0.8, 0.6, 0.4, 0.2 ];
this.compositeMaterial.uniforms[ 'bloomFactors' ].value = bloomFactors;
this.bloomTintColors = [ new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ) ];
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
// blend material
const copyShader = CopyShader;
this.copyUniforms = UniformsUtils.clone( copyShader.uniforms );
this.blendMaterial = new ShaderMaterial( {
uniforms: this.copyUniforms,
vertexShader: copyShader.vertexShader,
fragmentShader: copyShader.fragmentShader,
blending: AdditiveBlending,
depthTest: false,
depthWrite: false,
transparent: true
} );
this.enabled = true;
this.needsSwap = false;
this._oldClearColor = new Color();
this.oldClearAlpha = 1;
this.basic = new MeshBasicMaterial();
this.fsQuad = new FullScreenQuad( null );
}
dispose() {
for ( let i = 0; i < this.renderTargetsHorizontal.length; i ++ ) {
this.renderTargetsHorizontal[ i ].dispose();
}
for ( let i = 0; i < this.renderTargetsVertical.length; i ++ ) {
this.renderTargetsVertical[ i ].dispose();
}
this.renderTargetBright.dispose();
//
for ( let i = 0; i < this.separableBlurMaterials.length; i ++ ) {
this.separableBlurMaterials[ i ].dispose();
}
this.compositeMaterial.dispose();
this.blendMaterial.dispose();
this.basic.dispose();
//
this.fsQuad.dispose();
}
setSize( width, height ) {
let resx = Math.round( width / 2 );
let resy = Math.round( height / 2 );
this.renderTargetBright.setSize( resx, resy );
for ( let i = 0; i < this.nMips; i ++ ) {
this.renderTargetsHorizontal[ i ].setSize( resx, resy );
this.renderTargetsVertical[ i ].setSize( resx, resy );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
}
render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) {
renderer.getClearColor( this._oldClearColor );
this.oldClearAlpha = renderer.getClearAlpha();
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
renderer.setClearColor( this.clearColor, 0 );
if ( maskActive ) renderer.state.buffers.stencil.setTest( false );
// Render input to screen
if ( this.renderToScreen ) {
this.fsQuad.material = this.basic;
this.basic.map = readBuffer.texture;
renderer.setRenderTarget( null );
renderer.clear();
this.fsQuad.render( renderer );
}
// 1. Extract Bright Areas
this.highPassUniforms[ 'tDiffuse' ].value = readBuffer.texture;
this.highPassUniforms[ 'luminosityThreshold' ].value = this.threshold;
this.fsQuad.material = this.materialHighPassFilter;
renderer.setRenderTarget( this.renderTargetBright );
renderer.clear();
this.fsQuad.render( renderer );
// 2. Blur All the mips progressively
let inputRenderTarget = this.renderTargetBright;
for ( let i = 0; i < this.nMips; i ++ ) {
this.fsQuad.material = this.separableBlurMaterials[ i ];
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = inputRenderTarget.texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionX;
renderer.setRenderTarget( this.renderTargetsHorizontal[ i ] );
renderer.clear();
this.fsQuad.render( renderer );
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = this.renderTargetsHorizontal[ i ].texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionY;
renderer.setRenderTarget( this.renderTargetsVertical[ i ] );
renderer.clear();
this.fsQuad.render( renderer );
inputRenderTarget = this.renderTargetsVertical[ i ];
}
// Composite All the mips
this.fsQuad.material = this.compositeMaterial;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = this.strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = this.radius;
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
renderer.setRenderTarget( this.renderTargetsHorizontal[ 0 ] );
renderer.clear();
this.fsQuad.render( renderer );
// Blend it additively over the input texture
this.fsQuad.material = this.blendMaterial;
this.copyUniforms[ 'tDiffuse' ].value = this.renderTargetsHorizontal[ 0 ].texture;
if ( maskActive ) renderer.state.buffers.stencil.setTest( true );
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( readBuffer );
this.fsQuad.render( renderer );
}
// Restore renderer settings
renderer.setClearColor( this._oldClearColor, this.oldClearAlpha );
renderer.autoClear = oldAutoClear;
}
getSeperableBlurMaterial( kernelRadius ) {
const coefficients = [];
for ( let i = 0; i < kernelRadius; i ++ ) {
coefficients.push( 0.39894 * Math.exp( - 0.5 * i * i / ( kernelRadius * kernelRadius ) ) / kernelRadius );
}
return new ShaderMaterial( {
defines: {
'KERNEL_RADIUS': kernelRadius
},
uniforms: {
'colorTexture': { value: null },
'invSize': { value: new Vector2( 0.5, 0.5 ) }, // inverse texture size
'direction': { value: new Vector2( 0.5, 0.5 ) },
'gaussianCoefficients': { value: coefficients } // precomputed Gaussian coefficients
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`#include <common>
varying vec2 vUv;
uniform sampler2D colorTexture;
uniform vec2 invSize;
uniform vec2 direction;
uniform float gaussianCoefficients[KERNEL_RADIUS];
void main() {
float weightSum = gaussianCoefficients[0];
vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;
for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
float x = float(i);
float w = gaussianCoefficients[i];
vec2 uvOffset = direction * invSize * x;
vec3 sample1 = texture2D( colorTexture, vUv + uvOffset ).rgb;
vec3 sample2 = texture2D( colorTexture, vUv - uvOffset ).rgb;
diffuseSum += (sample1 + sample2) * w;
weightSum += 2.0 * w;
}
gl_FragColor = vec4(diffuseSum/weightSum, 1.0);
}`
} );
}
getCompositeMaterial( nMips ) {
return new ShaderMaterial( {
defines: {
'NUM_MIPS': nMips
},
uniforms: {
'blurTexture1': { value: null },
'blurTexture2': { value: null },
'blurTexture3': { value: null },
'blurTexture4': { value: null },
'blurTexture5': { value: null },
'bloomStrength': { value: 1.0 },
'bloomFactors': { value: null },
'bloomTintColors': { value: null },
'bloomRadius': { value: 0.0 }
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`varying vec2 vUv;
uniform sampler2D blurTexture1;
uniform sampler2D blurTexture2;
uniform sampler2D blurTexture3;
uniform sampler2D blurTexture4;
uniform sampler2D blurTexture5;
uniform float bloomStrength;
uniform float bloomRadius;
uniform float bloomFactors[NUM_MIPS];
uniform vec3 bloomTintColors[NUM_MIPS];
float lerpBloomFactor(const in float factor) {
float mirrorFactor = 1.2 - factor;
return mix(factor, mirrorFactor, bloomRadius);
}
void main() {
gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +
lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +
lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +
lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +
lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );
}`
} );
}
}
UnrealBloomPass.BlurDirectionX = new Vector2( 1.0, 0.0 );
UnrealBloomPass.BlurDirectionY = new Vector2( 0.0, 1.0 );
export { UnrealBloomPass };
+45
View File
@@ -0,0 +1,45 @@
/**
* Full-screen textured quad shader
*/
const CopyShader = {
name: 'CopyShader',
uniforms: {
'tDiffuse': { value: null },
'opacity': { value: 1.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform float opacity;
uniform sampler2D tDiffuse;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
gl_FragColor = opacity * texel;
}`
};
export { CopyShader };
@@ -0,0 +1,64 @@
import {
Color
} from '../../three.module.min.js';
/**
* Luminosity
* http://en.wikipedia.org/wiki/Luminosity
*/
const LuminosityHighPassShader = {
name: 'LuminosityHighPassShader',
shaderID: 'luminosityHighPass',
uniforms: {
'tDiffuse': { value: null },
'luminosityThreshold': { value: 1.0 },
'smoothWidth': { value: 1.0 },
'defaultColor': { value: new Color( 0x000000 ) },
'defaultOpacity': { value: 0.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform vec3 defaultColor;
uniform float defaultOpacity;
uniform float luminosityThreshold;
uniform float smoothWidth;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
float v = luminance( texel.xyz );
vec4 outputColor = vec4( defaultColor.rgb, defaultOpacity );
float alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v );
gl_FragColor = mix( outputColor, texel, alpha );
}`
};
export { LuminosityHighPassShader };
+85
View File
@@ -0,0 +1,85 @@
const OutputShader = {
name: 'OutputShader',
uniforms: {
'tDiffuse': { value: null },
'toneMappingExposure': { value: 1 }
},
vertexShader: /* glsl */`
precision highp float;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform sampler2D tDiffuse;
#include <tonemapping_pars_fragment>
#include <colorspace_pars_fragment>
varying vec2 vUv;
void main() {
gl_FragColor = texture2D( tDiffuse, vUv );
// tone mapping
#ifdef LINEAR_TONE_MAPPING
gl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb );
#elif defined( REINHARD_TONE_MAPPING )
gl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb );
#elif defined( CINEON_TONE_MAPPING )
gl_FragColor.rgb = CineonToneMapping( gl_FragColor.rgb );
#elif defined( ACES_FILMIC_TONE_MAPPING )
gl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb );
#elif defined( AGX_TONE_MAPPING )
gl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb );
#elif defined( NEUTRAL_TONE_MAPPING )
gl_FragColor.rgb = NeutralToneMapping( gl_FragColor.rgb );
#endif
// color space
#ifdef SRGB_TRANSFER
gl_FragColor = sRGBTransferOETF( gl_FragColor );
#endif
}`
};
export { OutputShader };
-48
View File
@@ -1,48 +0,0 @@
import { test, expect } from '@playwright/test';
// The Audio Engine plugin (`audio_engine`) is bundled only in the desktop
// build, so stub /api/plugins to make it present here. Asserts it renders as a
// promoted sidebar entry in the v3 "HOME" group, immediately AFTER the Settings
// entry, and routes to its plugin screen.
const STUB_PLUGINS = [
{ id: 'audio_engine', name: 'Audio Engine', nav: { label: 'Audio', screen: 'audio-engine', icon: '🎸' }, status: 'ready', has_screen: false, has_settings: false },
];
test('audio_engine is promoted into the HOME nav group after Settings', async ({ page }) => {
await page.route('**/api/plugins', route =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(STUB_PLUGINS) }));
await page.goto('/');
await page.waitForSelector('#v3-nav a[data-v3-nav]', { timeout: 15000 });
// The promoted slot is filled with the Audio Engine link.
const aeLink = page.locator('#v3-nav-audio-engine a[data-v3-nav="audio_engine"]');
await expect(aeLink).toHaveCount(1, { timeout: 10000 });
await expect(aeLink).toContainText('Audio');
// It sits inside the HOME group, immediately after the Settings entry.
const order = await page.evaluate(() => {
const nav = document.getElementById('v3-nav');
const links = Array.from(nav.querySelectorAll('a[data-v3-nav]'));
const keys = links.map(a => a.getAttribute('data-v3-nav'));
const settingsIdx = keys.indexOf('settings');
const aeIdx = keys.indexOf('audio_engine');
// renderSidebar emits one wrapper <div> per group as a direct child of
// #v3-nav: [ heading <div>GROUP</div>, items <div>…links…</div> ]. Walk up
// from the link to that group wrapper and read its heading (first child) —
// structural, so it doesn't depend on the heading's CSS classes.
function groupOf(el) {
let node = el;
while (node && node.parentElement && node.parentElement !== nav) node = node.parentElement;
const heading = node && node.firstElementChild;
return heading ? heading.textContent.trim() : null;
}
return { settingsIdx, aeIdx, group: aeIdx >= 0 ? groupOf(links[aeIdx]) : null };
});
expect(order.settingsIdx).toBeGreaterThanOrEqual(0);
expect(order.aeIdx).toBe(order.settingsIdx + 1);
expect(order.group).toBe('HOME');
// It targets the plugin's own screen.
await expect(aeLink).toHaveAttribute('href', '#/audio_engine');
});
+124
View File
@@ -0,0 +1,124 @@
import { test, expect } from '@playwright/test';
// Opt-in "Ask before leaving a song" confirm. Default OFF → Escape/✕ leave
// instantly. When ON, a true-modal confirm appears and PAUSES the song; Escape
// (like every other modal) DISMISSES it → Stay, so a second Escape returns to
// the song rather than leaving, and Space/Enter activate the default-focused
// "Leave". (The mock song has no backing audio, so the pause-on-open /
// resume-on-Stay is verified manually on web + desktop; these specs lock the
// navigation + keyboard semantics.)
const CONFIRM_KEY = 'confirmExitSong';
async function installMockSong(page) {
await page.evaluate(() => {
const messages = [
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
{ type: 'ready' },
];
class MockWebSocket {
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen = null; onmessage = null; onerror = null; onclose = null; url;
constructor(url) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen(new Event('open'));
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
}, 0);
}
send() {}
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
}
// @ts-ignore
window.WebSocket = MockWebSocket;
});
}
async function openPlayerWithMockSong(page) {
await installMockSong(page);
await page.evaluate(async () => { /* @ts-ignore */ await window.playSong('mock-song.sloppak'); });
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
}
test.describe('Exit-confirm toggle', () => {
test.beforeEach(async ({ page }) => {
// Suppress the first-run onboarding overlay (a modal that intercepts
// pointer/keyboard events) so Escape reaches the player, not the overlay.
await page.route('**/api/profile', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
} else { await route.continue(); }
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.evaluate((k) => localStorage.removeItem(k), CONFIRM_KEY);
});
test('default OFF: Escape exits the song immediately, no confirm', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(0);
});
test('ON: Escape opens the confirm and the song stays', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
await expect(page.locator('#player.active')).toHaveCount(1);
// "Leave" is focused so Space/Enter leaves immediately.
await expect(page.locator('#fb-exit-confirm button', { hasText: 'Leave' })).toBeFocused();
});
test('ON: a second Escape dismisses the prompt and stays in the song', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
// Escape = dismiss (Stay), matching every other modal — NOT leave.
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(1);
});
test('ON: clicking the backdrop dismisses the prompt and stays', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
// mousedown on the overlay backdrop (top-left, away from the centered card)
// is Stay — never an accidental leave.
await page.locator('#fb-exit-confirm').click({ position: { x: 5, y: 5 } });
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(1);
});
test('ON: "Stay" keeps you in the song; "Leave" exits', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await page.locator('#fb-exit-confirm button', { hasText: 'Stay' }).click();
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(1);
await page.keyboard.press('Escape');
await page.locator('#fb-exit-confirm button', { hasText: 'Leave' }).click();
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(0);
});
test('ON: Enter on the default-focused "Leave" leaves', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(0);
});
});
+185
View File
@@ -51,6 +51,14 @@ async function openPlayerWithMockSong(page) {
test.describe('Keyboard Shortcuts', () => {
test.beforeEach(async ({ page }) => {
// Suppress the first-run onboarding overlay (#v3-onboarding) — a modal that
// intercepts pointer/keyboard events — so the app behaves like a returning
// user, which is the state these tests assume.
await page.route('**/api/profile', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
} else { await route.continue(); }
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
});
@@ -778,6 +786,183 @@ test('should support condition callbacks', async ({ page }) => {
expect(result.clicked).toBe(1);
});
// ── Escape = universal "Back" carve-out ──────────────────────────────────
// Escape must escape a focused non-modal control exactly like Space does,
// so a focused transport/rail button can't swallow it ("Escape in song not
// consistent"). These mirror the #593 Space tests above. Each registers an
// Escape spy in the relevant scope (which replaces the built-in handler for
// that composite key) so the assertion doesn't depend on showScreen teardown.
test('Escape exits the song when a player rail button is focused', async ({ page }) => {
await openPlayerWithMockSong(page);
// The bug: a focused <button> is an "interactive control", so Escape was
// blocked before reaching the dispatcher and the song wouldn't exit until
// the user clicked empty canvas to blur the control.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const btn = document.createElement('button');
btn.id = '__test-rail-btn';
btn.textContent = 'Restart';
document.getElementById('player')!.appendChild(btn);
});
await page.locator('#__test-rail-btn').focus();
await expect(page.locator('#__test-rail-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Back-to-library fired despite the control button holding focus.
expect(backCount).toBe(1);
});
test('Escape in a player-screen text input does NOT exit the song', async ({ page }) => {
await openPlayerWithMockSong(page);
// The text-input exemption (_isTextInput) is checked before the Escape
// carve-out, so Escape in a field is the field's own concern (clear/blur),
// never a song exit.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const input = document.createElement('input');
input.type = 'text';
input.id = '__test-player-input';
document.getElementById('player')!.appendChild(input);
});
await page.locator('#__test-player-input').focus();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
expect(backCount).toBe(0);
});
test('Escape inside a modal over the player closes the modal, not back-to-library', async ({ page }) => {
await openPlayerWithMockSong(page);
// A true modal (role="dialog" aria-modal="true" / .feedBack-modal) layered
// over the player is a focus trap: Escape there must NOT eject past it to
// exit the song — the modal owns Escape. The carve-out's modal-overlay
// guard keeps the player-back shortcut from firing.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const modal = document.createElement('div');
modal.id = '__test-modal';
modal.className = 'feedBack-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const btn = document.createElement('button');
btn.id = '__test-modal-btn';
btn.textContent = 'Close';
modal.appendChild(btn);
document.body.appendChild(modal);
});
await page.locator('#__test-modal-btn').focus();
await expect(page.locator('#__test-modal-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Playback is NOT exited behind the modal.
expect(backCount).toBe(0);
});
test('Escape does NOT exit the song while the Section Practice popover is open', async ({ page }) => {
await openPlayerWithMockSong(page);
// The Section Practice popover claims Escape earlier in
// _shortcutDispatchBlocked (line ~447, before the Escape carve-out), so an
// open popover suppresses the player-scope back-to-library Escape — the
// popover's own handler owns closing it. This locks that ordering guard.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
let bar = document.getElementById('section-practice-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'section-practice-bar';
document.getElementById('player')!.appendChild(bar);
}
bar.classList.add('section-practice-bar--open');
});
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Player-back did NOT fire while the popover was open.
expect(backCount).toBe(0);
});
test('Escape goes back from settings when a control is focused (twin-bug)', async ({ page }) => {
// The same focus bug existed on the settings screen (the carve-out was
// player-only). The fix covers settings too: Escape returns to the
// previous screen even when a settings control holds focus.
await page.evaluate(() => {
// @ts-ignore
window.__escSettingsBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Go back from settings (test spy)',
scope: 'settings',
// @ts-ignore
handler: () => { window.__escSettingsBackCount++; },
});
// @ts-ignore
window.showScreen('settings');
const btn = document.createElement('button');
btn.id = '__test-settings-btn';
btn.textContent = 'Some setting';
document.getElementById('settings')!.appendChild(btn);
});
await page.waitForSelector('#settings.active', { timeout: 5000 });
await page.locator('#__test-settings-btn').focus();
await expect(page.locator('#__test-settings-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escSettingsBackCount);
expect(backCount).toBe(1);
});
test('should warn on invalid scope', async ({ page }) => {
const messages: string[] = [];
page.on('console', msg => {
+152
View File
@@ -0,0 +1,152 @@
import { test, expect } from '@playwright/test';
// Resume-last-session: leaving the player snapshots {song, arrangement,
// position, speed} so an exit is recoverable via a non-blocking "Resume" pill.
// These exercise the deterministic plumbing (snapshot guards, staleness, the
// pill, and resume consumption) without depending on real audio timing.
const RESUME_KEY = 'feedBack.resumeSession';
// Make playSong()'s WebSocket a no-network mock that emits a song_info + ready.
async function installMockSong(page) {
await page.evaluate(() => {
const messages = [
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
{ type: 'ready' },
];
class MockWebSocket {
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen = null; onmessage = null; onerror = null; onclose = null; url;
constructor(url) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen(new Event('open'));
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
}, 0);
}
send() {}
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
}
// @ts-ignore
window.WebSocket = MockWebSocket;
});
}
async function openPlayerWithMockSong(page) {
await installMockSong(page);
await page.evaluate(async () => {
// @ts-ignore
await window.playSong('mock-song.sloppak');
});
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
}
test.describe('Resume last session', () => {
test.beforeEach(async ({ page }) => {
// Suppress the first-run onboarding overlay (a modal that intercepts
// pointer/keyboard events) so the player isn't covered.
await page.route('**/api/profile', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
} else { await route.continue(); }
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.evaluate((k) => localStorage.removeItem(k), RESUME_KEY);
});
test('snapshots song + arrangement + position once you are mid-song', async ({ page }) => {
await openPlayerWithMockSong(page);
const snap = await page.evaluate(() => {
// @ts-ignore
window._snapshotResumeSession(30);
// @ts-ignore
return window._readResumeSession();
});
expect(snap).not.toBeNull();
expect(snap.f).toBe('mock-song.sloppak');
expect(snap.a).toBe(0);
expect(Math.round(snap.t)).toBe(30);
expect(snap.title).toBe('Mock Song');
});
test('does NOT snapshot a barely-started or basically-finished song', async ({ page }) => {
await openPlayerWithMockSong(page);
const result = await page.evaluate(() => {
// @ts-ignore
window._snapshotResumeSession(1); // < 3s min → ignored
// @ts-ignore
const tooEarly = window._readResumeSession();
// duration is 90; end-guard is 5s, so 88 > 85 → ignored
// @ts-ignore
window._snapshotResumeSession(88);
// @ts-ignore
const tooLate = window._readResumeSession();
return { tooEarly, tooLate };
});
expect(result.tooEarly).toBeNull();
expect(result.tooLate).toBeNull();
});
test('a stale (>24h) snapshot is ignored', async ({ page }) => {
const got = await page.evaluate((k) => {
const old = { f: 'old.sloppak', a: 0, t: 42, sp: 1, title: 'Old', ts: Date.now() - 25 * 60 * 60 * 1000 };
localStorage.setItem(k, JSON.stringify(old));
// @ts-ignore
return window._readResumeSession();
}, RESUME_KEY);
expect(got).toBeNull();
});
test('the Resume pill appears off-player and hides on the player', async ({ page }) => {
await page.evaluate((k) => {
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', artist: 'Mock Artist', ts: Date.now() };
localStorage.setItem(k, JSON.stringify(snap));
// @ts-ignore
window.feedBack._maybeShowResumePill();
}, RESUME_KEY);
await expect(page.locator('#fb-resume-pill')).toBeVisible();
await expect(page.locator('#fb-resume-pill')).toContainText('Mock Song');
// Entering the player hides it (screen:changed → _hideResumePill()).
await page.evaluate(() => { /* @ts-ignore */ window.showScreen('player'); });
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
});
test('dismissing the pill removes it and does not re-show it this session', async ({ page }) => {
await page.evaluate((k) => {
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
localStorage.setItem(k, JSON.stringify(snap));
// @ts-ignore
window.feedBack._maybeShowResumePill();
}, RESUME_KEY);
await expect(page.locator('#fb-resume-pill')).toBeVisible();
await page.locator('#fb-resume-pill button[aria-label="Dismiss"]').click();
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
// A re-offer attempt within the same session is suppressed.
await page.evaluate(() => { /* @ts-ignore */ window.feedBack._maybeShowResumePill(); });
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
});
test('resumeLastSession() re-enters the song and consumes the snapshot', async ({ page }) => {
await installMockSong(page);
await page.evaluate((k) => {
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
localStorage.setItem(k, JSON.stringify(snap));
}, RESUME_KEY);
await page.evaluate(async () => { /* @ts-ignore */ await window.resumeLastSession(); });
await page.waitForSelector('#player.active', { timeout: 5000 });
// The snapshot is consumed (cleared) so it isn't offered again.
const remaining = await page.evaluate((k) => localStorage.getItem(k), RESUME_KEY);
expect(remaining).toBeNull();
});
});
@@ -0,0 +1,130 @@
import { test, expect } from '@playwright/test';
// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3
// stage 2). Before virtualization the grid appended every scrolled page, so for
// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001).
// Now only the visible window (± overscan) is ever in the DOM while a sizer
// element gives the scrollbar the full-library geometry.
//
// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is
// deterministic in CI without a seeded 2000-row library: /api/library serves a
// synthetic page from the page/after param with total 2001, and the keyset
// cursor is mocked as the next absolute offset.
const TOTAL = 2001;
const PAGE_SIZE = 24;
const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
// Same bucketing as the seed/server: index % 26 → a first letter, so the AZ
// rail has real buckets and a jump has somewhere to land.
function songAt(i: number) {
const letter = COLS[i % 26];
return {
filename: `seed/${String(i).padStart(5, '0')}.sloppak`,
title: `Song ${String(i).padStart(4, '0')}`,
artist: `${letter}Band ${String(i).padStart(4, '0')}`,
album: `${letter} Album`,
format: 'sloppak',
arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }],
};
}
// sort_letters song-counts per bucket for index%26 over [0, TOTAL).
function sortLetters() {
const m: Record<string, number> = {};
for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; }
return m;
}
test.beforeEach(async ({ page }) => {
await page.route('**/api/library?**', async (route) => {
const url = new URL(route.request().url());
const after = url.searchParams.get('after');
const size = Number(url.searchParams.get('size') || PAGE_SIZE);
const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size;
const songs = [];
for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i));
const nextOffset = offset + size;
await route.fulfill({
json: {
songs, total: TOTAL, page: Math.floor(offset / size), size,
next_cursor: nextOffset < TOTAL ? String(nextOffset) : null,
},
});
});
await page.route('**/api/library/stats**', (route) => {
const url = new URL(route.request().url());
const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} };
if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters();
return route.fulfill({ json: body });
});
await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } }));
await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } }));
await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} }));
await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] }));
});
async function openSongs(page) {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.evaluate(() => {
// @ts-ignore — neutralize playback so a stray click can't navigate away.
window.playSong = () => Promise.resolve();
// @ts-ignore
window.showScreen('v3-songs');
});
await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 });
}
test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => {
await openSongs(page);
// The count reflects the FULL library even though only a window is rendered.
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
// The sizer reserves the full scroll height (so the scrollbar is library-wide).
const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight);
expect(scrollHeight).toBeGreaterThan(20000);
// Scroll the whole library; the in-DOM card count must stay bounded throughout.
const CAP = 150;
let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count();
for (let s = 0; s < 50; s++) {
await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; });
await page.waitForTimeout(60);
const n = await page.locator('#v3-songs-grid [data-fn]').count();
maxNodes = Math.max(maxNodes, n);
expect(n).toBeLessThanOrEqual(CAP);
}
// Sanity: we actually rendered a window (not zero), and stayed well under the
// unbounded 2001 the old append-everything grid would have produced.
expect(maxNodes).toBeGreaterThan(0);
expect(maxNodes).toBeLessThanOrEqual(CAP);
// The count is still correct after scrolling to the end.
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
});
test('the AZ rail jumps directly to a letter without loading every page', async ({ page }) => {
await openSongs(page);
await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 });
// Jump to 'M'; the window scrolls to the row holding the first 'M' card.
await page.evaluate(() => {
const b = [...document.querySelectorAll('.v3-azrail-letter')]
.find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined;
if (!b) throw new Error('no M rail letter'); b.click();
});
// After the jump+window render, an 'M' card is present near the top of the
// viewport (the jump is O(1) via sort_letters, not a full page-through).
await expect.poll(async () => page.evaluate(() => {
const main = document.getElementById('v3-main')!;
const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0);
return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => {
const r = c.getBoundingClientRect();
return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320;
});
}), { timeout: 5000 }).toBe(true);
});
@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';
// Regression coverage for the v3 Section Map "leftmost section unclickable" bug.
//
// The Section Map plugin pins a ~20px clickable bar (#section-map, z-index:5)
// to the very top of #player. The v3 chrome has a full-height invisible rail
// "catcher" (.v3-railzone::before, z-index:30, width:96px, pinned left/top:0)
// that reveals the hover rail. Because the catcher sat at top:0 and outranks
// the bar, its top-left corner swallowed every click on the section map's first
// section. Fix (static/v3/v3.css): `#section-map ~ #v3-railzone::before { top: 20px }`
// drops the catcher below the bar when the section map is present.
//
// We reproduce the plugin's bar exactly (first child of #player, the rendered
// position:relative / z-index:5 / 20px-tall state) and hit-test the top-left
// corner with elementFromPoint — that is precisely what a real click resolves
// against. A negative control re-raises the catcher to prove the test catches
// the bug.
// A fresh profile shows the blocking onboarding overlay; onboard via the API so
// it isn't (re)created over the player. Idempotent once onboarded.
test.beforeEach(async ({ request }) => {
await request.post('/api/profile', { data: { display_name: 'Section Map Tester' } });
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
});
async function openPlayerWithSectionMap(page) {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// The bug affects an already-onboarded user mid-song. The API skip above
// handles the common path; this persistent hide also covers a slow async
// profile render that could otherwise re-create the full-screen overlay and
// intercept the top-left hit-test (mirrors settings-tabbed.spec.ts).
await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' });
await page.evaluate(() => {
// @ts-ignore — show the player screen (static #v3-railzone markup lives here).
window.showScreen('player');
const player = document.getElementById('player');
if (!player) throw new Error('#player missing');
// Reproduce the section_map plugin's rendered bar: first child of #player,
// 20px tall, full width, z-index:5, position:relative (its post-_smRender
// state), with a left-edge "first section" block at left:0.
const bar = document.createElement('div');
bar.id = 'section-map';
bar.style.cssText =
'position:relative;top:0;left:0;right:0;z-index:5;height:20px;background:rgba(8,8,16,0.7);cursor:pointer;';
const block = document.createElement('div');
block.id = 'sm-first-block';
block.style.cssText =
'position:absolute;left:0;width:30%;top:0;bottom:0;background:#3b82f6;';
bar.appendChild(block);
player.insertBefore(bar, player.firstChild);
});
await page.waitForSelector('#section-map', { state: 'attached', timeout: 5000 });
await page.waitForSelector('#v3-railzone', { state: 'attached', timeout: 5000 });
}
// What element does a click at the top-left strip land on? (x within the 96px
// catcher, y within the 20px bar.)
function hitTopLeft(page, x = 10, y = 8) {
return page.evaluate(({ x, y }) => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
return el ? { id: el.id, cls: el.className, tag: el.tagName } : null;
}, { x, y });
}
test('top-left of the section map receives clicks, not the rail catcher (fix present)', async ({ page }) => {
await openPlayerWithSectionMap(page);
const hit = await hitTopLeft(page);
// Click must resolve to the section map (the bar or its first-section block),
// never the rail hover-zone.
expect(hit).not.toBeNull();
expect(hit!.id).not.toBe('v3-railzone');
expect(['section-map', 'sm-first-block']).toContain(hit!.id);
});
test('negative control: re-raising the catcher to top:0 reproduces the bug', async ({ page }) => {
await openPlayerWithSectionMap(page);
// Undo the fix at runtime (highest-specificity inline-ish override) so the
// catcher again covers the bar's top-left — this is the pre-fix layout.
await page.evaluate(() => {
const style = document.createElement('style');
style.textContent = '#section-map ~ #v3-railzone::before { top: 0 !important; }';
document.head.appendChild(style);
});
const hit = await hitTopLeft(page);
// Without the fix, the rail catcher swallows the click.
expect(hit!.id).toBe('v3-railzone');
});
+111
View File
@@ -0,0 +1,111 @@
// Regression guards for two Edit-Metadata modal fixes (static/app.js):
//
// 1. Year is editable — the modal renders an `edit-year` field and
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
// (Backend already accepts/normalizes year; only the UI omitted it.)
//
// 2. A click-drag that starts inside a field and is released on the backdrop
// must NOT dismiss the modal. _editModalShouldClose() gates backdrop
// dismissal on the mousedown having started on the backdrop too.
//
// Functions are extracted from the real shipped source and run in a vm — no
// mirror copies.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const readApp = () => fs.readFileSync(APP_JS, 'utf8');
function loadFn(signature, sandbox, exportAs) {
const fnSrc = extractFunction(readApp(), signature);
const ctx = vm.createContext(sandbox);
vm.runInContext(`${fnSrc}\nglobalThis.${exportAs} = ${exportAs};`, ctx);
return sandbox[exportAs];
}
// ── Issue: Edit Metadata does not allow changing Year ────────────────────────
test('openEditModal renders a Year field bound to songData.y', () => {
const src = extractFunction(readApp(), 'function openEditModal');
assert.match(src, /id="edit-year"/, 'modal must render an #edit-year input');
assert.match(src, /_escAttr\(songData\.y\)/, 'year input must be populated from songData.y');
});
test('Save button wires via data-edit-save, not an inline onclick that embeds the filename', () => {
// encodeURIComponent does NOT escape `'`, so embedding the filename in a
// single-quoted inline `saveEditModal('…')` handler breaks the save for a
// song whose filename contains an apostrophe (e.g. `Bob's Song.sloppak`).
// The Save button must use the data-attr + JS-listener pattern instead.
const src = extractFunction(readApp(), 'function openEditModal');
assert.doesNotMatch(src, /onclick="saveEditModal\('/, 'Save must not embed the filename in an inline onclick');
assert.match(src, /data-edit-save/, 'Save button must carry the data-edit-save hook');
assert.match(src, /querySelector\('\[data-edit-save\]'\)/, 'Save must be wired via addEventListener');
});
test('saveEditModal includes year in the metadata POST body', async () => {
const calls = [];
const values = {
'edit-title': 'My Title', 'edit-artist': 'My Artist',
'edit-album': 'My Album', 'edit-year': '1998',
'edit-art-file': null, // signals the file branch via .files below
'edit-modal': null,
};
const sandbox = {
decodeURIComponent, encodeURIComponent, JSON, Promise,
_lastLibSelected: null,
loadLibrary: () => {}, loadFavorites: () => {},
fetch: (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); },
document: {
getElementById: (id) => {
if (id === 'edit-art-file') return { files: null };
if (id === 'edit-modal') return null;
return id in values ? { value: values[id] } : null;
},
querySelector: () => null, // no active screen
body: { contains: () => false },
},
};
const saveEditModal = loadFn('async function saveEditModal', sandbox, 'saveEditModal');
await saveEditModal(encodeURIComponent('Song With Spaces.sloppak'));
const metaCall = calls.find((c) => /\/api\/song\/.+\/meta$/.test(c.url));
assert.ok(metaCall, 'expected a POST to /api/song/<filename>/meta');
const body = JSON.parse(metaCall.opts.body);
assert.equal(body.year, '1998', 'meta POST body must carry the edited year');
assert.deepEqual(
body,
{ title: 'My Title', artist: 'My Artist', album: 'My Album', year: '1998' },
'meta POST body shape',
);
});
// ── Issue: Renaming Metadata Closes Modal (click-drag release on backdrop) ────
test('_editModalShouldClose: backdrop needs mousedown to have started there', () => {
const fn = loadFn('function _editModalShouldClose', {}, '_editModalShouldClose');
const modalEl = { closest: () => null }; // the backdrop element
const innerEl = { closest: () => null }; // a field inside the modal
const cancelBtn = { closest: (s) => (s === '[data-edit-close]' ? { tag: 'button' } : null) };
// Cancel / ✕ always closes, regardless of where the mousedown began.
assert.equal(fn(cancelBtn, modalEl, false), true, 'Cancel/✕ closes');
assert.equal(fn(cancelBtn, modalEl, true), true, 'Cancel/✕ closes (down-on-backdrop irrelevant)');
// Genuine backdrop click: down AND up on the backdrop.
assert.equal(fn(modalEl, modalEl, true), true, 'backdrop down+up closes');
// The reported bug: drag began inside a field (down NOT on backdrop), click
// resolves to the backdrop on release — must NOT close.
assert.equal(fn(modalEl, modalEl, false), false, 'drag-from-field release on backdrop does NOT close');
// A click that lands on inner content never closes via the backdrop path.
assert.equal(fn(innerEl, modalEl, true), false, 'click on inner content does not close');
});
@@ -130,6 +130,59 @@ test('measure-start cache is invalidated on song change', () => {
);
});
// ── Fret-row fit guard ──────────────────────────────────────────────────────
// Keeps the heat-coloured fret-number row from clipping off the bottom edge
// when a tight, centred zoom (worst mid-neck) drops it below the lower-third
// framing. camUpdate dollies the camera back via a capped, hysteretic boost.
test('fret-row fit guard constants are defined', () => {
for (const name of [
'FRET_ROW_FIT_NDC_MIN', 'FRET_ROW_FIT_DEADBAND', 'FRET_ROW_FIT_BOOST_MAX',
]) {
assert.match(src, new RegExp('const\\s+' + name + '\\s*='),
`${name} must be declared as a fit-guard constant`);
}
});
test('the curDist lerp target applies the fit-guard dolly boost', () => {
// The span-driven tgtDist still owns zooming in; the boost only pulls back.
assert.match(
src,
/curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
'curDist must lerp toward tgtDist * _fretRowFitBoost',
);
});
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
assert.match(
src,
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
'the guard must probe the same row band the fret-number row is drawn at',
);
// Prompt pull-back when below the min, capped at BOOST_MAX.
assert.match(
src,
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
);
// Lazy relax only once past the deadband, floored at 1.
assert.match(
src,
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
'past the deadband the boost relaxes back toward 1',
);
});
test('the fit guard yields to the free-cam (Camera Director)', () => {
// When the free-cam owns the view the auto dolly must reset to 1, not fight it.
assert.match(
src,
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
'with the free-cam enabled the guard must drop any auto dolly back to 1',
);
});
// ── Debug hook stayed removed ───────────────────────────────────────────────
test('temporary camera debug hook is not present', () => {
+174
View File
@@ -0,0 +1,174 @@
// Pins the wide-pane horizontal-FOV-hold ("Hor+") framing in
// plugins/highway_3d/screen.js.
//
// What it guards: ultra-wide panes (top/bottom 2-player split → full-width /
// half-height → ~32:9) used to render the neck as a thin central sliver because
// THREE's PerspectiveCamera fov is VERTICAL and was locked at 70°, ballooning
// the horizontal cone past 130°. The fix lets camUpdate lower the effective
// vertical fov as the pane widens (holding the horizontal cone ~constant) so the
// neck fills the pane. It is gated behind window.__h3dAspectTune (default off →
// byte-for-byte the prior behaviour) for live A/B comparison.
//
// A refactor that re-hardcodes the camera fov, drops the change-guarded cam.fov
// write, stops caching the pane aspect, or removes the no-op-at-startAspect
// guarantee would silently regress the feature (or worse, change normal-pane
// framing). These are source-level pins — same strategy as the other
// tests/js/ files (no DOM / WebGL in CI).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// ── Constants ────────────────────────────────────────────────────────────────
test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => {
assert.match(
src,
/const\s+BASE_VFOV\s*=\s*70\s*;/,
'BASE_VFOV must be declared as a constant',
);
});
test('the camera is constructed with BASE_VFOV, not a bare 70', () => {
assert.match(
src,
/new\s+T\.PerspectiveCamera\(\s*BASE_VFOV\s*,/,
'PerspectiveCamera must take BASE_VFOV as its vertical fov',
);
});
test('the Hor+ start-aspect and min-vfov defaults exist', () => {
assert.match(src, /const\s+HORPLUS_START_ASPECT\s*=\s*16\s*\/\s*9\s*;/,
'HORPLUS_START_ASPECT must default to 16/9 (no-op at/under the reference aspect)');
assert.match(src, /const\s+HORPLUS_MIN_VFOV\s*=\s*\d+\s*;/,
'HORPLUS_MIN_VFOV floor must be declared');
});
// ── effectiveVfov: no-op guarantees ──────────────────────────────────────────
test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
// The disabled / malformed-input guard returns `base` before any Hor+ math,
// so normal panes are unaffected when __h3dAspectTune is missing or off.
assert.match(
src,
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
'effectiveVfov must short-circuit to the base fov when disabled',
);
});
test('effectiveVfov is a no-op at/under the start aspect', () => {
assert.match(
src,
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
);
});
// ── shipped defaults: off + coherent ─────────────────────────────────────────
// The "default off → byte-for-byte prior behaviour" contract only holds if the
// shipped _ASPECT_DEFAULTS actually ship disabled with a base that matches the
// camera's constructed fov. A previous revision shipped enabled:true with
// baseVfov:30 (and blend:0), which forced every pane's fov to 30/36 and
// silently re-framed normal single-player panes. These pin against that.
test('_ASPECT_DEFAULTS ships disabled (no-op out of the box)', () => {
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\benabled\s*:\s*false\b/,
'_ASPECT_DEFAULTS.enabled must default to false so the feature is opt-in',
);
});
test('the default base fov matches BASE_VFOV (enabling is still a no-op on normal panes)', () => {
// baseVfov === BASE_VFOV means even with the feature ON, a <=startAspect pane
// returns the unchanged 70° — the effect is confined to genuinely wide panes.
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bbaseVfov\s*:\s*BASE_VFOV\b/,
'_ASPECT_DEFAULTS.baseVfov must default to BASE_VFOV, not a divergent literal',
);
});
test('the default blend engages the hold and the floor sits below the base', () => {
// blend:1 means turning the feature on actually holds the horizontal cone
// (blend:0 would collapse effectiveVfov back to base = feature inert), and
// minVfovDeg:HORPLUS_MIN_VFOV keeps the floor below baseVfov (a real floor,
// not one that clamps the base upward).
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bblend\s*:\s*1\b/,
'_ASPECT_DEFAULTS.blend must default to 1 so the Hor+ hold actually applies when enabled',
);
assert.match(
src,
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bminVfovDeg\s*:\s*HORPLUS_MIN_VFOV\b/,
'_ASPECT_DEFAULTS.minVfovDeg must default to HORPLUS_MIN_VFOV (a floor below baseVfov)',
);
});
// ── camUpdate: change-guarded fov write + cached aspect ───────────────────────
test('applySize caches the pane aspect for camUpdate', () => {
assert.match(
src,
/_paneAspect\s*=\s*cam\.aspect\s*;/,
'applySize must cache cam.aspect into _paneAspect',
);
});
test('camUpdate reads the live tune bridge and respects splitOnly', () => {
assert.match(
src,
/const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()',
);
});
test('the tune bridge seeds from localStorage (persisted sessions apply on load)', () => {
assert.match(
src,
/function\s+_aspectTune\s*\(\)[\s\S]*?localStorage\.getItem\(\s*_ASPECT_LS\s*\)/,
'_aspectTune() must seed the bridge from localStorage',
);
});
test('a floating tuner panel is built and toggled with the A/B state', () => {
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
'_ensureAspectPanel() must exist to build the live panel');
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
'_setAspectPanelVisible() must show/hide the panel with the feature');
});
test('camUpdate only writes cam.fov when it actually changes', () => {
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady
// pane and keeps the disabled path free.
assert.match(
src,
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
'camUpdate must guard the cam.fov write behind a change check',
);
});
// ── A/B toggle + lifecycle reset ──────────────────────────────────────────────
test('an A/B toggle shortcut flips the tune enabled flag', () => {
assert.match(
src,
/registerShortcut\(\{[\s\S]*?const\s+t\s*=\s*_aspectTune\(\)\s*;[\s\S]*?t\.enabled\s*=\s*!\s*t\.enabled/,
'a registerShortcut handler must toggle the bridge enabled flag',
);
});
test('destroy() resets the pane aspect and restores the base fov', () => {
assert.match(src, /_paneAspect\s*=\s*0\s*;/,
'destroy() must reset _paneAspect to 0');
assert.match(
src,
/cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/,
'destroy() must restore cam.fov to BASE_VFOV for instance reuse',
);
});
@@ -0,0 +1,69 @@
// Regression: the play/pause button must not be reset to "Play" when an
// in-flight togglePlay() audio.play() is rejected *because the engine reroute
// (HTML5 -> JUCE) deliberately paused the <audio> element*. Playback continues
// on the JUCE transport, so the button must stay "Pause" (isPlaying true).
//
// Bug: first song after a fresh load on desktop — the reroute's audio.pause()
// aborts autoplay's play(); togglePlay's catch then flipped the button to Play
// while the song kept playing, so it took two clicks to actually pause.
//
// Same isolation strategy as autoplay_exit.test.js: extract togglePlay() from
// app.js by brace-matching and run it in a vm sandbox with stubbed deps.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
const TOGGLE_PLAY_SRC = extractFunction(SRC, 'async function togglePlay(');
// Drive togglePlay() from the not-playing state with an HTML5 audio.play() that
// rejects, optionally with a reroute in progress. Returns the observed button
// states and the final isPlaying flag.
async function runTogglePlayRejecting({ rerouteInProgress }) {
const buttonStates = [];
const sandbox = {
console: { log() {}, warn() {}, error() {} },
// not-playing -> togglePlay takes the HTML5 play branch
isPlaying: false,
_audioSeekGen: 0,
_playAttemptGen: 0,
setPlayButtonState(v) { buttonStates.push(v); },
audio: {
// Reject like the browser does when a pending play() is interrupted
// by a pause() (the reroute's deliberate audio.pause()).
play: () => Promise.reject(new DOMException('aborted by pause', 'AbortError')),
pause() {},
},
jucePlayer: { play: () => Promise.resolve(true), pause: () => Promise.resolve() },
window: {
_juceMode: false,
_juceRerouteInProgress: rerouteInProgress ? 1 : 0,
feedBack: { isPlaying: false, emit() {} },
},
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
await vm.runInContext('togglePlay()', sandbox);
return { buttonStates, isPlaying: sandbox.isPlaying };
}
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
const { buttonStates, isPlaying } = await runTogglePlayRejecting({ rerouteInProgress: true });
// Optimistic flip to Pause happened; the reroute guard must prevent the
// catch from flipping it back to Play.
assert.deepEqual(buttonStates, [true], 'button should only have been set to Pause, never reset to Play');
assert.equal(isPlaying, true, 'isPlaying must stay true — the JUCE transport owns playback');
});
test('a genuine play() rejection (no reroute) still resets the button to Play', async () => {
const { buttonStates, isPlaying } = await runTogglePlayRejecting({ rerouteInProgress: false });
assert.deepEqual(buttonStates, [true, false], 'button set to Pause then correctly reset to Play on real failure');
assert.equal(isPlaying, false, 'isPlaying must reflect the failed start');
});
+43
View File
@@ -0,0 +1,43 @@
// Guards the Section Practice popover's outside-click dismiss in static/app.js
// (_installSectionPracticeDismiss). The v3 player-rail icon buttons call
// e.stopPropagation() in their click handler (static/v3/player-chrome.js
// wireRail), so a BUBBLE-phase document dismiss never fires when the user clicks
// a different rail icon (Plugins, Audio, …) — leaving the Practice popover
// stranded open under the newly-opened one (feedBack#638). The dismiss must bind
// in the CAPTURE phase (runs before the target's stopPropagation can swallow it).
// Esc must stay bubble-phase so it doesn't reorder ahead of the player's
// Escape-to-exit handling. A revert to bubble-phase should fail here.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
const body = m[0];
test('the outside-click dismiss binds in the CAPTURE phase', () => {
assert.match(
body,
/addEventListener\(\s*['"]click['"][\s\S]*?,\s*true\s*\)/,
'the click dismiss must pass the capture flag (`, true`) so a rail icon\'s '
+ 'stopPropagation() cannot swallow it',
);
});
test('only the click listener is capture (Escape keydown stays bubble-phase)', () => {
// Exactly one capture binding in the installer — the click. The keydown
// (Escape) listener must NOT be capture.
const captureBinds = body.match(/,\s*true\s*\)/g) || [];
assert.equal(captureBinds.length, 1, 'expected exactly one capture-phase binding (the click)');
});
test('the dismiss ignores clicks inside the control (no self-close)', () => {
assert.match(body, /section-practice-control/, 'must scope to #section-practice-control');
assert.match(body, /ctrl\s*&&\s*ctrl\.contains\(e\.target\)\)\s*return/,
'a click inside the control (incl. the pill) must not dismiss the popover');
});
+133
View File
@@ -0,0 +1,133 @@
// Verify the feedpak credits overlay helpers in app.js:
// - _creditLineLabel() role → friendly "<verb> by" label
// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list
// - hideSongCreditsOverlay() removes the overlay element
//
// Same isolation strategy as autoplay_exit.test.js — extract the functions
// from app.js by brace-matching and run them in a vm sandbox with a fake DOM.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
// Minimal fake DOM element: records className, children, and textContent.
// Setting textContent clears children (matching real DOM) so we can assert
// names were set via textContent (not innerHTML) — the XSS-safety contract.
function makeEl() {
return {
className: '',
children: [],
_text: '',
set textContent(v) { this._text = String(v); this.children = []; },
get textContent() { return this._text; },
appendChild(c) { this.children.push(c); return c; },
replaceChildren() { this.children = []; },
remove() { this.removed = true; },
};
}
function allText(node) {
let s = node._text || '';
for (const c of node.children) s += allText(c);
return s;
}
function buildSandbox(currentSong) {
const body = makeEl();
const sandbox = {
document: { body, createElement: () => makeEl() },
window: { feedBack: { currentSong, off() {} } },
setTimeout: () => 1,
clearTimeout: () => {},
};
vm.createContext(sandbox);
const preamble = `
let _creditsOverlay = null;
let _creditsTimer = null;
let _creditsHideOnPlay = null;
let _creditsMaxTimer = null;
const _CREDITS_MAX_MS = 12000;
const _CREDIT_ROLE_VERBS = ${JSON.stringify({
charter: 'Charted by', transcriber: 'Transcribed by',
arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by',
engineer: 'Engineered by', proofreader: 'Proofread by',
})};
`;
vm.runInContext(
preamble
+ extractFunction(SRC, 'function _creditLineLabel(') + '\n'
+ extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n'
+ extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n'
+ 'globalThis._creditLineLabel = _creditLineLabel;'
+ 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;'
+ 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;'
+ 'globalThis._getOverlay = () => _creditsOverlay;',
sandbox,
);
return sandbox;
}
test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => {
const s = buildSandbox({});
assert.equal(s._creditLineLabel('charter'), 'Charted by');
assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive
assert.equal(s._creditLineLabel('mixer'), 'Mixed by');
assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased
assert.equal(s._creditLineLabel(null), ''); // no role → bare name
assert.equal(s._creditLineLabel(''), '');
});
test('showSongCreditsOverlay builds a card with heading + credit lines', () => {
const s = buildSandbox({ title: 'My Song' });
s.showSongCreditsOverlay([
{ name: 'Azure', role: 'charter' },
{ name: 'Bob Lee', role: 'editor' },
{ name: 'Solo', role: null },
]);
const overlay = s._getOverlay();
assert.ok(overlay, 'overlay created');
assert.equal(overlay.className, 'song-credits-overlay');
assert.equal(s.document.body.children.length, 1);
const text = allText(overlay);
assert.match(text, /My Song/); // heading is the song title
assert.match(text, /Charted by/);
assert.match(text, /Azure/);
assert.match(text, /Edited by/);
assert.match(text, /Bob Lee/);
assert.match(text, /Solo/); // role-less entry still shows the name
});
test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([{ name: '<img src=x onerror=alert(1)>', role: 'charter' }]);
const overlay = s._getOverlay();
// The raw string survives verbatim as text — proving it was never parsed
// as HTML (no innerHTML interpolation anywhere on the path).
assert.match(allText(overlay), /<img src=x onerror=alert\(1\)>/);
});
test('showSongCreditsOverlay is a no-op for empty / non-array input', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([]);
assert.equal(s._getOverlay(), null);
s.showSongCreditsOverlay(undefined);
assert.equal(s._getOverlay(), null);
assert.equal(s.document.body.children.length, 0);
});
test('hideSongCreditsOverlay removes the overlay', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]);
const overlay = s._getOverlay();
assert.ok(overlay);
s.hideSongCreditsOverlay();
assert.equal(overlay.removed, true);
assert.equal(s._getOverlay(), null);
});
+37
View File
@@ -0,0 +1,37 @@
// Guard: a song's ⋮ "More" menu offers "Add to playlist" for a single song —
// not only the select-mode checkbox + batch-bar flow. Both paths share the
// extracted addFilenamesToPlaylist() helper. (Menu/DOM wiring isn't headlessly
// unit-testable, so these are source-level guards.)
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js'), 'utf8');
test('the ⋮ card menu lists an "Add to playlist" row', () => {
assert.match(SONGS, /id:\s*'__playlist',\s*label:\s*'Add to playlist'/);
});
test('the menu row adds the single song via the shared helper', () => {
assert.match(SONGS, /id === '__playlist'[\s\S]{0,100}addFilenamesToPlaylist\(\[song\.filename\]\)/);
});
test('batch and single-song add share addFilenamesToPlaylist()', () => {
assert.match(SONGS, /async function addFilenamesToPlaylist\(filenames\)/);
assert.match(SONGS, /async function batchAddToPlaylist\(\)[\s\S]{0,120}addFilenamesToPlaylist\(state\.selected\)/);
});
test('batch only finishes (clears selection) when the add succeeded, not on cancel', () => {
// addFilenamesToPlaylist returns null on a cancelled/failed picker; the
// batch caller must capture it and gate finishBatch() on a truthy pid, so
// cancelling preserves the multi-select (regression guard for the
// extract-helper refactor — previously finishBatch ran unconditionally).
assert.match(SONGS, /const pid = await addFilenamesToPlaylist\(state\.selected\)/,
'batch must capture the returned playlist id');
assert.match(SONGS, /if \(pid\) finishBatch\(\)/,
'finishBatch must be gated on a successful add (truthy pid)');
});
+94
View File
@@ -0,0 +1,94 @@
// Pins the v3 Songs AZ jump rail wiring in static/v3/songs.js.
//
// The rail lets a user jump the library grid to artists/titles starting with a
// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3
// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first
// card's absolute index (cumulative of prior buckets), which converts to a
// scrollTop — no page-through. The rail only offers letters the server reports
// present for the active sort+filter (so a tap always lands on a real card). It
// is shown only for the grid view + alphabetical (artist/title) sorts.
//
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('the rail is context-gated to grid view + alphabetical sorts', () => {
// railSortColumn returns the active alpha column or null (recent/year/tuning).
assert.match(src, /function\s+railSortColumn\s*\(\)/);
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
assert.match(
src,
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
'the rail must be visible only for the grid view + an alphabetical sort',
);
});
test('cards carry a data-letter bucket and non-AZ buckets under #', () => {
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
'each card must tag its sort-letter bucket via songBucket(song)');
assert.match(
src,
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
'songBucket must bucket non-AZ first chars under "#"',
);
});
test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => {
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
'refreshRail must query /api/library/stats with the active filter params');
// Opts into the active-sort breakdown so non-rail callers skip the scan.
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
'refreshRail must request the sort_letters breakdown');
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
'refreshRail must prefer the active-sort breakdown (sort_letters)');
// The legacy artist `letters` is only a valid fallback for an artist sort;
// a title sort with no sort_letters hides the rail rather than mislabel it.
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
'refreshRail must only fall back to letters for an artist sort');
// Absent letters are disabled (non-interactive), not just dimmed.
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
});
test('reload() refreshes the rail', () => {
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
});
test('the rail + drag bubble are rendered in the Songs markup', () => {
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
assert.match(src, /id="v3-songs-azbubble"/);
});
test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => {
// The cumulative-count seek: sum the song-counts of buckets ordered before
// the target to get its first row's absolute index.
assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/,
'jumpToLetter must derive the target index from sort_letters counts');
assert.match(
src,
/async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/,
'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)',
);
// It pre-fetches the destination window so cards are ready when the scroll lands.
assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/,
'jumpToLetter must pre-fetch the destination window before scrolling');
// The old forward-paging helper is gone (the seek is O(1)).
assert.doesNotMatch(src, /_loadNextAwait/,
'the page-through helper must be removed under the windowed grid');
// A token still guards overlapping jumps (drag scrubbing) — newest wins.
assert.match(src, /_jumpToken\s*!==\s*myToken/);
});
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
assert.match(src, /addEventListener\('pointerdown'/);
assert.match(src, /addEventListener\('pointermove'/);
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
'arrow keys must move between present letters');
});
+34
View File
@@ -0,0 +1,34 @@
// Pins the v3 "Save as collection" wiring in static/v3/songs.js (#636 item 2).
// A smart collection is a saved live library filter, surfaced as a source in
// the provider picker; the drawer can save the current filter set as one.
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('currentFilterRules builds the raw query-param rule object', () => {
assert.match(src, /function\s+currentFilterRules/);
// Multi-value filters are CSV strings (what the backend stores / re-parses).
assert.match(src, /r\.tunings\s*=\s*f\.tunings\.join\(','\)/);
assert.match(src, /r\.arrangements_has\s*=\s*f\.arr_has\.join\(','\)/);
});
test('saving POSTs to /api/collections with name + rules', () => {
assert.match(
src,
/fetch\('\/api\/collections',[\s\S]*?JSON\.stringify\(\{\s*name,\s*rules\s*\}\)/,
'saveCurrentAsCollection must POST {name, rules} to /api/collections',
);
// After save, switch the source to the new collection and rebuild the UI.
assert.match(src, /state\.provider\s*=\s*'collection:'\s*\+\s*col\.id/);
});
test('the drawer shows a Save-as-collection action only when filters are set', () => {
assert.match(src, /Object\.keys\(currentFilterRules\(\)\)\.length[\s\S]*?data-drawer-save/);
assert.match(src, /data-drawer-save[\s\S]*?saveCurrentAsCollection/);
});
+74
View File
@@ -0,0 +1,74 @@
// Pins the practice-aware library home in static/v3/songs.js:
// - a "Repertoire" progress meter (mastered / total library songs), and
// - a "Keep practicing" shelf (recently played, not yet mastered).
// Both reuse existing data (/api/stats/best already in state.accuracy, and
// /api/stats/recent) and are shown only on the unfiltered grid front door.
//
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('repertoire uses the same mastery threshold as the green accuracy badge', () => {
assert.match(src, /const\s+MASTERY_ACCURACY\s*=\s*0\.9/);
assert.match(
src,
/function\s+_repertoireCounts[\s\S]*?v\s*>=\s*MASTERY_ACCURACY\s*\)\s*mastered\+\+;\s*else\s+learning\+\+/,
'repertoire counts must bucket scored songs into mastered/learning at MASTERY_ACCURACY',
);
});
test('the home is the unfiltered grid front door, local provider only', () => {
assert.match(
src,
/function\s+libHomeVisible[\s\S]*?state\.view === 'grid'[\s\S]*?state\.provider === 'local'[\s\S]*?!state\.selectMode[\s\S]*?!state\.q[\s\S]*?activeFilterCount\(\)\s*===\s*0/,
'libHomeVisible must require grid view, the local provider, no select mode, no search, no active filters',
);
});
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
assert.match(src, /\/api\/stats\/recent\?limit=/);
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
// shows), not the per-arrangement recents row, and each filename appears
// once — so no green-badged "keep practicing" card and no duplicates.
assert.match(
src,
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
);
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
});
test('the meter + shelf fetch together and a stale render is discarded', () => {
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
'the two reads must be issued together (Promise.all), not sequentially');
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
'a stale render must be superseded by a newer one via a token');
});
test('the repertoire denominator is the unfiltered library total', () => {
assert.match(src, /\/api\/library\/stats\?provider='/);
assert.match(src, /total_songs\s*\?\?\s*stats\.total/);
assert.match(src, /Math\.round\(\(mastered\s*\/\s*total\)\s*\*\s*100\)/);
});
test('the home + #v3-lib-home host are wired into render and reload', () => {
assert.match(src, /id="v3-lib-home"/, 'render() must include the #v3-lib-home host');
assert.match(src, /function reload\s*\([\s\S]*?updateLibraryHome\(\)/,
'reload() must refresh/toggle the home');
assert.match(src, /function applyScoreRefresh[\s\S]*?renderLibraryHome\(\)/,
'a new score must refresh the meter + shelf');
});
test('shelf cards play the song on click', () => {
assert.match(
src,
/querySelectorAll\('\.v3-kp-card'\)[\s\S]*?window\.playSong\(enc\(fn\)/,
'a shelf card click must call window.playSong with the recents filename',
);
});
+40
View File
@@ -0,0 +1,40 @@
// Regression guard for "No DLC until restart": a library scan triggered from
// Settings (rescan / full rescan, e.g. right after pointing at a DLC folder)
// reloaded only the classic library — the v3 Songs grid kept its cached
// (pre-DLC, empty) state until an app restart.
//
// The fix wires a `library:changed` event (emitted by the rescan handlers in
// app.js) to a reload in static/v3/songs.js. That's DOM/event glue, not a pure
// function, so these are source-level guards that the wiring isn't dropped; the
// end-to-end behavior is verified in-app / by a browser test.
'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 SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
test('app.js emits library:changed when a Settings rescan completes', () => {
assert.match(APP, /emit\(\s*['"]library:changed['"]/,
'a completed rescan must broadcast library:changed for the v3 grid');
});
test('songs.js handles library:changed — reload when active, else mark dirty', () => {
const m = SONGS.match(/sm\.on\(\s*['"]library:changed['"][\s\S]{0,500}?\}\);/);
assert.ok(m, 'songs.js must subscribe to library:changed');
assert.match(m[0], /reload\(\)/, 'reloads the grid when the screen is active');
assert.match(m[0], /_libraryDirty\s*=\s*true/, 'marks dirty when off-screen');
});
test('onV3SongsScreenEnter forces a reload when the library is dirty', () => {
const m = SONGS.match(/function onV3SongsScreenEnter\(\)[\s\S]{0,400}?\{/);
assert.ok(m, 'onV3SongsScreenEnter present');
// The dirty check must short-circuit to a reload before the cached-DOM
// fast-paths get a chance to restore the stale grid.
assert.match(SONGS, /if\s*\(_libraryDirty\)\s*\{[^}]*reload\(\)[^}]*return;/,
'a dirty library must force a full reload on entry, ahead of any fast-path');
});
+28
View File
@@ -0,0 +1,28 @@
// Guard for the content-dependent playlist cover (playlists.js). A custom
// uploaded cover wins; otherwise the playlist's song art decides: icon when
// empty, a single cover for a few songs, a 2×2 mosaic at 4+. (Rendering is DOM
// glue, so this is a source-level guard on the decision branches.)
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const PL = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js'), 'utf8');
test('custom cover_url takes priority', () => {
assert.match(PL, /function playlistCoverHtml\(p\)/);
assert.match(PL, /if \(p\.cover_url\) return/);
});
test('empty → icon, <4 → single art, 4+ → 2×2 mosaic', () => {
assert.match(PL, /if \(!arts\.length\)[\s\S]{0,160}(🔖|🎵)/); // empty → icon
assert.match(PL, /arts\.length < 4\) return[\s\S]{0,120}arts\[0\]/); // a few → single cover
assert.match(PL, /grid-cols-2 grid-rows-2[\s\S]{0,120}slice\(0, 4\)/); // 4+ → mosaic
});
test('the card uses playlistCoverHtml (not the old static emoji box)', () => {
assert.match(PL, /playlistCoverHtml\(p\)/);
});
@@ -0,0 +1,99 @@
// Regression guard for the post-play score-badge refresh bug
// (#574 follow-up): after finishing a song, its accuracy badge on the
// Songs screen stayed stale until a full re-render (app restart / search /
// re-enter), even though stats-recorder fired `stats:recorded`.
//
// Root cause: `stats:recorded` (like `song:loading`) carries the filename
// exactly as handed to playSong — encodeURIComponent'd (see playCard) — but
// library cards key on the DECODED filename (data-fn = cardKey → localFilename)
// and /api/stats/best is server-canonicalized to that same decoded key. So the
// in-place repaint (repaintAccuracy) matched no card and silently no-oped.
//
// The fix is a `decFn` helper in static/v3/songs.js that decodes the event
// filename back into the card / state.accuracy key space before matching. This
// test extracts the REAL decFn from the shipped source (not a mirror) and proves
// the encoded event filename round-trips to the raw card key.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
// Brace-balanced extraction so nested braces / template strings survive.
function extractFunctionSource(src, name) {
const sig = `function ${name}`;
const start = src.indexOf(sig);
assert.ok(start !== -1, `function declaration '${name}' not found in songs.js`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${name}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces in function '${name}'`);
return src.slice(start, i);
}
function loadDecFn() {
const src = fs.readFileSync(SONGS_JS, 'utf8');
const fnSrc = extractFunctionSource(src, 'decFn');
const sandbox = {};
vm.createContext(sandbox);
// decodeURIComponent is an intrinsic global in the fresh context.
vm.runInContext(`${fnSrc}\nglobalThis.__decFn = decFn;`, sandbox);
return sandbox.__decFn;
}
const enc = encodeURIComponent; // exactly what playCard passes to playSong
// The on-disk library filenames from the bug report's screenshots, plus a
// subfolder path (encodeURIComponent turns '/' into %2F too).
const CARD_KEYS = [
'Black Me Out.sloppak',
'All In Now.sloppak',
'Dogstar - All In Now.feedpak',
'Subdir/Song (Live).sloppak',
];
test('decFn decodes an encoded event filename back to the raw card key', () => {
const decFn = loadDecFn();
for (const key of CARD_KEYS) {
const eventFilename = enc(key); // how stats:recorded carries it
// Precondition: the encoded form does NOT equal the card key — this is
// exactly why the un-decoded match failed and the badge stayed stale.
assert.notEqual(eventFilename, key, `expected '${key}' to encode to something different`);
// The fix: decoding lands back on the card / state.accuracy key.
assert.equal(decFn(eventFilename), key, `decFn must recover the card key for '${key}'`);
}
});
test('decFn is idempotent for already-decoded filenames (no % present)', () => {
const decFn = loadDecFn();
for (const key of CARD_KEYS) {
assert.equal(decFn(key), key, `decFn must leave the already-decoded '${key}' unchanged`);
}
});
test('decFn leaves a real literal-% filename intact rather than throwing', () => {
const decFn = loadDecFn();
// '%.sloppak' / '100%.sloppak' are malformed percent-escapes —
// decodeURIComponent would throw; decFn must fall back to the original.
for (const name of ['100%.sloppak', 'mix %.feedpak', '%zz.sloppak']) {
assert.equal(decFn(name), name, `decFn must not corrupt/throw on '${name}'`);
}
});
test('decFn coerces non-string / empty input to an empty string', () => {
const decFn = loadDecFn();
assert.equal(decFn(null), '');
assert.equal(decFn(undefined), '');
assert.equal(decFn(''), '');
});
+12 -8
View File
@@ -36,13 +36,15 @@ function makeStore() {
};
}
function saveSnapshot(storage, state, scrollTop, page, loadedCount) {
// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed
// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just
// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets
// scrollTop and re-renders the window that maps to it).
function saveSnapshot(storage, state, scrollTop) {
const snap = {
hash: buildLibraryStateHash(state),
scrollTop,
view: state.view,
page,
loadedCount,
};
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
}
@@ -88,19 +90,21 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
});
test('snapshot stores scrollTop and page', () => {
test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => {
const storage = makeStore();
saveSnapshot(storage, baseState, 1840, 3, 96);
saveSnapshot(storage, baseState, 1840);
const snap = readSnapshot(storage);
assert.strictEqual(snap.scrollTop, 1840);
assert.strictEqual(snap.page, 3);
assert.strictEqual(snap.loadedCount, 96);
assert.strictEqual(snap.view, 'grid');
assert.strictEqual(snap.hash, buildLibraryStateHash(baseState));
// Page-depth bookkeeping is gone — the windowed grid restores from scrollTop.
assert.strictEqual(snap.page, undefined);
assert.strictEqual(snap.loadedCount, undefined);
});
test('stale snapshot is detected when filters change', () => {
const storage = makeStore();
saveSnapshot(storage, baseState, 500, 1, 48);
saveSnapshot(storage, baseState, 500);
const snap = readSnapshot(storage);
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
assert.notStrictEqual(snap.hash, changed);
+80
View File
@@ -0,0 +1,80 @@
// Guards the v3 text-selection policy (static/v3/v3.css + static/v3/index.html):
// the UI defaults to non-selectable so accidental chrome selection can't look
// broken, while form fields, plugin screens, and core content opt back in. A
// future global reset clobbering the rule — or the content containers losing
// their .fb-selectable opt-in — should fail here.
//
// Source-level only — same strategy as the other tests/js/ files.
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, '..', '..');
// Strip block comments so the policy's own explanatory prose (which quotes the
// `* { user-select:none }` anti-pattern as a warning) can't trip the assertions.
const css = fs.readFileSync(path.join(root, 'static', 'v3', 'v3.css'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '');
const html = fs.readFileSync(path.join(root, 'static', 'v3', 'index.html'), 'utf8');
test('v3 defaults to non-selectable on html (not a universal `*` rule)', () => {
assert.match(css, /html\s*\{[^}]*user-select:\s*none/,
'html must default user-select: none');
// The `* { user-select: none }` anti-pattern breaks input carets / IME — must not exist.
assert.doesNotMatch(css, /\*\s*\{[^}]*user-select:\s*none/,
'must NOT use a universal `*` user-select:none rule');
});
test('form fields are always re-enabled (caret / IME safe)', () => {
assert.match(
css,
/input,\s*textarea,\s*select[\s\S]*?contenteditable[\s\S]*?user-select:\s*text/,
'input/textarea/select/[contenteditable] must be re-enabled to user-select: text',
);
});
test('plugin screen subtree stays selectable by inheritance (no `*`, respects plugin opt-outs)', () => {
assert.match(
css,
/\.screen\[id\^="plugin-"\]\s*\{[^}]*user-select:\s*text/,
'plugin screens must be re-enabled so plugin content is not silently un-copyable',
);
assert.doesNotMatch(
css,
/\.screen\[id\^="plugin-"\]\s*\*/,
'the plugin carve must NOT use `*` (would override a plugin\'s own non-select chrome)',
);
});
// The rule that re-enables selection on copyable content. Find the single
// declaration block whose body sets `user-select: text`, then assert each
// required selector is one of its selectors — order/format independent.
const selectableRule = (css.match(/([^{}]*)\{[^}]*user-select:\s*text[^}]*\}/g) || [])
.join('\n');
test('core content opts back in via .fb-selectable (element + descendants)', () => {
assert.match(selectableRule, /\.fb-selectable\b/, '.fb-selectable must set user-select: text');
assert.match(selectableRule, /\.fb-selectable\s*\*/, '...and its descendants (.fb-selectable *)');
});
test('focused copyable surfaces (modals/toasts/scan banner) opt back in', () => {
// The PR\'s a11y guardrail keeps copyable text selectable "incl. in
// modals/toasts" — these carry errors / IDs / paths the user copies.
assert.match(selectableRule, /\.feedBack-modal\b/, 'modals (.feedBack-modal) must be selectable');
assert.match(selectableRule, /\[role="dialog"\]/, 'dialogs ([role="dialog"]) must be selectable');
assert.match(selectableRule, /#fb-notify-stack\b/, 'toasts (#fb-notify-stack) must be selectable');
assert.match(selectableRule, /#scan-banner\b/, 'the scan banner (#scan-banner) must be selectable');
});
// Match a class="" attribute that contains ALL given tokens in any order.
const hasClasses = (...tokens) => new RegExp(
'class="' + tokens.map((t) => '(?=[^"]*\\b' + t + '\\b)').join('') + '[^"]*"');
test('the Settings panel and now-playing metadata carry .fb-selectable', () => {
assert.match(html, hasClasses('fb-settings', 'fb-selectable'),
'the Settings panel must opt back in (paths / version / diagnostics / About)');
assert.match(html, hasClasses('fb-selectable', 'pointer-events-auto'),
'the now-playing metadata must opt back in AND re-enable pointer-events '
+ '(its #player-hud parent is pointer-events-none, which would block mouse selection)');
});
+208
View File
@@ -0,0 +1,208 @@
"""Tests for the folder_library plugin backend.
Covers the pure path-safety helpers and end-to-end behaviour of the two
filesystem-mutating endpoints whose bugs this guards against:
* /song/move must reject path traversal in `filename` (no escaping DLC_DIR).
* /folder/delete must relocate EVERY song to the root, never destroy a song
whose name collides with an existing root song.
The plugin's routes.py is loaded under a unique module name via importlib so it
does not collide in sys.modules with other bundled plugins' routes.py.
"""
import importlib.util
import logging
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_ROUTES_PATH = (
Path(__file__).resolve().parents[3]
/ "plugins" / "folder_library" / "routes.py"
)
_spec = importlib.util.spec_from_file_location("folder_library_routes", _ROUTES_PATH)
fl = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(fl)
# ── Pure helpers ────────────────────────────────────────────────────────────
class TestSafeName:
@pytest.mark.parametrize("name", ["Rock", "Folder 1", "A-B_C", "über", "AC.DC"])
def test_accepts_ordinary_names(self, name):
assert fl._safe_name(name) is True
@pytest.mark.parametrize("name", [
"", "..", ".", "../x", "a/b", "a\\b", "a:b", "a*b", "a?b",
'a"b', "a<b", "a>b", "a|b", " lead", "lead ",
])
def test_rejects_unsafe_names(self, name):
assert fl._safe_name(name) is False
class TestSafePath:
@pytest.mark.parametrize("path", ["A", "A/B", "A/B/C", "Rock/Sub Folder"])
def test_accepts_safe_paths(self, path):
assert fl._safe_path(path) is True
@pytest.mark.parametrize("path", [
"", "..", "../x", "A/../B", "A/..", "/A", "A//B", "A/b\\c",
])
def test_rejects_traversal_and_empty(self, path):
assert fl._safe_path(path) is False
class TestIsWithin:
def test_inside(self, tmp_path):
assert fl._is_within(tmp_path, tmp_path / "a" / "b") is True
def test_traversal_escapes(self, tmp_path):
root = tmp_path / "dlc"
root.mkdir()
assert fl._is_within(root, root / ".." / "secret") is False
def test_sibling_prefix_not_within(self, tmp_path):
root = tmp_path / "dlc"
root.mkdir()
(tmp_path / "dlc-evil").mkdir()
assert fl._is_within(root, tmp_path / "dlc-evil" / "x") is False
class TestIsSong:
@pytest.mark.parametrize("name", ["a.sloppak", "a.feedpak", "A.SLOPPAK"])
def test_song_extensions(self, name, tmp_path):
assert fl._is_song(tmp_path / name) is True
@pytest.mark.parametrize("name", ["a.txt", "a", "a.zip"])
def test_non_song(self, name, tmp_path):
assert fl._is_song(tmp_path / name) is False
# ── Endpoint behaviour ──────────────────────────────────────────────────────
@pytest.fixture
def env(tmp_path):
dlc = tmp_path / "dlc"
dlc.mkdir()
app = FastAPI()
fl.setup(app, {
"log": logging.getLogger("folder_library_test"),
"get_dlc_dir": lambda: str(dlc),
"extract_meta": lambda p: {},
})
return TestClient(app), dlc, tmp_path
def _song(path: Path, content: str):
path.write_text(content)
def _loose_song(folder: Path):
"""Minimal valid loose-folder song: audio + an arrangement XML (<song> root)."""
folder.mkdir(parents=True, exist_ok=True)
(folder / "audio.wem").write_bytes(b"\x00")
(folder / "lead.xml").write_text("<song><title>Loose</title></song>")
class TestLooseFolderRecognition:
def test_is_song_detects_loose_folder_dir(self, tmp_path):
loose = tmp_path / "MyLoose"
_loose_song(loose)
assert fl._is_song(loose) is True
def test_plain_folder_is_not_a_song(self, tmp_path):
plain = tmp_path / "Plain"
plain.mkdir()
(plain / "notes.txt").write_text("x")
assert fl._is_song(plain) is False
def test_loose_folder_surfaces_as_song_not_child_folder(self, env):
client, dlc, _ = env
_loose_song(dlc / "Rock" / "LooseSong")
r = client.get("/api/plugins/folder_library/tree")
assert r.status_code == 200, r.text
rock = next(f for f in r.json()["folders"] if f["name"] == "Rock")
assert "LooseSong" in {s["title"] for s in rock["songs"]}
assert "LooseSong" not in {c["name"] for c in rock["children"]}
class TestMoveTraversal:
def test_rejects_parent_traversal_and_does_not_move(self, env):
client, dlc, tmp = env
secret = tmp / "secret.sloppak"
_song(secret, "TOP SECRET")
r = client.post("/api/plugins/folder_library/song/move",
json={"filename": "../secret.sloppak", "folder": ""})
assert r.status_code == 400
# The external file must NOT have been moved into the served library.
assert secret.exists()
assert not (dlc / "secret.sloppak").exists()
def test_rejects_absolute_style_traversal(self, env):
client, dlc, tmp = env
r = client.post("/api/plugins/folder_library/song/move",
json={"filename": "../../etc/passwd", "folder": ""})
assert r.status_code == 400
def test_valid_move_succeeds(self, env):
client, dlc, _ = env
_song(dlc / "A.sloppak", "a")
(dlc / "Dest").mkdir()
r = client.post("/api/plugins/folder_library/song/move",
json={"filename": "A.sloppak", "folder": "Dest"})
assert r.status_code == 200
assert not (dlc / "A.sloppak").exists()
assert (dlc / "Dest" / "A.sloppak").read_text() == "a"
class TestDeleteFolderNoDataLoss:
def test_colliding_song_is_relocated_not_destroyed(self, env):
client, dlc, _ = env
# A root song and a same-named song inside the folder being deleted.
_song(dlc / "song.sloppak", "ROOT")
(dlc / "F").mkdir()
_song(dlc / "F" / "song.sloppak", "INSIDE")
r = client.post("/api/plugins/folder_library/folder/delete",
json={"name": "F"})
assert r.status_code == 200, r.text
# Folder gone, original root song intact, and the colliding song
# survived under a de-duplicated name (NOT destroyed by rmtree).
assert not (dlc / "F").exists()
assert (dlc / "song.sloppak").read_text() == "ROOT"
survivors = {p.read_text() for p in dlc.glob("*.sloppak")}
assert "INSIDE" in survivors
assert len(list(dlc.glob("*.sloppak"))) == 2
def test_nested_songs_all_relocated(self, env):
client, dlc, _ = env
(dlc / "F" / "Sub").mkdir(parents=True)
_song(dlc / "F" / "a.sloppak", "a")
_song(dlc / "F" / "Sub" / "b.sloppak", "b")
r = client.post("/api/plugins/folder_library/folder/delete",
json={"name": "F"})
assert r.status_code == 200, r.text
assert not (dlc / "F").exists()
names = {p.name for p in dlc.glob("*.sloppak")}
assert names == {"a.sloppak", "b.sloppak"}
class TestFolderOpsValidation:
def test_create_rejects_unsafe_name(self, env):
client, _, _ = env
r = client.post("/api/plugins/folder_library/folder/create",
json={"name": "../evil"})
assert r.status_code == 400
def test_create_and_rename_roundtrip(self, env):
client, dlc, _ = env
assert client.post("/api/plugins/folder_library/folder/create",
json={"name": "New"}).status_code == 200
assert (dlc / "New").is_dir()
assert client.post("/api/plugins/folder_library/folder/rename",
json={"old": "New", "new": "Renamed"}).status_code == 200
assert (dlc / "Renamed").is_dir()
assert not (dlc / "New").exists()
+164
View File
@@ -0,0 +1,164 @@
"""Tests for smart/dynamic collections (got-feedback/feedBack#636 item 2).
A collection is a saved set of library filter rules, surfaced as a registered
library provider so it inherits the v3 Songs UI. Storage reuses the playlists
table (a `rules` JSON blob smart collection); membership is the LIVE filter
result, not stored songs.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _put(server_mod, *, filename, title, artist, tuning_name="E Standard", tuning_sort_key=0):
server_mod.meta_db.put(filename, 1.0, 1, {
"title": title, "artist": artist, "album": "LP", "year": "", "duration": 1.0,
"tuning": tuning_name, "arrangements": [], "has_lyrics": False, "format": "archive",
"stem_count": 0, "stem_ids": [], "tuning_name": tuning_name,
"tuning_sort_key": tuning_sort_key, "tuning_offsets": "",
})
def _seed_mixed(server_mod):
_put(server_mod, filename="d1.archive", title="Drop One", artist="Anna", tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="d2.archive", title="Drop Two", artist="Bea", tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="e1.archive", title="Std One", artist="Cy", tuning_name="E Standard")
# ── CRUD ────────────────────────────────────────────────────────────────────
def test_create_list_delete_collection(client):
assert client.get("/api/collections").json() == {"collections": []}
r = client.post("/api/collections", json={"name": "Drop D stuff", "rules": {"tunings": ["Drop D"]}})
assert r.status_code == 200
col = r.json()["collection"]
assert col["name"] == "Drop D stuff"
assert col["rules"] == {"tunings": "Drop D"} # raw query-param format
cid = col["id"]
listed = client.get("/api/collections").json()["collections"]
assert [c["name"] for c in listed] == ["Drop D stuff"]
assert client.request("DELETE", f"/api/collections/{cid}").json() == {"ok": True}
assert client.get("/api/collections").json() == {"collections": []}
def test_create_requires_name_and_sanitizes_rules(client):
assert client.post("/api/collections", json={"rules": {}}).status_code == 400
# Unknown rule keys are dropped (never 500); known ones normalized to the
# raw query-param format (list→CSV, favorites→1).
col = client.post("/api/collections", json={
"name": "Mix", "rules": {"tunings": ["Drop D", "Eb Standard"], "sort": "title", "bogus": "x", "favorites": True},
}).json()["collection"]
assert col["rules"] == {"tunings": "Drop D,Eb Standard", "sort": "title", "favorites": 1}
def test_update_collection(client):
cid = client.post("/api/collections", json={"name": "A", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
r = client.put(f"/api/collections/{cid}", json={"name": "B", "rules": {"format": "sloppak"}})
assert r.status_code == 200
assert r.json()["collection"]["name"] == "B"
assert r.json()["collection"]["rules"] == {"format": "sloppak"}
assert client.put("/api/collections/99999", json={"name": "x"}).status_code == 404
# ── Provider behaviour ──────────────────────────────────────────────────────
def test_collection_registers_as_a_provider(client, server_mod):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
providers = client.get("/api/library/providers").json()["providers"]
ids = [p["id"] for p in providers]
assert f"collection:{cid}" in ids
def test_collection_provider_returns_only_matching_songs(client, server_mod):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
pid = f"collection:{cid}"
page = client.get("/api/library", params={"provider": pid}).json()
titles = sorted(s["title"] for s in page["songs"])
assert titles == ["Drop One", "Drop Two"] # E Standard song excluded
stats = client.get("/api/library/stats", params={"provider": pid}).json()
assert stats["total_songs"] == 2
def test_collection_provider_is_local_kind(client, server_mod):
# kind="local" keeps the client's play/art paths on the local branch (a
# collection's matched songs are local rows), not the remote-sync branch.
cid = client.post("/api/collections", json={"name": "C", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
prov = next(p for p in client.get("/api/library/providers").json()["providers"]
if p["id"] == f"collection:{cid}")
assert prov["kind"] == "local"
def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
# A hand-edited / imported bad rules row (int where a string is expected, a
# list for `sort`) must not crash the query — the provider re-sanitizes on
# load. Write the bad JSON straight past the API sanitizer.
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "Bad", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
# `artist: []` (list for a string field) and `sort: []` (unhashable) are
# the values that would crash `.strip()` / `sort_map.get` if they reached a
# query — they must be dropped, leaving the valid `tunings` rule intact.
server_mod.meta_db.conn.execute(
"UPDATE playlists SET rules = ? WHERE id = ?",
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
)
server_mod.meta_db.conn.commit()
server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
assert r.status_code == 200 # no 500/503 from bad rules
assert sorted(s["title"] for s in r.json()["songs"]) == ["Drop One", "Drop Two"]
def test_collection_provider_survives_restart(client, server_mod, tmp_path, monkeypatch):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
server_mod.meta_db.conn.close()
# Re-import the server (same CONFIG_DIR) → boot scan must re-register it.
sys.modules.pop("server", None)
mod2 = importlib.import_module("server")
try:
ids = [p["id"] for p in mod2.library_providers.list()]
assert f"collection:{cid}" in ids
finally:
mod2.meta_db.conn.close()
# ── Isolation from manual playlists ─────────────────────────────────────────
def test_collections_excluded_from_playlists_and_are_read_only(client):
cid = client.post("/api/collections", json={"name": "Coll", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
# Not listed among manual playlists...
assert all(p["id"] != cid for p in client.get("/api/playlists").json())
# ...and manual-playlist mutations 404 on a collection id (get_playlist gate).
assert client.post(f"/api/playlists/{cid}/songs", json={"filename": "d1.archive"}).status_code == 404
assert client.get(f"/api/playlists/{cid}").status_code == 404
+159
View File
@@ -0,0 +1,159 @@
"""Tests for feedpak contributor credits on the highway.
Covers the `_sanitize_authors` helper (unit) and the `song_info` WebSocket
frame carrying the manifest `authors` list end-to-end (integration). The
frontend uses a non-empty `authors` list to gate a credits overlay shown when
a song loads, so loose/archive/synthetic plays must surface `[]`.
"""
from __future__ import annotations
import importlib
import json
import sys
import pytest
import yaml
from fastapi.testclient import TestClient
# ── _sanitize_authors unit tests ────────────────────────────────────────────
@pytest.fixture()
def server_mod(monkeypatch, tmp_path):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
(tmp_path / "dlc").mkdir()
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def test_sanitize_authors_valid(server_mod):
out = server_mod._sanitize_authors(
{
"authors": [
{"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"},
{"name": "Bob Lee", "role": "editor"},
{"name": "Solo"},
]
}
)
# name + role only; email/url dropped; missing role → None.
assert out == [
{"name": "Azure", "role": "charter"},
{"name": "Bob Lee", "role": "editor"},
{"name": "Solo", "role": None},
]
def test_sanitize_authors_skips_malformed(server_mod):
out = server_mod._sanitize_authors(
{
"authors": [
{"name": ""}, # blank name → skipped
{"name": " "}, # whitespace name → skipped
{"role": "mixer"}, # no name → skipped
"not-a-dict", # non-dict → skipped
{"name": " Kept ", "role": " arranger "}, # trimmed
]
}
)
assert out == [{"name": "Kept", "role": "arranger"}]
@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"])
def test_sanitize_authors_absent_or_nonlist(server_mod, manifest):
assert server_mod._sanitize_authors(manifest) == []
# ── song_info WS integration ────────────────────────────────────────────────
def _write_sloppak(dlc_root, *, authors):
pak = dlc_root / "authortest.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
(pak / "arrangements" / "lead.json").write_text(
json.dumps(
{
"notes": [],
"chords": [],
"anchors": [],
"handshapes": [],
"templates": [],
"beats": [{"time": 0.0, "measure": 1}],
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
}
)
)
manifest = {
"title": "Author Test",
"artist": "Tester",
"album": "",
"year": 2026,
"duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [],
}
if authors is not None:
manifest["authors"] = authors
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
return pak
@pytest.fixture()
def make_client(tmp_path, monkeypatch):
def _make():
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
return server
(tmp_path / "dlc").mkdir()
yield _make
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def _song_info(client, path):
with client.websocket_connect(path) as ws:
for _ in range(200):
msg = ws.receive_json()
if msg.get("error"):
raise AssertionError(f"WS error frame: {msg}")
if msg.get("type") == "song_info":
return msg
if msg.get("type") == "ready":
break
raise AssertionError("no song_info frame received")
def test_song_info_carries_authors(make_client):
server = make_client()
_write_sloppak(
server._get_dlc_dir(),
authors=[{"name": "Azure", "role": "charter", "email": "a@b.c"}],
)
with TestClient(server.app) as client:
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
assert info["authors"] == [{"name": "Azure", "role": "charter"}]
def test_song_info_authors_empty_when_absent(make_client):
server = make_client()
_write_sloppak(server._get_dlc_dir(), authors=None)
with TestClient(server.app) as client:
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
assert info["authors"] == []
+39 -2
View File
@@ -398,6 +398,39 @@ def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_m
assert stats["letters"] == {"#": 1}
def test_query_stats_sort_letters_artist_counts_songs(client, server_mod):
"""The v3 jump rail's `sort_letters` counts SONGS per first-letter bucket
of the active sort column (vs `letters`, which counts distinct artists).
Two songs by the same A-artist letters {A:1}, sort_letters {A:2}."""
_put(server_mod, filename="a1.archive", title="Song One", artist="Abba")
_put(server_mod, filename="a2.archive", title="Song Two", artist="Abba")
_put(server_mod, filename="b1.archive", title="Another", artist="Beck")
_put(server_mod, filename="num.archive", title="Track", artist="2Pac")
# sort_letters=1 opts into the active-sort breakdown (the jump rail path).
stats = client.get("/api/library/stats", params={"sort": "artist", "sort_letters": 1}).json()
assert stats["letters"] == {"A": 1, "B": 1, "#": 1} # distinct artists
assert stats["sort_letters"] == {"A": 2, "B": 1, "#": 1} # songs
# Without the opt-in, the extra breakdown is not computed or returned.
plain = client.get("/api/library/stats", params={"sort": "artist"}).json()
assert "sort_letters" not in plain
assert plain["letters"] == {"A": 1, "B": 1, "#": 1}
def test_query_stats_sort_letters_follow_title_sort(client, server_mod):
"""With a title sort, the rail buckets key on the TITLE's first letter,
not the artist's, so a tap lands on a real card in the grid's order."""
_put(server_mod, filename="z1.archive", title="Apple", artist="Zztop")
_put(server_mod, filename="z2.archive", title="Banana", artist="Zztop")
stats = client.get("/api/library/stats", params={"sort": "title", "sort_letters": 1}).json()
assert stats["sort_letters"] == {"A": 1, "B": 1}
# The legacy artist breakdown is unchanged regardless of sort — both songs
# share one artist, so it stays a single distinct-artist Z bucket.
assert stats["letters"] == {"Z": 1}
def test_query_stats_ignores_null_letter_counts(server_mod):
"""Legacy/corrupt rows can surface as NULL-ish letter aggregate
rows on some SQLite builds. The stats endpoint should ignore those
@@ -429,9 +462,13 @@ def test_query_stats_ignores_null_letter_counts(server_mod):
server_mod.meta_db.conn.close()
server_mod.meta_db.conn = FakeConn()
stats = server_mod.meta_db.query_stats()
stats = server_mod.meta_db.query_stats(want_sort_letters=True)
assert stats == {"total_songs": 1, "total_artists": 1, "letters": {"T": 1}}
# `sort_letters` (the v3 jump-rail breakdown) shares the GROUP BY letter
# path in this fake, so it surfaces the same single live bucket when the
# caller opts in.
assert stats == {"total_songs": 1, "total_artists": 1,
"letters": {"T": 1}, "sort_letters": {"T": 1}}
def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded):
+154
View File
@@ -0,0 +1,154 @@
"""Keyset (cursor) pagination for the library grid (feedBack#636 item 3, stage 1).
Pins the data layer the virtualized grid builds on:
- every sort gets a unique `filename` tiebreak a TOTAL order (fixes the
latent OFFSET skip/dupe across equal-key rows);
- `/api/library?after=<cursor>` walks the SAME total order with a WHERE-seek,
returning exactly the OFFSET page would, with no gaps or dupes;
- bad cursors / non-keyset sorts fall back to OFFSET safely.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _seed(server_mod, n=25, *, shared_artist=False):
for i in range(n):
artist = "SameArtist" if shared_artist else f"Artist{i:02d}"
server_mod.meta_db.put(f"song{i:02d}.archive", float(i), 1, {
"title": f"Title{i:02d}", "artist": artist, "album": "LP", "year": "",
"duration": 1.0, "tuning": "E Standard", "arrangements": [], "has_lyrics": False,
"format": "archive", "stem_count": 0, "stem_ids": [], "tuning_name": "E Standard",
"tuning_sort_key": 0, "tuning_offsets": "",
})
def _walk_keyset(client, sort, size, total):
"""Page the whole library via the cursor and return the filename order."""
seen, cursor, guard = [], "", 0
while len(seen) < total and guard < total + 5:
guard += 1
params = {"sort": sort, "size": size}
if cursor:
params["after"] = cursor
body = client.get("/api/library", params=params).json()
seen.extend(s["filename"] for s in body["songs"])
cursor = body.get("next_cursor")
if not body["songs"] or not cursor:
break
return seen
def _walk_offset(client, sort, size, total):
seen, page = [], 0
while len(seen) < total:
body = client.get("/api/library", params={"sort": sort, "size": size, "page": page}).json()
if not body["songs"]:
break
seen.extend(s["filename"] for s in body["songs"])
page += 1
return seen
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "title", "title-desc", "recent"])
def test_keyset_matches_offset_exactly(client, server_mod, sort):
_seed(server_mod, 25)
offset_order = _walk_offset(client, sort, 7, 25)
keyset_order = _walk_keyset(client, sort, 7, 25)
assert keyset_order == offset_order # same order...
assert len(keyset_order) == 25
assert len(set(keyset_order)) == 25 # ...no gaps, no dupes
def test_stable_tiebreak_on_equal_keys(client, server_mod):
# 25 songs, all the SAME artist → the artist sort is decided entirely by the
# filename tiebreak. Both pagers must still cover all 25 with no dupe.
_seed(server_mod, 25, shared_artist=True)
keyset_order = _walk_keyset(client, "artist", 6, 25)
assert len(keyset_order) == 25 and len(set(keyset_order)) == 25
assert keyset_order == sorted(keyset_order) # tiebreak is filename ASC
def test_first_page_has_cursor_and_no_after_is_offset(client, server_mod):
_seed(server_mod, 5)
body = client.get("/api/library", params={"sort": "artist", "size": 2}).json()
assert body["next_cursor"] # cursor offered
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive"]
def test_bad_cursor_falls_back_to_first_page(client, server_mod):
_seed(server_mod, 5)
body = client.get("/api/library", params={"sort": "artist", "size": 3, "after": "not-a-cursor"}).json()
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive", "song02.archive"]
def test_legacy_dir_desc_keysets_correctly(client, server_mod):
# The legacy `sort=artist&dir=desc` shape must keyset against a DESC order
# (canonicalized to artist-desc), not seek `>` against it → no gaps/dupes.
_seed(server_mod, 20)
offset_order, page = [], 0
while True:
body = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 6, "page": page}).json()
if not body["songs"]:
break
offset_order.extend(s["filename"] for s in body["songs"])
page += 1
keyset, cursor, guard = [], "", 0
while len(keyset) < 20 and guard < 25:
guard += 1
params = {"sort": "artist", "dir": "desc", "size": 6}
if cursor:
params["after"] = cursor
body = client.get("/api/library", params=params).json()
keyset.extend(s["filename"] for s in body["songs"])
cursor = body.get("next_cursor")
if not body["songs"] or not cursor:
break
assert keyset == offset_order
assert len(set(keyset)) == 20
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "recent"])
def test_keyset_handles_null_sort_keys(client, server_mod, sort):
# NULL artist/mtime (corrupt/legacy rows past put()'s '' defaults) sort
# first in ASC / last in DESC; keyset must cover them exactly like OFFSET.
_seed(server_mod, 10)
server_mod.meta_db.conn.executemany(
"INSERT INTO songs (filename, mtime, size, title, artist) VALUES (?, NULL, 1, ?, NULL)",
[("zznull1.archive", "ZZ1"), ("zznull2.archive", "ZZ2")],
)
server_mod.meta_db.conn.commit()
offset_order = _walk_offset(client, sort, 4, 12)
keyset_order = _walk_keyset(client, sort, 4, 12)
assert keyset_order == offset_order
assert len(keyset_order) == 12 and len(set(keyset_order)) == 12
def test_non_keyset_sort_offers_no_cursor(client, server_mod):
_seed(server_mod, 5)
body = client.get("/api/library", params={"sort": "tuning", "size": 2}).json()
assert body["next_cursor"] is None # compound sort → OFFSET only
assert len(body["songs"]) == 2
+3 -1
View File
@@ -213,7 +213,9 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert stats["letters"] == {"R": 1}
assert "page" not in provider.stats_kwargs
assert "size" not in provider.stats_kwargs
assert "sort" not in provider.stats_kwargs
# `sort` is forwarded to query_stats now (the v3 jump rail keys its
# present-letter breakdown on the active sort column); defaults to "artist".
assert provider.stats_kwargs.get("sort") == "artist"
tunings = client.get("/api/library/tuning-names", params={"provider": "remote:frodo"}).json()
assert tunings["tunings"][0]["name"] == "E Standard"
+64
View File
@@ -106,3 +106,67 @@ def test_playlist_hides_dead_songs_when_library_populated(client, server):
names = [s["filename"] for s in pl["songs"]]
assert "live.archive" in names and "ghost.archive" not in names
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1
# ── Playlist covers (content-dependent art + custom upload) ──────────────────
def _png_b64():
"""A tiny base64 PNG with the data-URL prefix, like the browser sends."""
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (4, 4), (200, 30, 60)).save(buf, "PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def test_list_includes_song_art_urls_for_content_cover(client, server):
for fn in ("x.archive", "y.archive"):
server.meta_db.put(fn, 0, 0, {})
pid = client.post("/api/playlists", json={"name": "Arts"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "x.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "y.archive"})
pl = [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]
assert pl["art_urls"] == ["/api/song/x.archive/art", "/api/song/y.archive/art"]
assert pl["cover_url"] is None # no custom cover yet
def test_custom_cover_roundtrip(client):
pid = client.post("/api/playlists", json={"name": "Cover"}).json()["id"]
r = client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert r.status_code == 200 and r.json()["ok"] is True
assert r.json()["cover_url"].startswith(f"/api/playlists/{pid}/cover")
# list + detail both report it
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["cover_url"]
assert client.get(f"/api/playlists/{pid}").json()["cover_url"]
# served as a real PNG
img = client.get(f"/api/playlists/{pid}/cover")
assert img.status_code == 200 and img.headers["content-type"] == "image/png"
assert img.content[:8] == b"\x89PNG\r\n\x1a\n"
# removed
assert client.delete(f"/api/playlists/{pid}/cover").json() == {"ok": True}
assert client.get(f"/api/playlists/{pid}/cover").status_code == 404
assert client.get(f"/api/playlists/{pid}").json()["cover_url"] is None
def test_cover_rejects_non_image(client):
pid = client.post("/api/playlists", json={"name": "Bad"}).json()["id"]
assert client.post(f"/api/playlists/{pid}/cover",
json={"image": "data:text/plain;base64,bm90IGFuIGltYWdl"}).status_code == 400
assert client.post(f"/api/playlists/{pid}/cover", json={"image": ""}).status_code == 400
def test_cover_rejects_non_string_image_with_400_not_500(client):
# A non-string `image` (number / null / object) must be a clean 400, not a
# 500 from `"," in <non-str>` raising TypeError before the type check.
pid = client.post("/api/playlists", json={"name": "Typed"}).json()["id"]
for bad in (123, None, {"x": 1}, ["a"]):
assert client.post(f"/api/playlists/{pid}/cover", json={"image": bad}).status_code == 400
def test_deleting_playlist_removes_custom_cover(client, server):
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert server._playlist_cover_path(pid).exists()
client.delete(f"/api/playlists/{pid}")
assert not server._playlist_cover_path(pid).exists()
+311
View File
@@ -0,0 +1,311 @@
"""Tests for the library-DB + custom-art half of the settings bundle
(got-feedback/feedBack#636 item 1).
The base bundle (config + plugin files) is covered in test_settings_export.py;
this file pins the additive `core_server_files` section:
- the live library DB is exported as a CONSISTENT single-file snapshot
(SQLite online-backup), base64-encoded;
- custom playlist covers / avatar are walked into the bundle;
- on import the DB is STAGED to `web_library.db.restore` (never written
over the live, open DB) and swapped in at next startup, clearing stale
WAL sidecars; custom art is written immediately;
- the whole thing round-trips: export wipe import restart data back.
"""
import base64
import importlib
import sqlite3
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _valid_db_bytes(tmp_path, name="mk.db", marker="x"):
"""Bytes of a small, valid (quick_check-clean) SQLite database."""
p = tmp_path / name
c = sqlite3.connect(str(p))
try:
c.execute("CREATE TABLE t (x TEXT)")
c.execute("INSERT INTO t VALUES (?)", (marker,))
c.commit()
finally:
c.close()
return p.read_bytes()
def _seed_song(server_mod, filename="marker.archive", title="Marker", artist="Tester"):
server_mod.meta_db.put(filename, 1.0, 1, {
"title": title, "artist": artist, "album": "LP", "year": "",
"duration": 200.0, "tuning": "E Standard", "arrangements": [],
"has_lyrics": False, "format": "archive", "stem_count": 0,
"stem_ids": [], "tuning_name": "E Standard", "tuning_sort_key": 0,
"tuning_offsets": "",
})
# ── Export ──────────────────────────────────────────────────────────────────
def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_path):
_seed_song(server_mod, filename="snap.archive", title="SnapSong")
bundle = client.get("/api/settings/export").json()
core = bundle["core_server_files"]
assert "web_library.db" in core
entry = core["web_library.db"]
assert entry["encoding"] == "base64"
# The snapshot must be a complete, openable DB reflecting current data —
# written to its own file (no WAL sidecar needed) and queryable.
snap = tmp_path / "snapshot.db"
snap.write_bytes(base64.b64decode(entry["data"]))
conn = sqlite3.connect(str(snap))
try:
rows = conn.execute(
"SELECT title FROM songs WHERE filename = ?", ("snap.archive",)
).fetchall()
finally:
conn.close()
assert rows == [("SnapSong",)]
def test_export_includes_custom_art_dirs(client, tmp_path):
(tmp_path / "playlist_covers").mkdir()
(tmp_path / "playlist_covers" / "3.png").write_bytes(b"\x89PNG-cover")
(tmp_path / "avatars").mkdir()
(tmp_path / "avatars" / "me.png").write_bytes(b"\x89PNG-avatar")
core = client.get("/api/settings/export").json()["core_server_files"]
assert core["playlist_covers/3.png"]["encoding"] == "base64"
assert base64.b64decode(core["playlist_covers/3.png"]["data"]) == b"\x89PNG-cover"
assert base64.b64decode(core["avatars/me.png"]["data"]) == b"\x89PNG-avatar"
# ── Import: DB is staged, never written over the live file ──────────────────
def test_import_stages_db_restore_without_touching_live_db(client, server_mod, tmp_path):
live = tmp_path / "web_library.db"
live_bytes_before = live.read_bytes()
payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored")
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(payload).decode()},
},
})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["restart_required"] is True
assert any("restart" in w.lower() for w in body["warnings"])
assert "web_library.db" in body["applied"]["core_files"]
# Live DB untouched; the restore is staged beside it for next startup.
assert live.read_bytes() == live_bytes_before
assert (tmp_path / "web_library.db.restore").read_bytes() == payload
def test_import_rejects_corrupt_db_with_valid_magic_header(client, server_mod, tmp_path):
# The dangerous case: SQLite magic header but a corrupt body. It must be
# refused at import — otherwise startup would delete the live DB and then
# fail to open the bad restore.
corrupt = b"SQLite format 3\x00" + b"\xff" * 200
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(corrupt).decode()},
},
})
assert r.status_code == 400
assert not (tmp_path / "web_library.db.restore").exists()
def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
# A truncated / wrong file staged as the restore would brick startup —
# reject anything lacking the SQLite magic header, before touching disk.
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(b"not a database").decode()},
},
})
assert r.status_code == 400
assert not (tmp_path / "web_library.db.restore").exists()
def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"playlist_covers/7.png": {"encoding": "base64",
"data": base64.b64encode(b"cover7").decode()},
},
})
assert r.status_code == 200
assert r.json()["restart_required"] is False
assert (tmp_path / "playlist_covers" / "7.png").read_bytes() == b"cover7"
def test_import_core_path_traversal_rejected(client, server_mod, tmp_path):
secret = tmp_path.parent / "escape.txt"
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"../escape.txt": {"encoding": "base64",
"data": base64.b64encode(b"pwned").decode()},
},
})
assert r.status_code == 400
assert not secret.exists()
def test_import_core_undeclared_path_skipped_not_fatal(client, server_mod, tmp_path):
# A relpath outside the core allowlist is a warn-and-skip, not a refusal —
# the rest of the bundle still applies.
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"audio_cache/x.ogg": {"encoding": "base64",
"data": base64.b64encode(b"nope").decode()},
},
})
assert r.status_code == 200
assert not (tmp_path / "audio_cache" / "x.ogg").exists()
assert any("undeclared" in w.lower() for w in r.json()["warnings"])
# ── Startup swap ────────────────────────────────────────────────────────────
def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path):
main = tmp_path / "web_library.db"
new_db = _valid_db_bytes(tmp_path, name="new.db", marker="new")
# Simulate a live DB with stale WAL sidecars + a (valid) staged restore.
main.write_bytes(b"OLD-DB")
(tmp_path / "web_library.db-wal").write_bytes(b"OLD-WAL")
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
(tmp_path / "web_library.db.restore").write_bytes(new_db)
server_mod._apply_pending_db_restore(tmp_path)
assert main.read_bytes() == new_db # swapped in
assert not (tmp_path / "web_library.db.restore").exists()
assert not (tmp_path / "web_library.db-wal").exists() # stale sidecars gone
assert not (tmp_path / "web_library.db-shm").exists()
def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_path):
# A corrupt staged restore must be thrown away WITHOUT destroying the
# live DB — never brick startup or lose data for a bad bundle.
main = tmp_path / "web_library.db"
main.write_bytes(b"LIVE-GOOD-DB")
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
server_mod._apply_pending_db_restore(tmp_path)
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
(tmp_path / "web_library.db").write_bytes(b"LIVE")
server_mod._apply_pending_db_restore(tmp_path) # nothing staged
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
# ── Full round-trip ─────────────────────────────────────────────────────────
def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
_seed_song(server_mod, filename="keepme.archive", title="KeepMe")
bundle = client.get("/api/settings/export").json()
# Lose the data (a song removed from the live DB after the backup).
server_mod.meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", ("keepme.archive",))
server_mod.meta_db.conn.commit()
assert server_mod.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs WHERE filename = ?", ("keepme.archive",)
).fetchone()[0] == 0
# Re-import the bundle → DB staged, not yet live.
r = client.post("/api/settings/import", json=bundle)
assert r.status_code == 200 and r.json()["restart_required"] is True
# Simulate a restart: close the live conn, apply the staged restore,
# reopen — the song is back.
server_mod.meta_db.conn.close()
server_mod._apply_pending_db_restore(tmp_path)
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
try:
rows = conn.execute(
"SELECT title FROM songs WHERE filename = ?", ("keepme.archive",)
).fetchall()
finally:
conn.close()
assert rows == [("KeepMe",)]
assert not (tmp_path / "web_library.db.restore").exists()
# ── Failure modes ───────────────────────────────────────────────────────────
def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch):
# A backup that silently omits the library DB is a data-loss trap — the
# export must error rather than hand back an incomplete-looking bundle.
monkeypatch.setattr(server_mod, "_snapshot_library_db", lambda: None)
r = client.get("/api/settings/export")
assert r.status_code == 500
assert "library database" in r.json()["error"].lower()
def test_failed_import_disarms_staged_db_restore(client, server_mod, tmp_path, monkeypatch):
# If a later write in phase 2 fails, the request 500s — but a staged DB
# restore must NOT survive to swap in on the next restart.
payload = _valid_db_bytes(tmp_path, name="incoming.db")
real_write = server_mod._atomic_write_file
def boom(target, data):
if target.name == "config.json": # last write of the commit
raise OSError("disk full")
return real_write(target, data)
monkeypatch.setattr(server_mod, "_atomic_write_file", boom)
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(payload).decode()},
},
})
assert r.status_code == 500
assert not (tmp_path / "web_library.db.restore").exists()
+111
View File
@@ -0,0 +1,111 @@
"""Tests for the wishlist / "wanted" list (got-feedback/feedBack#636 item 4).
A wishlist entry is a song the user does NOT own yet (the *arr Wanted/Monitored
analogue), so it lives in its own `wanted` table keyed by descriptive identity
rather than a local filename. Producers (the find_more ownership-diff, or a
manual add) POST entries; the API is idempotent on identity so a re-run of an
ownership-diff can't duplicate.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def test_add_list_remove_round_trip(client):
assert client.get("/api/wanted").json() == {"wanted": []}
r = client.post("/api/wanted", json={"artist": "Tool", "title": "Lateralus",
"source": "find_more", "source_ref": "cf:123"})
assert r.status_code == 200
row = r.json()["wanted"]
assert (row["artist"], row["title"], row["source"]) == ("Tool", "Lateralus", "find_more")
wid = row["id"]
listed = client.get("/api/wanted").json()["wanted"]
assert [w["title"] for w in listed] == ["Lateralus"]
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": True}
assert client.get("/api/wanted").json() == {"wanted": []}
# Deleting an already-gone id is a no-op, not an error.
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": False}
def test_add_is_idempotent_on_identity(client, server_mod):
payload = {"artist": "Rush", "title": "YYZ", "source": "find_more", "source_ref": "x1"}
first = client.post("/api/wanted", json=payload).json()["wanted"]
# Same identity (case-insensitive on artist/title) → no duplicate, same row.
again = client.post("/api/wanted", json={**payload, "artist": "rush", "title": "yyz"}).json()["wanted"]
assert first["id"] == again["id"]
assert server_mod.meta_db.count_wanted() == 1
# A different source_ref is a distinct entry.
client.post("/api/wanted", json={**payload, "source_ref": "x2"})
assert server_mod.meta_db.count_wanted() == 2
def test_newest_first_ordering(client, server_mod):
for t in ("First", "Second", "Third"):
server_mod.meta_db.add_wanted(artist="A", title=t, source="manual")
titles = [w["title"] for w in client.get("/api/wanted").json()["wanted"]]
assert titles == ["Third", "Second", "First"]
def test_add_requires_artist_or_title(client):
r = client.post("/api/wanted", json={"source": "manual"})
assert r.status_code == 400
r2 = client.post("/api/wanted", json={"artist": "", "title": " "})
assert r2.status_code == 400
def test_add_defaults_source_to_manual(client):
row = client.post("/api/wanted", json={"title": "Untitled"}).json()["wanted"]
assert row["source"] == "manual"
assert row["artist"] == ""
def test_non_dict_body_rejected(client):
# FastAPI's `data: dict` validation rejects a JSON array (422) before the
# handler's own defensive isinstance guard; either way it's not a 2xx.
assert client.post("/api/wanted", json=[]).status_code in (400, 422)
def test_table_creation_is_idempotent(server_mod):
# Re-running the CREATE TABLE / CREATE INDEX must not error or wipe rows —
# pin the additive + idempotent migration guarantee (constitution IV).
server_mod.meta_db.add_wanted(artist="Keep", title="Me")
server_mod.meta_db.conn.execute("""
CREATE TABLE IF NOT EXISTS wanted (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', source_ref TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '', created_at TEXT
)
""")
server_mod.meta_db.conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
"ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
)
assert server_mod.meta_db.count_wanted() == 1