Compare commits

...
Author SHA1 Message Date
ChrisBeWithYouandClaude Opus 4.8 fbc7ca2d9a 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>
2026-06-28 01:51:18 -05:00
ChrisBeWithYouandClaude Opus 4.8 0596f7970c perf(highway): stop the adaptive renderScale from visibly hunting up/down
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-27 22:51:22 -05:00
ChrisBeWithYouandClaude Opus 4.8 5111ef9440 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>
2026-06-27 22:03:01 -05:00
ChrisBeWithYouandClaude Opus 4.8 b04d7fa00d 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>
2026-06-27 17:15:54 -05:00
ChrisBeWithYouandClaude Opus 4.8 ccbb2c476d 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
2026-06-27 17:11:19 -05:00
ChrisBeWithYouandClaude Opus 4.8 e0e6213b1e 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
2026-06-27 17:11:19 -05:00
ChrisBeWithYouandClaude Opus 4.8 d92db7ad44 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
2026-06-27 17:07:02 -05:00
ChrisBeWithYouandClaude Opus 4.8 88e822dafb 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
2026-06-27 17:07:02 -05: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
35 changed files with 4848 additions and 20 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/
+3
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **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).
@@ -35,6 +36,7 @@ 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
- **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.
@@ -49,6 +51,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
@@ -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` |
+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.0",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+171 -5
View File
@@ -1768,7 +1768,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 +2115,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 +2125,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 +2259,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);
@@ -3552,6 +3559,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;
@@ -5912,10 +5932,23 @@
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 +6167,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 +7231,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 +7387,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 +7833,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 +12712,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 +12888,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 +13021,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
@@ -14012,6 +14156,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;
@@ -14337,7 +14483,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
+74
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>
+75 -2
View File
@@ -1082,7 +1082,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 +1760,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 +1786,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 +3392,8 @@ 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();
// 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).
@@ -5851,6 +5886,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.
@@ -6270,6 +6331,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);
@@ -8998,6 +9067,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);
+21 -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
+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) ═════════════════════ -->
+14
View File
@@ -505,6 +505,20 @@
</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>
</div>
</div>
+2
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;
+2 -2
View File
@@ -104,7 +104,7 @@
: '<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();
@@ -137,7 +137,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);
+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', '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.
+2
View File
@@ -50,6 +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: 'amp' },
// Not in the sidebar groups, but routable (profile badge → here).
{ key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' },
];
@@ -61,6 +62,7 @@
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
];
const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings'];
const SIDEBAR_GROUPS = ['HOME', 'LIBRARY'];
+57 -5
View File
@@ -216,6 +216,8 @@
const treeBtn = document.getElementById('v3-songs-tree-btn');
if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
const folderBtn = document.getElementById('v3-songs-folder-btn');
if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
updateFilterBadge();
}
@@ -593,8 +595,13 @@
async function batchAddToPlaylist() {
const lists = (await jget('/api/playlists')) || [];
const choices = lists.filter((p) => !p.system_key);
const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join('\n');
const ans = (window.prompt('Add ' + state.selected.size + ' song(s) to which playlist?\n' + labels + '\n\nEnter a number, or a new playlist name:', '') || '').trim();
const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join(' ');
const ans = ((await window.uiPrompt({
title: 'Add ' + state.selected.size + ' song(s) to a playlist',
label: (labels ? labels + '' : '') + 'Type a number above, or a new playlist name:',
okLabel: 'Add',
placeholder: 'Number or new playlist name',
})) || '').trim();
if (!ans) return;
let pid = null;
const num = parseInt(ans, 10);
@@ -843,6 +850,24 @@
function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }
// The host loads the Folder Library plugin's screen.js at startup (defining
// window.folderLibrary). If it isn't present yet, inject it once; the
// plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The
// promise is memoised so concurrent folder-view switches don't double-inject.
let _flLoadPromise = null;
function _ensureFolderLibrary() {
if (window.folderLibrary) return Promise.resolve();
if (_flLoadPromise) return _flLoadPromise;
_flLoadPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/api/plugins/folder_library/screen.js';
s.onload = () => resolve();
s.onerror = () => { _flLoadPromise = null; reject(new Error('Failed to load Folder Library')); };
document.head.appendChild(s);
});
return _flLoadPromise;
}
function reload() {
_clearLibraryScrollSnapshot();
// Record the state this fetch reflects so a later sidebar return can
@@ -853,9 +878,15 @@
// Keep a handle on the load so callers (notably the scroll restore on
// screen re-entry) can await page-0 actually landing before paging
// deeper. The visibility/scroll resets below stay synchronous.
const loaded = state.view === 'grid' ? loadGrid(true) : loadTree();
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
if (state.view === 'folder') {
_applyMainScrollTop(0);
return _ensureFolderLibrary().then(() => window.folderLibrary?.load());
}
const loaded = state.view === 'grid' ? loadGrid(true) : loadTree();
_applyMainScrollTop(0);
return loaded;
}
@@ -904,7 +935,7 @@
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button></div>' +
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><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>' +
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
@@ -913,6 +944,8 @@
'</div></div></div>' +
'<div id="v3-songs-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
'<div id="v3-songs-tree" class="hidden"></div>' +
'<div id="lib-folder-controls" style="display:none"></div>' +
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' +
// Filter drawer + overlay
'<div id="v3-songs-overlay" class="fixed inset-0 bg-black/50 z-40 hidden"></div>' +
@@ -979,14 +1012,17 @@
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
const setView = (v) => {
const setView = async (v) => {
state.view = v;
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
if (v === 'folder') await _ensureFolderLibrary();
return reload();
};
byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder'));
// Await the initial load so a caller awaiting render() (the scroll
// restore on screen re-entry) sees a populated grid + real state.total
// before it tries to page deeper.
@@ -1042,6 +1078,8 @@
if (state.renderedHash !== _libraryStateHash()) { reload(); return; }
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
return;
}
@@ -1093,6 +1131,20 @@
reload: reload,
search: search,
setQuery: (q) => { state.q = q || ''; },
getSort: () => state.sort,
getArtist: () => state.artist,
getAlbum: () => state.album,
filterParams: () => {
const f = state.filters;
const p = new URLSearchParams();
if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
if (f.lyrics) p.set('has_lyrics', f.lyrics);
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
return p.toString();
},
_scrollHelpers: {
SCROLL_STATE_KEY,
buildLibraryStateHash,
+7
View File
@@ -425,6 +425,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;
@@ -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 };
@@ -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');
});
@@ -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');
});
+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()