mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 07:44:31 +00:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5d5339afd | ||
|
|
a732e1f9d2 | ||
|
|
dfa825b4ab | ||
|
|
3120ae3e71 | ||
|
|
199550e5fb | ||
|
|
8fbbc761fc | ||
|
|
a791a0d8fe | ||
|
|
5ed6f454e7 | ||
|
|
331857ff2a | ||
|
|
8ca7ea4002 | ||
|
|
07ab902604 | ||
|
|
6a6efc793a | ||
|
|
6a71577e05 | ||
|
|
b29bab1884 | ||
|
|
8a2175aa1c | ||
|
|
5a0b62599d | ||
|
|
271fedda55 | ||
|
|
90fb2ee3bc | ||
|
|
3d97c07b2b | ||
|
|
b103a722ce | ||
|
|
d841813e0b | ||
|
|
a0f5435854 | ||
|
|
2a43d5b494 | ||
|
|
ee7bafbb47 | ||
|
|
fef870047b | ||
|
|
290783b80b | ||
|
|
b206633131 | ||
|
|
a57d0e3f85 | ||
|
|
4480ac2732 | ||
|
|
3b2d83d406 | ||
|
|
d1f7f12293 | ||
|
|
6dbcc5861b | ||
|
|
8f0625e1f7 | ||
|
|
c8e0ad3f75 | ||
|
|
13bbfc0b3d | ||
|
|
6fa5aabba8 | ||
|
|
3bb55ab854 | ||
|
|
97dae88860 | ||
|
|
b70fde9b02 | ||
|
|
4c3ec2ff66 | ||
|
|
8b4c9b0050 | ||
|
|
82db8e56b1 | ||
|
|
64801d5735 | ||
|
|
d2569cc2a8 | ||
|
|
287c23a532 | ||
|
|
05dd3d227a | ||
|
|
873ee3d5f2 | ||
|
|
d91995fec3 | ||
|
|
ac3c89493d | ||
|
|
3b485fe62b | ||
|
|
f3a5cb9ed3 | ||
|
|
db4a30085b | ||
|
|
b123ab3258 | ||
|
|
a43e7b13be | ||
|
|
7399a2ac63 | ||
|
|
7fb568c727 |
@@ -21,9 +21,15 @@ plugins/*/
|
||||
# treats them identically to user-installed ones) but are bundled with
|
||||
# the default container image and marked `"bundled": true` in their
|
||||
# manifest. Add new core plugins as `!plugins/<id>/` exceptions.
|
||||
!plugins/achievements/
|
||||
!plugins/achievements/**
|
||||
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/
|
||||
|
||||
+38
-1
@@ -8,6 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
|
||||
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
|
||||
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
|
||||
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
|
||||
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
|
||||
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
|
||||
- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
|
||||
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
|
||||
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
|
||||
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
|
||||
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
|
||||
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
|
||||
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
|
||||
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
|
||||
- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable).
|
||||
- **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `<config_dir>/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf).
|
||||
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
|
||||
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
|
||||
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
|
||||
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
|
||||
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
|
||||
@@ -29,6 +47,21 @@ 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
|
||||
- **`audio-input` `open-source` now reports the device the engine *actually* bound, so the wrong-mic case can be caught instead of trusting the pick blind.** A provider's `source.open` handler may return `payload: { boundType, boundName }`; `openInputSource()` (`static/capabilities/audio-session.js`) surfaces it on the command's **return** value as `payload.bound = { type, name }` for the trusted in-process caller, enabling an honest "Now listening to: <device>" readout and detection of a silent substitution (picked BlackHole, got the internal mic). The raw device name is PII, so it is deliberately kept **out** of the emitted `source-opened` event and the diagnostics snapshot (which stay redacted) — mirroring how `list-sources` already returns the device `label` verbatim to the UI but pseudonymizes it in diagnostics. Purely additive: the open-session summary shape is unchanged and `bound` is omitted when the provider reports nothing. Pairs with feedBack-desktop's stable name-based input identity + fail-loud open. Tests: `tests/js/audio_session_input.test.js` (read-back surfaced to the caller, absent from event + snapshot).
|
||||
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
|
||||
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
|
||||
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
|
||||
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
|
||||
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) − S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
|
||||
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
|
||||
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
|
||||
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
|
||||
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
|
||||
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards key on the **decoded** `localFilename` (`data-fn`), and `/api/stats/best` is server-canonicalized to that same decoded key (`server.py` `_canonical_song_filename`). So `repaintAccuracy`'s `data-fn !== key` check rejected every card and `state.accuracy[encoded]` was `undefined`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
|
||||
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
|
||||
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** The v0.3.0 player chrome's persistent upcoming-section pill (`#v3-upnext`, drawn by `static/v3/player-chrome.js`'s `updateUpNext()`) shipped with no off switch, so it always showed during playback whenever a section was upcoming — overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up Next' section card" checkbox (a *different*, in-canvas widget that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
|
||||
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
|
||||
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked` → `_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
|
||||
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
|
||||
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
|
||||
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (feedBack#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `FEEDBACK_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
|
||||
@@ -36,11 +69,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Screensaver no longer kicks in during windowed-mode playback** (#686). While a song is playing, `static/app.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In feedBack-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.feedBackDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
|
||||
|
||||
### Changed
|
||||
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedback-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale` → `virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
|
||||
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedBack-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale` → `virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
|
||||
- **3D highway: realistic curved metal frets.** The fret wires are now bowed `TubeGeometry` (the middle strings push away from the camera so the row of frets reads as wrapping a cylindrical neck — a depth cue) rendered with a lit `MeshStandardMaterial` instead of the old flat, straight `MeshBasicMaterial` boxes, so the scene's ambient + directional light glints across the rounded surface for a polished-steel look. The existing per-frame highlight is preserved unchanged: frets inside the active anchor still turn gold (`0xD8A636`), which under the metallic shading reads as brass. Metalness is kept moderate (0.4, not full-metal) because the scene has no envMap — a PBR full-metal surface would reflect black — with a dim emissive floor so frets stay legible down the fogged neck. Backported from the `highway_babylon` plugin's "hit-zone fret bars". All knobs (`FRET_BOW_DZ`, metalness/roughness/emissive) are tunable constants. `plugins/highway_3d` v3.25.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 7–12 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.
|
||||
|
||||
### Added
|
||||
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled FeedBack Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime − spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
|
||||
|
||||
@@ -530,7 +530,7 @@ cover.jpg Album art (optional)
|
||||
lyrics.json Syllable-level lyrics (optional)
|
||||
```
|
||||
|
||||
Sloppak is the preferred format for new features. The [Stems plugin](https://github.com/topkoa/feedBack-plugin-stems) provides live stem mixing for sloppak songs.
|
||||
Sloppak is the preferred format for new features. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) provides live stem mixing for sloppak songs.
|
||||
|
||||
**Full developer reference:** the authoritative format spec now lives in its own repo —
|
||||
[got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)
|
||||
@@ -552,6 +552,7 @@ a local pointer + code map.
|
||||
- **Storage** — `localStorage` for all user preferences
|
||||
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
||||
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
|
||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
||||
|
||||
@@ -22,5 +22,5 @@ too sharp / too flat / not played).
|
||||
- **[Implementation Plan](docs/NOTE_FAILURE_PLAN.md)** — 7 phases from
|
||||
detection foundation through section grading and polish
|
||||
- **Note Detection Plugin Plan** — see the
|
||||
[feedBack-plugin-notedetect](https://github.com/topkoa/feedBack-plugin-notedetect)
|
||||
[slopsmith-plugin-notedetect](https://github.com/topkoa/slopsmith-plugin-notedetect)
|
||||
repository (Phase 0 foundation)
|
||||
|
||||
@@ -177,7 +177,7 @@ entry explaining why.
|
||||
"capability_validation_warnings": [],
|
||||
"capability_unsupported_versions": [],
|
||||
"compatibility_shims": [],
|
||||
"git": { "sha": "abc123d", "remote": "https://github.com/topkoa/feedBack-plugin-stems.git" }
|
||||
"git": { "sha": "abc123d", "remote": "https://github.com/topkoa/slopsmith-plugin-stems.git" }
|
||||
}
|
||||
],
|
||||
"orphans": [
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,246 @@
|
||||
# Host Theme Contract — design proposal
|
||||
|
||||
**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI
|
||||
**Trigger:** a plugin UI feature accidentally "carved itself into a single theme."
|
||||
|
||||
## 1. Problem
|
||||
|
||||
A results-card feature in the `note_detect` plugin (a glow-ring hero button + a
|
||||
gradient-filled accuracy number) was built and visually verified against **only the
|
||||
default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately
|
||||
glow-less, near-monochrome design language — the glow ring and the colour gradient
|
||||
simply **vanished**. The colours adapted (everything used CSS custom-property tokens),
|
||||
but the **visual devices themselves did not port**, because nothing in the system says
|
||||
"this theme does / doesn't do glow rings."
|
||||
|
||||
### Root cause (three findings)
|
||||
|
||||
1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients;
|
||||
esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels +
|
||||
drop-shadows. Tokens made *colour* portable; they never made a *device* portable.
|
||||
2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon
|
||||
but a **hard drop-shadow** in metal — the metal skin is already repurposing a
|
||||
device-named slot to express a different language. The cure is to finish that move:
|
||||
name slots by intent, with "off" (`none`) a legal value.
|
||||
3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places;
|
||||
on esports' amber accent that's a contrast failure. And `--nd-accent2` was
|
||||
**double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved
|
||||
amber→near-white and washed out.
|
||||
|
||||
A process gap compounds it: **verification covered one skin**, so the regression was
|
||||
invisible until a user switched themes. And this recurs ecosystem-wide — other plugins
|
||||
ship their own independent skin systems too.
|
||||
|
||||
## 2. Current state (two disconnected systems)
|
||||
|
||||
| System | What it is | Limits |
|
||||
| --- | --- | --- |
|
||||
| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults* → **devices are an ownerless gap.** |
|
||||
| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
|
||||
|
||||
## 3. Goals / non-goals
|
||||
|
||||
- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not
|
||||
yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally.
|
||||
- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing.
|
||||
- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but
|
||||
**implement** the contract.
|
||||
- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
|
||||
|
||||
## 4. The contract — three layers
|
||||
|
||||
### Layer 1 — Semantic colour **roles** (always present)
|
||||
|
||||
The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under
|
||||
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
|
||||
resolves — themed or not. Roles:
|
||||
|
||||
**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on
|
||||
`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**.
|
||||
The existing `--fbv-*` vars stay **internal plumbing** — `theme-core.js` uses them only to
|
||||
recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`;
|
||||
they are **not** part of this contract and plugins must not read them. (Implementation may seed
|
||||
`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.)
|
||||
|
||||
**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching
|
||||
today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional
|
||||
alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their
|
||||
device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except**
|
||||
where noted.
|
||||
|
||||
**Normative role tokens** (all `--fb-*`, all always present):
|
||||
|
||||
| Role | Token | Notes |
|
||||
| --- | --- | --- |
|
||||
| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural |
|
||||
| text / dim | `--fb-text` `--fb-text-dim` | |
|
||||
| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end |
|
||||
| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both |
|
||||
| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). |
|
||||
| **focus (new)** | `--fb-focus-ring` | focus indicator independent of `accent`, so focus stays visible when `accent ≈ surface` |
|
||||
|
||||
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
|
||||
|
||||
A theme declares its design *language* by filling intent-named slots (all `--fb-*`-prefixed,
|
||||
same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never
|
||||
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
|
||||
`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`,
|
||||
`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` is legal.
|
||||
|
||||
Two **composite recipes** carry the load:
|
||||
|
||||
- **EMPHASIS** — how this theme makes a primary action special:
|
||||
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
|
||||
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
|
||||
Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of
|
||||
fill/border/halo non-`none`), so a primary action is never visually flat.
|
||||
- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill`
|
||||
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
|
||||
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
|
||||
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
|
||||
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
|
||||
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
|
||||
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
|
||||
`background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible
|
||||
even where clip-text is unsupported.
|
||||
|
||||
> These generalize the interim per-skin tokens already shipped in `note_detect`
|
||||
> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`).
|
||||
|
||||
### Layer 3 — JS read API + reconciliation
|
||||
|
||||
**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
|
||||
consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved
|
||||
token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to
|
||||
draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of
|
||||
booleans. On the existing `window.feedBack` bus:
|
||||
|
||||
- `feedBack.theme.get()` → `{ id, isThemed, tokens }` where `tokens` is the **resolved** map of
|
||||
every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device,
|
||||
e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean).
|
||||
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the
|
||||
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
|
||||
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
|
||||
CSS-authored decorative motion.
|
||||
- `theme:changed` event → `{ id, tokens }`.
|
||||
|
||||
**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously**
|
||||
and is valid at any time — before any theme is applied it returns the default/unthemed roles
|
||||
(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh);
|
||||
`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial
|
||||
hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()`
|
||||
on mount, then subscribe to `theme:changed` — never assume an order between your mount and the
|
||||
first theme apply.
|
||||
|
||||
**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin
|
||||
**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and
|
||||
**owns only its accent + its devices**, selecting the device via the recipe. A host theme then
|
||||
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
|
||||
never imposes a device the active theme neutralizes.
|
||||
|
||||
**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*`
|
||||
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
|
||||
renders into a **shadow root or iframe** is responsible for bridging — copy the resolved
|
||||
`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't
|
||||
cross those boundaries).
|
||||
|
||||
## 5. Consumption pattern (the rule for feature authors)
|
||||
|
||||
> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw
|
||||
> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live
|
||||
> in slots; the theme owns the slots.
|
||||
|
||||
```css
|
||||
.hero-cta {
|
||||
background: var(--fb-emph-fill);
|
||||
border: var(--fb-emph-border);
|
||||
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
|
||||
color: var(--fb-emph-on); /* never hardcoded #fff again */
|
||||
border-radius: var(--fb-corner-radius);
|
||||
}
|
||||
.accuracy-number {
|
||||
/* Always-legible solid base; survives no-clip-text support too. */
|
||||
color: rgb(var(--fb-accent));
|
||||
}
|
||||
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
|
||||
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
|
||||
invisible. */
|
||||
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
|
||||
.accuracy-number {
|
||||
background: var(--fb-acc-text-fill);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g.
|
||||
`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the
|
||||
always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a
|
||||
Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the
|
||||
`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps
|
||||
runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`).
|
||||
|
||||
- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is
|
||||
named; one central rule in the contract sheet sets it to `none` under
|
||||
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
|
||||
uses `feedBack.theme.prefersReducedMotion()` — §4.3.)
|
||||
- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }`
|
||||
for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus
|
||||
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
|
||||
`v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked
|
||||
cleanup — "one rule" describes the end state, not day one.
|
||||
- **On-fill contrast:** every `--fb-on-*` is required and **lintable**
|
||||
(`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`).
|
||||
Contrast is the theme's job, computed once — not re-judged per feature.
|
||||
|
||||
## 7. Verification gate (prevent recurrence)
|
||||
|
||||
- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key
|
||||
surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin ×
|
||||
key states** (rest / hover / focus / reduced-motion).
|
||||
- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis
|
||||
present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring +
|
||||
fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
|
||||
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime +
|
||||
a guard test so the matrix can't silently go stale.
|
||||
|
||||
**Definition-of-done for any theme-touching UI change** (the few items that would have caught this):
|
||||
expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device*
|
||||
stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast.
|
||||
|
||||
## 8. Back-compat & rollout
|
||||
|
||||
All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new
|
||||
`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those
|
||||
reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they
|
||||
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
|
||||
two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and
|
||||
`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today.
|
||||
|
||||
**Workstream (sub-tasks):**
|
||||
1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
|
||||
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
|
||||
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
|
||||
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
|
||||
|
||||
## 9. Cross-apply status (already done)
|
||||
|
||||
- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens
|
||||
(the Layer-2 prototype) and verified across neon/esports/metal.
|
||||
- The **canvas share-image card** — re-checked across all three skins: **theme-robust**
|
||||
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
|
||||
fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*.
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
- Should plugin skins eventually become *selectable host themes* (one picker), or stay
|
||||
plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
|
||||
- Component-recipe **bundles** (per named component) are the richer end-state; intent-named
|
||||
slots are the right seed. When/whether to graduate.
|
||||
|
||||
*(Resolved during review and folded into the sections above: the token namespace + value grammar
|
||||
and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2);
|
||||
JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe
|
||||
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
|
||||
host-owned contract stylesheet outside Tailwind (§6).)*
|
||||
@@ -73,7 +73,7 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
|
||||
2. Open `manifest.yaml` in any text editor (Notepad++, VS Code, BBEdit, gedit — all fine; just **don't use Word**).
|
||||
3. Find the `stems:` block. Two things matter here:
|
||||
- **Order:** FeedBack's base `<audio>` element always plays the **first** stem listed in `stems[]`, regardless of `default:` flags. So if you want your custom stem to be what the player plays out-of-the-box (and what users without the Stems plugin will hear), put it **first**.
|
||||
- **`default:` flags:** consulted by the [Stems plugin](https://github.com/topkoa/feedBack-plugin-stems) to decide which faders start un-muted. They do **not** affect what the base `<audio>` element plays — that's purely the first-stem rule above.
|
||||
- **`default:` flags:** consulted by the [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) to decide which faders start un-muted. They do **not** affect what the base `<audio>` element plays — that's purely the first-stem rule above.
|
||||
|
||||
Example for a Demucs-split sloppak where you re-recorded the rhythm guitar:
|
||||
|
||||
@@ -110,7 +110,7 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
|
||||
|
||||
### Step 5 — Reload and verify
|
||||
|
||||
Reload the song in FeedBack. The [Stems plugin](https://github.com/topkoa/feedBack-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
|
||||
Reload the song in FeedBack. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
|
||||
|
||||
### Common gotchas
|
||||
|
||||
|
||||
@@ -1124,7 +1124,17 @@ def _build_xml(
|
||||
# for compatibility, and emit additional string6+ attributes (up to
|
||||
# `len(tuning)-1`) for 7+ string arrangements. FeedBack parses
|
||||
# them; the format ignores them.
|
||||
#
|
||||
# `stringCount` records the AUTHORITATIVE string count (== len(tuning)),
|
||||
# because the 6-slot padding above erases the 4-vs-5-vs-6-string
|
||||
# distinction for standard tunings (a 4-string bass, 5-string bass and
|
||||
# 6-string guitar are otherwise byte-identical, all string0..5 = 0).
|
||||
# parse_arrangement trims `tuning` back to this on read so downstream
|
||||
# string-count derivation (song.arrangement_string_count, the editor's
|
||||
# _stringCountFor) sees the real width instead of guessing. RS2014 and
|
||||
# any other consumer simply ignore the unknown attribute.
|
||||
tuning_el = ET.SubElement(root, "tuning")
|
||||
tuning_el.set("stringCount", str(len(tuning)))
|
||||
for i in range(max(6, len(tuning))):
|
||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||
ET.SubElement(root, "capo").text = "0"
|
||||
|
||||
+133
-30
@@ -239,12 +239,20 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
||||
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||||
_beats_by_id = {b.get('id'): b for b in (root.find('Beats') or [])}
|
||||
_notes_by_id = {n.get('id'): n for n in (root.find('Notes') or [])}
|
||||
|
||||
def _note_count_for_raw(raw_idx: int) -> int:
|
||||
# Total note count for the track (sum of notes across all its beats).
|
||||
# This is the single source of truth: list_tracks surfaces it as the
|
||||
# 'notes' field, and _auto_select_gpx uses (count == 0) to skip empty
|
||||
# tracks — so the graph is walked once here, not again in list_tracks.
|
||||
# Count of notes that ACTUALLY become RS notes for the track. This is
|
||||
# the single source of truth: list_tracks surfaces it as the 'notes'
|
||||
# field (the importer's per-track preview count) and _auto_select_gpx
|
||||
# uses (count == 0) to skip empty tracks — so the graph is walked once
|
||||
# here, not again in list_tracks.
|
||||
#
|
||||
# Tie-DESTINATION notes are excluded: a tied note is folded into the
|
||||
# previous note as extended sustain (see the `_note_is_tie` skips in
|
||||
# convert_file), so it never becomes a separate RS note. Counting them
|
||||
# made the preview overstate the result (e.g. 260 shown, 241 imported);
|
||||
# excluding them makes the preview match what the user actually gets.
|
||||
n = 0
|
||||
for mb in _masterbars:
|
||||
bar_ids = mb.findtext('Bars', '').split()
|
||||
@@ -263,9 +271,11 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
beat = _beats_by_id.get(bid)
|
||||
if beat is None:
|
||||
continue
|
||||
notes_text = beat.findtext('Notes', '').strip()
|
||||
if notes_text:
|
||||
n += len(notes_text.split())
|
||||
for nid in beat.findtext('Notes', '').split():
|
||||
note_el = _notes_by_id.get(nid)
|
||||
if note_el is not None and _note_is_tie(note_el):
|
||||
continue
|
||||
n += 1
|
||||
return n
|
||||
|
||||
result = []
|
||||
@@ -1372,9 +1382,69 @@ def convert_file(
|
||||
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
||||
|
||||
output_files = []
|
||||
# Counts of auto-named guitar/bass arrangements so far, so multiple guitars
|
||||
# get distinct RS roles (Lead, Rhythm, Combo, …) instead of all "Lead".
|
||||
_role_counts: dict[str, int] = {}
|
||||
# All auto-assigned arrangement names handed out so far, so multiple
|
||||
# arrangements get distinct labels (Lead, Rhythm, Combo, Bass, Bass 2, …)
|
||||
# and the name-aware and positional guitar paths never collide.
|
||||
_used_arr_names: set[str] = set()
|
||||
|
||||
def _unique_arr_name(base: str) -> str:
|
||||
"""Return `base`, or `base 2`/`base 3`/… if it's already been used."""
|
||||
if base not in _used_arr_names:
|
||||
_used_arr_names.add(base)
|
||||
return base
|
||||
k = 2
|
||||
while f"{base} {k}" in _used_arr_names:
|
||||
k += 1
|
||||
name = f"{base} {k}"
|
||||
_used_arr_names.add(name)
|
||||
return name
|
||||
|
||||
_KEYS_PROGS = set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||||
|
||||
def _auto_guitar_hint(track_idx: int):
|
||||
"""For a track that auto-resolves to a guitar arrangement, return its
|
||||
role hint: 'lead', 'rhythm', or '' (unhinted). None when the track is
|
||||
NOT an auto-named guitar (explicitly named, bass, drum, vocal, keys).
|
||||
Mirrors the per-track classification in the conversion loop below."""
|
||||
if track_idx >= len(tracks) or names.get(track_idx):
|
||||
return None
|
||||
t = tracks[track_idx]
|
||||
if t['is_drums'] or _is_vocal_track(t):
|
||||
return None
|
||||
low = t['name'].lower()
|
||||
sp = t['string_pitches']
|
||||
prog = t['midi_program']
|
||||
if (isinstance(prog, int) and 32 <= prog <= 39) or (bool(sp) and max(sp) <= 48) or 'bass' in low:
|
||||
return None # bass
|
||||
if (not sp and prog in _KEYS_PROGS) or any(kw in low for kw in ('piano', 'keys', 'keyboard', 'organ')):
|
||||
return None # keys
|
||||
if 'lead' in low and 'rhythm' not in low:
|
||||
return 'lead'
|
||||
if 'rhythm' in low and 'lead' not in low:
|
||||
return 'rhythm'
|
||||
return '' # guitar, no role hint
|
||||
|
||||
# Two-pass guitar role naming, resolved up front so the per-track loop just
|
||||
# looks names up. Reserve every name-hinted Lead/Rhythm first, THEN fill
|
||||
# unhinted guitars into the remaining canonical roles. A single pass would
|
||||
# let an unhinted guitar that appears BEFORE a hinted one steal its role,
|
||||
# pushing the real Lead/Rhythm to a non-canonical "Rhythm 2" that the
|
||||
# downstream name-based path classification doesn't recognise.
|
||||
_guitar_name_by_idx: dict[int, str] = {}
|
||||
_unhinted_guitars: list[int] = []
|
||||
for _ti in track_indices:
|
||||
hint = _auto_guitar_hint(_ti)
|
||||
if hint is None:
|
||||
continue
|
||||
if hint == 'lead':
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name('Lead')
|
||||
elif hint == 'rhythm':
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name('Rhythm')
|
||||
else:
|
||||
_unhinted_guitars.append(_ti)
|
||||
for _ti in _unhinted_guitars:
|
||||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in _used_arr_names), 'Combo')
|
||||
_guitar_name_by_idx[_ti] = _unique_arr_name(base)
|
||||
|
||||
for track_idx in track_indices:
|
||||
if track_idx >= len(tracks):
|
||||
@@ -1422,16 +1492,12 @@ def convert_file(
|
||||
)
|
||||
low = track['name'].lower()
|
||||
if is_bass or 'bass' in low:
|
||||
_bc = _role_counts.get('bass', 0)
|
||||
_role_counts['bass'] = _bc + 1
|
||||
arr_name = 'Bass' if _bc == 0 else f'Bass {_bc + 1}'
|
||||
arr_name = _unique_arr_name('Bass')
|
||||
else:
|
||||
# Distinct guitar roles by appearance order so two guitars
|
||||
# don't both become "Lead": Lead, Rhythm, Combo, then Combo N.
|
||||
_gc = _role_counts.get('guitar', 0)
|
||||
_role_counts['guitar'] = _gc + 1
|
||||
_roles = ('Lead', 'Rhythm', 'Combo')
|
||||
arr_name = _roles[_gc] if _gc < len(_roles) else f'Combo {_gc - 1}'
|
||||
# Guitar role was resolved up front (two-pass, honoring
|
||||
# "lead"/"rhythm" in the GP track name so a Rhythm-before-Lead
|
||||
# file isn't swapped by positional assignment).
|
||||
arr_name = _guitar_name_by_idx.get(track_idx) or _unique_arr_name('Lead')
|
||||
|
||||
# Vocal tracks get their own converter — outputs vocals XML, not notes XML
|
||||
if is_vocal:
|
||||
@@ -2317,7 +2383,15 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
||||
if is_bass:
|
||||
selected.append((i, 'bass'))
|
||||
elif is_guitar:
|
||||
selected.append((i, 'guitar'))
|
||||
# Honor "lead"/"rhythm" in the GP track name so two guitars keep
|
||||
# the author's roles instead of being labelled by appearance order
|
||||
# (which swaps a Rhythm-before-Lead file). Unhinted → positional.
|
||||
if 'lead' in name_l and 'rhythm' not in name_l:
|
||||
selected.append((i, 'guitar_lead'))
|
||||
elif 'rhythm' in name_l and 'lead' not in name_l:
|
||||
selected.append((i, 'guitar_rhythm'))
|
||||
else:
|
||||
selected.append((i, 'guitar'))
|
||||
elif is_keys:
|
||||
selected.append((i, 'keys'))
|
||||
|
||||
@@ -2326,19 +2400,48 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
||||
if not t['is_drums'] and t.get('note_count', 1) > 0:
|
||||
selected.append((i, 'guitar'))
|
||||
|
||||
indices = []
|
||||
name_map = {}
|
||||
counts: dict[str, int] = {}
|
||||
RS_NAMES = {'guitar': ('Lead', 'Rhythm', 'Combo'), 'bass': ('Bass',), 'keys': ('Keys',), 'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||||
RS_NAMES = {'bass': ('Bass',), 'keys': ('Keys',),
|
||||
'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||||
used: set[str] = set()
|
||||
|
||||
def _unique(base: str) -> str:
|
||||
if base not in used:
|
||||
used.add(base)
|
||||
return base
|
||||
k = 2
|
||||
while f"{base} {k}" in used:
|
||||
k += 1
|
||||
used.add(f"{base} {k}")
|
||||
return f"{base} {k}"
|
||||
|
||||
# Two passes so name-hinted Lead/Rhythm guitars reserve their canonical role
|
||||
# BEFORE unhinted guitars are filled in — otherwise an unhinted guitar that
|
||||
# appears before a hinted one steals its role (real Rhythm → "Rhythm 2").
|
||||
# Non-guitar roles are handled in pass 1. `name_map` keys by track index so
|
||||
# this does not affect arrangement (selection) order, computed separately.
|
||||
for idx, role in selected:
|
||||
if role == 'guitar':
|
||||
continue
|
||||
if role == 'guitar_lead':
|
||||
base = 'Lead'
|
||||
elif role == 'guitar_rhythm':
|
||||
base = 'Rhythm'
|
||||
else:
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
c = counts[role]
|
||||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||||
base = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||||
if c > len(names_for_role):
|
||||
base = f"{names_for_role[-1]} {c}"
|
||||
name_map[idx] = _unique(base)
|
||||
|
||||
for idx, role in selected:
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
c = counts[role]
|
||||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||||
arr_name = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||||
if c > len(names_for_role):
|
||||
arr_name = f"{names_for_role[-1]} {c}"
|
||||
indices.append(idx)
|
||||
name_map[idx] = arr_name
|
||||
if role != 'guitar':
|
||||
continue
|
||||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in used), 'Combo')
|
||||
name_map[idx] = _unique(base)
|
||||
|
||||
indices = [idx for idx, _role in selected]
|
||||
return indices, name_map
|
||||
|
||||
@@ -367,6 +367,14 @@ class LoadedSloppak:
|
||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||
# absent so indexing by song.arrangements index is safe.
|
||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||
# Manifest-relative path to the single full-mix audio file, taken from the
|
||||
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
|
||||
# pre-separation mixdown that exists alongside the per-instrument `stems`.
|
||||
# None when the key is absent, points outside source_dir, or the file is
|
||||
# missing on disk. Served to the front-end via the highway WS as
|
||||
# `original_audio_url`; the stems plugin uses it to play the untouched mix
|
||||
# when every stem slider is at unity (and the separate stems otherwise).
|
||||
original_audio: str | None = None
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -808,6 +816,29 @@ def load_song(
|
||||
}
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# Optional full-mix audio — manifest `original_audio:` key. The single
|
||||
# pre-separation mixdown that ships alongside the per-instrument stems.
|
||||
# Same permissive, path-traversal-guarded posture as drum_tab above: a
|
||||
# missing/escaping/absent file simply leaves the full mix unavailable (the
|
||||
# player falls back to the separate stems) rather than aborting the load.
|
||||
# We store the manifest-relative string so server.py can build its URL the
|
||||
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
|
||||
original_audio_data: str | None = None
|
||||
original_audio_rel = manifest.get("original_audio")
|
||||
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
|
||||
rel = original_audio_rel.strip()
|
||||
try:
|
||||
oa_path = (source_dir / rel).resolve()
|
||||
oa_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
oa_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
oa_path = None
|
||||
if oa_path is not None and oa_path.is_file():
|
||||
original_audio_data = rel
|
||||
|
||||
return LoadedSloppak(
|
||||
song=song,
|
||||
stems=stems,
|
||||
@@ -821,6 +852,7 @@ def load_song(
|
||||
keys=keys_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
original_audio=original_audio_data,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+14
@@ -1090,6 +1090,20 @@ def parse_arrangement(xml_path: str) -> Arrangement:
|
||||
while el.get(f"string{i}") is not None:
|
||||
tuning.append(_int(el, f"string{i}"))
|
||||
i += 1
|
||||
# Authoritative string count, written by the GP/RS serializer
|
||||
# (gp2rs._build_xml). The schema pads `<tuning>` to 6 slots, which
|
||||
# erases the 4-vs-5-vs-6-string distinction for standard tunings;
|
||||
# when the real count was recorded, trim the padded tail so
|
||||
# arrangement_string_count / the editor see 4 or 5 instead of 6.
|
||||
# Absent (archive / legacy sources) → leave the 6-slot tuning as-is.
|
||||
sc = el.get("stringCount")
|
||||
if sc is not None:
|
||||
try:
|
||||
n = int(sc)
|
||||
except (TypeError, ValueError):
|
||||
n = 0
|
||||
if 1 <= n <= len(tuning):
|
||||
tuning = tuning[:n]
|
||||
|
||||
# Capo
|
||||
capo = 0
|
||||
|
||||
@@ -1330,6 +1330,18 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
_category = manifest.get("category")
|
||||
if not isinstance(_category, str) or not _category:
|
||||
_category = None
|
||||
# Settings-tab placement (tabbed settings page). When `settings` is a
|
||||
# dict, an optional `category` field names which settings tab the
|
||||
# plugin's panel mounts under (e.g. "graphics", "mic", "progression").
|
||||
# Distinct from the top-level `category` above (which drives Pedalboard
|
||||
# grouping) so the two don't collide. Absent/blank → None → the
|
||||
# frontend falls back to the generic "Plugins" tab.
|
||||
_settings_manifest = manifest.get("settings")
|
||||
_settings_category = None
|
||||
if isinstance(_settings_manifest, dict):
|
||||
_sc = _settings_manifest.get("category")
|
||||
if isinstance(_sc, str) and _sc:
|
||||
_settings_category = _sc
|
||||
_icon = manifest.get("icon")
|
||||
if not isinstance(_icon, str) or not _icon:
|
||||
_icon = None
|
||||
@@ -1338,6 +1350,13 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
_icon = "assets/thumb.png"
|
||||
except OSError:
|
||||
_icon = None
|
||||
# Immersive (full-screen) screen opt-in. A plugin that declares a
|
||||
# top-level `"fullscreen": true` gets the whole content area when its
|
||||
# screen is active: the v3 shell hides the topbar and collapses the
|
||||
# sidebar to an icon rail (see static/v3/shell.js + v3.css). For
|
||||
# DAW-style plugin UIs that need the viewport, not a scrolling content
|
||||
# page. Strict `is True` so a stray truthy value can't silently opt in.
|
||||
_fullscreen = manifest.get("fullscreen") is True
|
||||
return {
|
||||
"id": plugin_id,
|
||||
"name": manifest.get("name", plugin_id),
|
||||
@@ -1355,6 +1374,10 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
"has_screen": bool(manifest.get("screen")),
|
||||
"has_script": bool(manifest.get("script")),
|
||||
"has_settings": bool(manifest.get("settings")),
|
||||
"settings_category": _settings_category,
|
||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||
# plugin's screen. False unless the manifest declares it explicitly.
|
||||
"fullscreen": _fullscreen,
|
||||
"has_tour": _is_valid_tour_manifest(manifest.get("tour")),
|
||||
# `styles` is an optional relpath (under the plugin's assets/) to a
|
||||
# compiled, preflight-off stylesheet the frontend injects as a
|
||||
@@ -2067,6 +2090,11 @@ def register_plugin_api(app: FastAPI):
|
||||
"has_screen": p["has_screen"],
|
||||
"has_script": p["has_script"],
|
||||
"has_settings": p["has_settings"],
|
||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||
"fullscreen": p.get("fullscreen", False),
|
||||
# Settings-tab placement; None when the manifest's `settings`
|
||||
# is absent, a bare string, or omits `category`.
|
||||
"settings_category": p.get("settings_category"),
|
||||
"has_tour": p.get("has_tour", False),
|
||||
# `.get()` fallbacks keep stubbed test entries (built without
|
||||
# _nav_entry) working — styles is None when unset.
|
||||
@@ -2115,6 +2143,8 @@ def register_plugin_api(app: FastAPI):
|
||||
"has_screen": e.get("has_screen", False),
|
||||
"has_script": e.get("has_script", False),
|
||||
"has_settings": e.get("has_settings", False),
|
||||
"settings_category": e.get("settings_category"),
|
||||
"fullscreen": e.get("fullscreen", False),
|
||||
"has_tour": e.get("has_tour", False),
|
||||
"has_styles": e.get("has_styles", False),
|
||||
"styles": e.get("styles"),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"version": 1,
|
||||
"global": [
|
||||
{
|
||||
"id": "first_steps",
|
||||
"title": "First Steps",
|
||||
"description": "Reach Mastery Rank 1 — finish onboarding.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"criterion": { "type": "mastery_rank", "tiers": [1] },
|
||||
"tiers": [1]
|
||||
},
|
||||
{
|
||||
"id": "ascendant",
|
||||
"title": "Ascendant",
|
||||
"description": "Climb the Mastery Rank ladder.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"criterion": { "type": "mastery_rank", "tiers": [10, 25, 50] },
|
||||
"tiers": [10, 25, 50],
|
||||
"tier_titles": ["Ascendant I", "Ascendant II", "Ascendant III"]
|
||||
},
|
||||
{
|
||||
"id": "steady_hands",
|
||||
"title": "Steady Hands",
|
||||
"description": "Make a real advance on many separate days.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"criterion": { "type": "growth_streak_days", "tiers": [7, 30, 100] },
|
||||
"tiers": [7, 30, 100],
|
||||
"tier_titles": ["Steady Hands (7)", "Steady Hands (30)", "Steady Hands (100)"]
|
||||
},
|
||||
{
|
||||
"id": "renaissance",
|
||||
"title": "Renaissance",
|
||||
"description": "Reach Level 10 across several distinct instrument paths.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"criterion": { "type": "paths_at_level", "level": 10, "tiers": [2, 3, 5] },
|
||||
"tiers": [2, 3, 5],
|
||||
"tier_titles": ["Renaissance (2)", "Renaissance (3)", "Renaissance (5)"]
|
||||
}
|
||||
],
|
||||
"per_instrument": [
|
||||
{
|
||||
"id": "path_rank",
|
||||
"title": "{Inst} Mastery",
|
||||
"description": "Climb the {Inst} path: Apprentice, Journeyman, Master.",
|
||||
"sourceId": "achievements",
|
||||
"criterion": { "type": "path_level", "tiers": [10, 25, "max"] },
|
||||
"tier_titles": ["{Inst} Apprentice", "{Inst} Journeyman", "{Inst} Master"]
|
||||
},
|
||||
{
|
||||
"id": "personal_best",
|
||||
"title": "Personal Best ({Inst})",
|
||||
"description": "Beat your own best accuracy on any {Inst} chart.",
|
||||
"sourceId": "achievements",
|
||||
"criterion": { "type": "personal_best" }
|
||||
},
|
||||
{
|
||||
"id": "challenger",
|
||||
"title": "Challenger ({Inst})",
|
||||
"description": "Clear a full level-up challenge set on the {Inst} path.",
|
||||
"sourceId": "achievements",
|
||||
"criterion": { "type": "challenge_set_cleared" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* Achievements & Feats of Power — plugin styles (plain CSS, no Tailwind build;
|
||||
* P-II: plugins ship their own stylesheet for non-core classes). Mirrors the v3
|
||||
* dark surface tokens used across the Profile page. */
|
||||
|
||||
/* Secondary category pill row inside the Achievements tab (lighter .fb-tab). */
|
||||
.fb-ach-pillrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.fb-ach-pill {
|
||||
appearance: none;
|
||||
background: rgba(30, 41, 59, .5);
|
||||
border: 1px solid rgba(51, 65, 85, .6);
|
||||
color: #94a3b8;
|
||||
border-radius: 9999px;
|
||||
padding: .35rem .75rem;
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.fb-ach-pill:hover { color: #e2e8f0; border-color: rgba(14, 165, 233, .4); }
|
||||
.fb-ach-pill.active { color: #f8fafc; background: rgba(14, 165, 233, .15); border-color: #0ea5e9; }
|
||||
.fb-ach-pill-badge {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
margin-left: .15rem;
|
||||
}
|
||||
.fb-ach-pill.active .fb-ach-pill-badge { color: #7dd3fc; }
|
||||
|
||||
/* Catalogue list. */
|
||||
.fb-ach-list { display: flex; flex-direction: column; gap: .5rem; }
|
||||
.fb-ach-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: .85rem;
|
||||
padding: .75rem .9rem;
|
||||
border-radius: .6rem;
|
||||
border: 1px solid rgba(51, 65, 85, .5);
|
||||
background: rgba(30, 41, 59, .35);
|
||||
}
|
||||
.fb-ach-item.locked { opacity: .45; filter: grayscale(.6); }
|
||||
.fb-ach-item.earned { border-color: rgba(14, 165, 233, .35); background: rgba(14, 165, 233, .06); }
|
||||
.fb-ach-item-icon { font-size: 1.4rem; line-height: 1.6rem; flex: 0 0 auto; }
|
||||
.fb-ach-item-body { min-width: 0; }
|
||||
.fb-ach-item-title {
|
||||
font-size: .9rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
}
|
||||
.fb-ach-tier {
|
||||
font-size: .65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
font-weight: 700;
|
||||
color: #7dd3fc;
|
||||
border: 1px solid rgba(125, 211, 252, .35);
|
||||
border-radius: 9999px;
|
||||
padding: .05rem .4rem;
|
||||
}
|
||||
.fb-ach-item-desc { font-size: .78rem; color: #94a3b8; margin-top: .15rem; }
|
||||
|
||||
/* Feats of Power trophy shelf (profile main page). */
|
||||
.fb-feat-shelf {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
.fb-feat-card {
|
||||
text-align: center;
|
||||
padding: 1rem .75rem;
|
||||
border-radius: .7rem;
|
||||
border: 1px solid rgba(234, 179, 8, .35);
|
||||
background: linear-gradient(180deg, rgba(234, 179, 8, .1), rgba(30, 41, 59, .3));
|
||||
}
|
||||
.fb-feat-icon { font-size: 1.9rem; }
|
||||
.fb-feat-title { font-size: .85rem; font-weight: 800; color: #fde68a; margin-top: .3rem; }
|
||||
.fb-feat-desc { font-size: .72rem; color: #cbd5e1; margin-top: .25rem; }
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Achievements & Feats of Power — pure evaluation helpers.
|
||||
|
||||
This module holds the side-effect-free core of the engine so it is unit
|
||||
testable (constitution P-V): no IO, no SQLite, no clock. `routes.py` owns the
|
||||
storage/HTTP shell and calls into these functions.
|
||||
|
||||
**Integration law (structural):** Feats are evaluated from *activity counters*
|
||||
only (`evaluate_feats` / `apply_activity`); competency Achievements are recorded
|
||||
from *competency events* the source reports (`report-unlock`). Nothing here ever
|
||||
converts an activity count into a competency unlock or vice versa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Counter keys the activity model owns. `*_max` keys take the running maximum;
|
||||
# everything else is a cumulative running total. Kept here (not in routes) so a
|
||||
# test can assert the contract without standing up a DB.
|
||||
MAX_COUNTERS = frozenset({"notes_session_max", "streak_insong_max", "chart_encore_max"})
|
||||
|
||||
|
||||
def tier_index_for(tiers, value):
|
||||
"""Highest 0-based tier index whose threshold is met by ``value``.
|
||||
|
||||
Returns -1 when no tier is reached. Tiers are assumed ascending; we scan
|
||||
all of them rather than short-circuit so an out-of-order catalogue still
|
||||
resolves to the largest satisfied tier.
|
||||
"""
|
||||
idx = -1
|
||||
for i, threshold in enumerate(tiers or []):
|
||||
try:
|
||||
if value >= threshold:
|
||||
idx = i
|
||||
except TypeError:
|
||||
continue
|
||||
return idx
|
||||
|
||||
|
||||
def feat_counter_value(feat, counters):
|
||||
"""Activity-counter value backing a Feat definition (0 when absent)."""
|
||||
key = feat.get("counter")
|
||||
if not key:
|
||||
return 0
|
||||
try:
|
||||
return int(counters.get(key, 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def evaluate_feats(feat_defs, counters):
|
||||
"""Map ``feat_id -> highest reached tier index`` for all satisfied Feats.
|
||||
|
||||
A Feat with no tiers, or whose counter hasn't reached tier 0, is omitted.
|
||||
Pure: takes the current counters snapshot, returns a plain dict.
|
||||
"""
|
||||
out = {}
|
||||
for feat in feat_defs or []:
|
||||
fid = feat.get("id")
|
||||
if not fid:
|
||||
continue
|
||||
tiers = feat.get("tiers") or []
|
||||
if not tiers:
|
||||
continue
|
||||
ti = tier_index_for(tiers, feat_counter_value(feat, counters))
|
||||
if ti >= 0:
|
||||
out[fid] = ti
|
||||
return out
|
||||
|
||||
|
||||
def apply_activity(counters, delta):
|
||||
"""Return a NEW counters dict after folding in one activity ``delta``.
|
||||
|
||||
Cumulative keys add; ``*_max`` keys keep the running maximum. The caller
|
||||
(routes.py) is responsible for the only stateful bit — the per-chart play
|
||||
count — and passes the post-increment value as ``delta['chart_play_count']``
|
||||
so this function stays pure.
|
||||
|
||||
Recognised delta fields (all optional, default 0):
|
||||
notes -> notes_total (+=)
|
||||
song_done -> songs_done (+=)
|
||||
seconds -> time_total_seconds (+=)
|
||||
session_notes -> notes_session_max (max)
|
||||
in_song_streak -> streak_insong_max (max)
|
||||
chart_play_count -> chart_encore_max (max)
|
||||
"""
|
||||
out = dict(counters or {})
|
||||
|
||||
def _cur(key):
|
||||
try:
|
||||
return int(out.get(key, 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
return int(v or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
out["notes_total"] = _cur("notes_total") + _int(delta.get("notes"))
|
||||
out["songs_done"] = _cur("songs_done") + _int(delta.get("song_done"))
|
||||
out["time_total_seconds"] = _cur("time_total_seconds") + _int(delta.get("seconds"))
|
||||
out["notes_session_max"] = max(_cur("notes_session_max"), _int(delta.get("session_notes")))
|
||||
out["streak_insong_max"] = max(_cur("streak_insong_max"), _int(delta.get("in_song_streak")))
|
||||
if delta.get("chart_play_count") is not None:
|
||||
out["chart_encore_max"] = max(_cur("chart_encore_max"), _int(delta.get("chart_play_count")))
|
||||
return out
|
||||
|
||||
|
||||
def consecutive_run_length(dates):
|
||||
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
|
||||
|
||||
Used by the `secret_witching` Feat (practise in the 2–5am window on N
|
||||
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
|
||||
feeds it the distinct night-dates recorded in `comp_ledger`.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
parsed = []
|
||||
for d in dates or []:
|
||||
try:
|
||||
y, m, dd = (int(x) for x in str(d).split("-"))
|
||||
parsed.append(date(y, m, dd))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not parsed:
|
||||
return 0
|
||||
parsed = sorted(set(parsed))
|
||||
best = run = 1
|
||||
for prev, cur in zip(parsed, parsed[1:]):
|
||||
if (cur - prev).days == 1:
|
||||
run += 1
|
||||
best = max(best, run)
|
||||
else:
|
||||
run = 1
|
||||
return best
|
||||
|
||||
|
||||
# ── Data-minimization contract (binding, code-enforced) ──────────────────────
|
||||
# The wall payload key-set is frozen here and asserted by a unit test. The
|
||||
# serializer below is the ONLY way outbound data is built — never dict(row) or
|
||||
# **model — so a stray field cannot leak. Adding a key makes the test go red.
|
||||
WALL_PAYLOAD_KEYS = ("display_name", "player_hash", "achievement_id", "unlocked_at")
|
||||
|
||||
|
||||
def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at):
|
||||
"""Build the EXACT four-field wall payload. ``achievement_id`` must always be
|
||||
a Feat id (the caller only ever invokes this for Feat unlocks — competency
|
||||
never syncs). Explicit literal dict on purpose; do not refactor into a
|
||||
row/model splat."""
|
||||
return {
|
||||
"display_name": display_name,
|
||||
"player_hash": player_hash,
|
||||
"achievement_id": achievement_id,
|
||||
"unlocked_at": unlocked_at,
|
||||
}
|
||||
|
||||
|
||||
def drain_decision(status):
|
||||
"""Dead-letter state machine for one wall-sync attempt (pure).
|
||||
|
||||
``status`` is the HTTP status code, or ``None`` for a network error.
|
||||
Returns one of:
|
||||
'ack' → server accepted; delete the row.
|
||||
'retry' → keep it pending (network error, 429 backoff, or 5xx).
|
||||
'dead' → any other 4xx; move to dead_letter (diagnosable/replayable).
|
||||
A row is NEVER silently dropped — it leaves the queue only on 'ack' (or a
|
||||
user opt-out wiping it).
|
||||
"""
|
||||
if status is None:
|
||||
return "retry"
|
||||
if 200 <= status < 300:
|
||||
return "ack"
|
||||
if status == 429:
|
||||
return "retry"
|
||||
if 400 <= status < 500:
|
||||
return "dead"
|
||||
return "retry" # 5xx — transient server-side, try again later
|
||||
|
||||
|
||||
def diff_unlocks(prev_tiers, new_tiers):
|
||||
"""Feat ids whose tier advanced (incl. first unlock).
|
||||
|
||||
``prev_tiers`` / ``new_tiers`` are ``feat_id -> tier_index`` maps as
|
||||
returned by :func:`evaluate_feats`. Returns the ids that are newly present
|
||||
or moved to a higher tier — i.e. the Feats to record + announce this round.
|
||||
"""
|
||||
out = []
|
||||
for fid, tier in (new_tiers or {}).items():
|
||||
if tier > prev_tiers.get(fid, -1):
|
||||
out.append(fid)
|
||||
return out
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"version": 1,
|
||||
"feats": [
|
||||
{
|
||||
"id": "notes_total",
|
||||
"title": "Note Hunter",
|
||||
"description": "Hit a colossal number of notes across all your practice.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": false,
|
||||
"needs_notedetect": true,
|
||||
"counter": "notes_total",
|
||||
"tiers": [100000, 1000000, 10000000],
|
||||
"tier_titles": ["Note Hunter", "Million-Note Maestro", "Ten-Million-Note Titan"]
|
||||
},
|
||||
{
|
||||
"id": "notes_session",
|
||||
"title": "Marathon",
|
||||
"description": "Hit 25,000 notes in a single sitting.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": false,
|
||||
"needs_notedetect": true,
|
||||
"counter": "notes_session_max",
|
||||
"tiers": [25000],
|
||||
"tier_titles": ["Marathon"]
|
||||
},
|
||||
{
|
||||
"id": "streak_insong",
|
||||
"title": "Untouchable",
|
||||
"description": "Land a huge run of consecutive in-song hits with no miss.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": false,
|
||||
"needs_notedetect": true,
|
||||
"counter": "streak_insong_max",
|
||||
"tiers": [1000, 5000],
|
||||
"tier_titles": ["Untouchable", "Truly Untouchable"]
|
||||
},
|
||||
{
|
||||
"id": "songs_done",
|
||||
"title": "Road Warrior",
|
||||
"description": "Finish a mountain of songs.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": false,
|
||||
"needs_notedetect": false,
|
||||
"counter": "songs_done",
|
||||
"tiers": [1000, 5000],
|
||||
"tier_titles": ["Road Warrior", "Road Legend"]
|
||||
},
|
||||
{
|
||||
"id": "time_total",
|
||||
"title": "Time Served",
|
||||
"description": "Pour hundreds of hours into practice.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": false,
|
||||
"needs_notedetect": false,
|
||||
"counter": "time_total_seconds",
|
||||
"tiers": [1800000, 7200000],
|
||||
"tier_titles": ["Time Served (500h)", "Time Served (2,000h)"]
|
||||
},
|
||||
{
|
||||
"id": "chart_encore",
|
||||
"title": "Encore",
|
||||
"description": "Play the same chart again and again and again.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": false,
|
||||
"needs_notedetect": false,
|
||||
"counter": "chart_encore_max",
|
||||
"tiers": [100, 500],
|
||||
"tier_titles": ["Encore", "Standing Ovation"]
|
||||
},
|
||||
{
|
||||
"id": "secret_witching",
|
||||
"title": "The Witching Hour",
|
||||
"description": "Practise in the dead of night, seven nights running.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": true,
|
||||
"needs_notedetect": false,
|
||||
"counter": "witching_nights_run",
|
||||
"tiers": [7],
|
||||
"tier_titles": ["The Witching Hour"]
|
||||
},
|
||||
{
|
||||
"id": "secret_combo",
|
||||
"title": "Hidden Track",
|
||||
"description": "You found the hidden track.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": true,
|
||||
"needs_notedetect": false,
|
||||
"counter": null,
|
||||
"tiers": [],
|
||||
"tier_titles": ["Hidden Track"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "achievements",
|
||||
"name": "Achievements",
|
||||
"version": "0.1.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"description": "Achievements & Feats of Power — skill milestones on your Profile, plus rare activity Feats.",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/achievements.css",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "system",
|
||||
"server_files": [
|
||||
"achievements/"
|
||||
]
|
||||
},
|
||||
"routes": "routes.py"
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Achievements & Feats of Power — local engine (offline).
|
||||
|
||||
State lives under ``<config_dir>/achievements/``:
|
||||
- ``achievements.db`` SQLite — unlocks, activity counters, derived ledger,
|
||||
and the (PR3) wall sync queue.
|
||||
|
||||
Two surfaces, one engine, kept structurally apart (the **integration law**):
|
||||
* **Feats of Power** — activity/volume. The engine OWNS raw activity counters
|
||||
(`counters`), evaluates Feat thresholds, and records Feat unlocks. Feats are
|
||||
the only thing that ever syncs to the public wall.
|
||||
* **Achievements** — demonstrated competency. The engine RECORDS unlocks the
|
||||
source reports (`report-unlock`); it never re-derives them from activity.
|
||||
A baseline catalogue ships here and is driven by the built-in progression
|
||||
system; richer items are contributed by source plugins at runtime.
|
||||
|
||||
Endpoints (all under /api/plugins/achievements/):
|
||||
POST /activity bump activity counters, eval Feats, return newly-unlocked
|
||||
POST /report-unlock idempotent upsert of a competency/feat unlock
|
||||
POST /report-criterion record a (criterion_id, token) pair → distinct count
|
||||
GET /catalog baseline competency defs + earned state
|
||||
GET /earned all earned items (id, cls, category, tier, at)
|
||||
GET /feats earned Feats (for the profile trophy shelf)
|
||||
POST /remove-me wipe synced state (full wall-removal lands in PR2/PR3)
|
||||
|
||||
Pure threshold/criterion math lives in the sibling ``engine.py`` (P-V testable);
|
||||
this module is the SQLite + HTTP shell.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state = {
|
||||
"db_path": None,
|
||||
"dir": None, # plugin directory (for catalog JSON)
|
||||
"config_dir": None, # CONFIG_DIR (for reading the opt-in setting)
|
||||
"meta_db": None, # MetadataDB (for the profile identity: name + hash)
|
||||
"log": logging.getLogger("feedBack.plugin.achievements"),
|
||||
"engine": None, # sibling engine.py module (pure helpers)
|
||||
"feat_defs": [], # parsed feats.json -> list of feat defs
|
||||
"baseline": {}, # parsed achievements.json
|
||||
}
|
||||
|
||||
|
||||
# ── SQLite ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _conn():
|
||||
conn = sqlite3.connect(_state["db_path"], timeout=5)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def _init_db():
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS unlocks (
|
||||
achievement_id TEXT PRIMARY KEY,
|
||||
cls TEXT NOT NULL, -- 'competency' | 'feat'
|
||||
disp_category TEXT, -- global/guitar/bass/...
|
||||
source_id TEXT,
|
||||
tier INTEGER NOT NULL DEFAULT 0,
|
||||
unlocked_at TEXT,
|
||||
synced INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
key TEXT PRIMARY KEY,
|
||||
value INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS comp_ledger (
|
||||
criterion_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
PRIMARY KEY (criterion_id, token)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- 'unlock' | 'remove'
|
||||
payload TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending' -- 'pending' | 'dead_letter'
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _now_iso():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _opted_in():
|
||||
"""True only when the user has opted in (core setting ``achievements_enabled``).
|
||||
|
||||
Read straight from CONFIG_DIR/config.json — the single source of truth the
|
||||
/api/settings endpoint persists. Default OFF on any read failure: nothing
|
||||
leaves the device unless explicitly enabled.
|
||||
"""
|
||||
try:
|
||||
cfg_path = Path(_state["config_dir"]) / "config.json"
|
||||
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
return bool(cfg.get("achievements_enabled") is True)
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _identity():
|
||||
"""(display_name, player_hash) from the profile, or (None, None).
|
||||
|
||||
Reused as the wall identity (server.py's documented player_hash). Sync is
|
||||
skipped entirely when either is missing.
|
||||
"""
|
||||
db = _state["meta_db"]
|
||||
if db is None or not hasattr(db, "get_profile"):
|
||||
return None, None
|
||||
try:
|
||||
prof = db.get_profile() or {}
|
||||
return (prof.get("display_name") or None), (prof.get("player_hash") or None)
|
||||
except Exception: # noqa: BLE001 — identity is best-effort; never break a request
|
||||
return None, None
|
||||
|
||||
|
||||
def _enqueue_feat_sync(conn, feat_id, unlocked_at):
|
||||
"""Enqueue a wall-sync POST for a Feat unlock — opt-in gated, identity gated.
|
||||
|
||||
Builds the outbound payload through the SINGLE code-gated serializer
|
||||
(engine.build_wall_payload, exactly four fields). Competency unlocks never
|
||||
reach this path (integration law + data-minimization contract). The drain
|
||||
worker (PR3) POSTs the queued rows; here we only persist intent.
|
||||
"""
|
||||
if not _opted_in():
|
||||
return False
|
||||
display_name, player_hash = _identity()
|
||||
if not display_name or not player_hash:
|
||||
return False
|
||||
payload = _state["engine"].build_wall_payload(display_name, player_hash, feat_id, unlocked_at)
|
||||
conn.execute(
|
||||
"INSERT INTO sync_queue(kind, payload, state) VALUES ('unlock', ?, 'pending')",
|
||||
(json.dumps(payload),),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ── Wall sync — background drain worker (dead-letter, never drop) ─────────────
|
||||
# Idle unless a wall URL is configured. POSTs pending rows to the hosted
|
||||
# feedback-achievements service; the decision state machine
|
||||
# (engine.drain_decision) is pure + tested. A row leaves the queue only on a
|
||||
# server ack (or a user opt-out wiping it) — never silently dropped.
|
||||
|
||||
# Canonical hosted Feats wall (the got-feedback service). Used by default so the
|
||||
# drain worker targets it out of the box; override via env for self-hosting or a
|
||||
# staging wall. Nothing is ever sent unless the user opted in AND has an identity
|
||||
# (see _enqueue_feat_sync), so a default URL does not publish anything on its own.
|
||||
_DEFAULT_WALL_URL = "https://feedback-achievements.onrender.com"
|
||||
_WALL_URL = (os.environ.get("FEEDBACK_ACHIEVEMENTS_WALL_URL")
|
||||
or os.environ.get("SLOPSMITH_ACHIEVEMENTS_WALL_URL")
|
||||
or _DEFAULT_WALL_URL).rstrip("/")
|
||||
_WALL_TOKEN = os.environ.get("FEEDBACK_ACHIEVEMENTS_CLIENT_TOKEN", "fb-wall-v1")
|
||||
_DRAIN_INTERVAL_S = int(os.environ.get("FEEDBACK_ACHIEVEMENTS_DRAIN_INTERVAL", "30"))
|
||||
_drain_started = False
|
||||
|
||||
|
||||
def _post_to_wall(kind, payload):
|
||||
"""POST one queued item; return the HTTP status code, or None on a network
|
||||
error. Mirrors the lib/lyrics_transcribe outbound pattern (explicit timeout,
|
||||
no raise on non-2xx — the caller's state machine decides)."""
|
||||
import requests # local import: only needed when a wall is configured
|
||||
|
||||
path = "/api/unlock" if kind == "unlock" else "/api/remove"
|
||||
try:
|
||||
resp = requests.post(
|
||||
_WALL_URL + path, json=payload,
|
||||
headers={"X-Client-Token": _WALL_TOKEN, "Content-Type": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
return resp.status_code
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
def _drain_once(post_fn=None):
|
||||
"""Process all pending queue rows once. ``post_fn(kind, payload) -> status``
|
||||
is injectable for tests; defaults to the real wall POST."""
|
||||
post_fn = post_fn or _post_to_wall
|
||||
engine = _state["engine"]
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, kind, payload FROM sync_queue WHERE state='pending'").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for row in rows:
|
||||
try:
|
||||
payload = json.loads(row["payload"]) if row["payload"] else {}
|
||||
except ValueError:
|
||||
payload = {}
|
||||
status = post_fn(row["kind"], payload)
|
||||
action = engine.drain_decision(status)
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
if action == "ack":
|
||||
conn.execute("DELETE FROM sync_queue WHERE id=?", (row["id"],))
|
||||
elif action == "dead":
|
||||
conn.execute("UPDATE sync_queue SET state='dead_letter' WHERE id=?", (row["id"],))
|
||||
# 'retry' → leave it pending for the next pass
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _drain_loop():
|
||||
while True:
|
||||
try:
|
||||
_drain_once()
|
||||
except Exception as e: # noqa: BLE001 — a worker crash must not kill the thread
|
||||
_state["log"].warning("achievements wall drain error: %s", e)
|
||||
time.sleep(_DRAIN_INTERVAL_S)
|
||||
|
||||
|
||||
def _maybe_start_drain():
|
||||
global _drain_started
|
||||
if _drain_started or not _WALL_URL:
|
||||
return
|
||||
_drain_started = True
|
||||
threading.Thread(target=_drain_loop, name="ach-wall-drain", daemon=True).start()
|
||||
_state["log"].info("achievements wall drain worker started → %s", _WALL_URL)
|
||||
|
||||
|
||||
def _chart_key(chart):
|
||||
"""Stable per-chart counter key — a sha1 digest of the chart id. NOT the
|
||||
builtin hash(), whose str hashing is salted per process (PYTHONHASHSEED), so
|
||||
the same chart would land on a different counter after every restart and the
|
||||
Encore Feat could never accumulate across sessions."""
|
||||
return "chart_plays:" + hashlib.sha1(str(chart).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _read_counters(conn):
|
||||
# Excludes the per-chart `chart_plays:*` rows: they are bumped + read
|
||||
# individually via _bump_counter and would otherwise make this aggregate
|
||||
# round-trip O(distinct charts played) on every activity POST.
|
||||
return {
|
||||
row["key"]: int(row["value"])
|
||||
for row in conn.execute("SELECT key, value FROM counters WHERE key NOT LIKE 'chart_plays:%'")
|
||||
}
|
||||
|
||||
|
||||
def _write_counters(conn, counters):
|
||||
for key, value in counters.items():
|
||||
conn.execute(
|
||||
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, int(value)),
|
||||
)
|
||||
|
||||
|
||||
def _bump_counter(conn, key, delta):
|
||||
"""Increment a counter and return the new value (used for per-chart plays)."""
|
||||
conn.execute(
|
||||
"INSERT INTO counters(key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=value+excluded.value",
|
||||
(key, int(delta)),
|
||||
)
|
||||
row = conn.execute("SELECT value FROM counters WHERE key=?", (key,)).fetchone()
|
||||
return int(row["value"]) if row else int(delta)
|
||||
|
||||
|
||||
def _earned_feat_tiers(conn):
|
||||
return {
|
||||
row["achievement_id"]: int(row["tier"])
|
||||
for row in conn.execute("SELECT achievement_id, tier FROM unlocks WHERE cls='feat'")
|
||||
}
|
||||
|
||||
|
||||
def _record_unlock(conn, ach_id, cls, disp_category, source_id, tier, at):
|
||||
"""Idempotent upsert; only advances the tier upward. Returns True if changed."""
|
||||
row = conn.execute("SELECT tier FROM unlocks WHERE achievement_id=?", (ach_id,)).fetchone()
|
||||
if row is not None and int(row["tier"]) >= int(tier):
|
||||
return False
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO unlocks(achievement_id, cls, disp_category, source_id, tier, unlocked_at, synced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(achievement_id) DO UPDATE SET
|
||||
tier=excluded.tier,
|
||||
cls=excluded.cls,
|
||||
disp_category=COALESCE(excluded.disp_category, unlocks.disp_category),
|
||||
source_id=COALESCE(excluded.source_id, unlocks.source_id)
|
||||
""",
|
||||
(ach_id, cls, disp_category, source_id, int(tier), at or _now_iso()),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ── Catalog loading ─────────────────────────────────────────────────────────
|
||||
|
||||
def _feat_by_id(fid):
|
||||
for f in _state["feat_defs"]:
|
||||
if f.get("id") == fid:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _load_catalogs():
|
||||
base = Path(_state["dir"])
|
||||
try:
|
||||
feats = json.loads((base / "feats.json").read_text(encoding="utf-8"))
|
||||
_state["feat_defs"] = feats.get("feats", []) if isinstance(feats, dict) else []
|
||||
except (OSError, ValueError) as e:
|
||||
_state["log"].warning("achievements: could not load feats.json: %s", e)
|
||||
_state["feat_defs"] = []
|
||||
try:
|
||||
_state["baseline"] = json.loads((base / "achievements.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as e:
|
||||
_state["log"].warning("achievements: could not load achievements.json: %s", e)
|
||||
_state["baseline"] = {}
|
||||
|
||||
|
||||
# ── Request models ──────────────────────────────────────────────────────────
|
||||
|
||||
class ActivityIn(BaseModel):
|
||||
notes: int = Field(ge=0, default=0)
|
||||
session_notes: int = Field(ge=0, default=0)
|
||||
in_song_streak: int = Field(ge=0, default=0)
|
||||
song_done: int = Field(ge=0, default=0)
|
||||
seconds: int = Field(ge=0, default=0)
|
||||
chart: str | None = None
|
||||
night_session: bool = False
|
||||
night_date: str | None = None # 'YYYY-MM-DD', frontend supplies (no server clock)
|
||||
|
||||
|
||||
class UnlockIn(BaseModel):
|
||||
id: str
|
||||
kind: str = "achievement" # 'achievement' | 'feat'
|
||||
category: str | None = None # display category (global/guitar/...)
|
||||
sourceId: str | None = None
|
||||
tier: int = Field(ge=0, default=0)
|
||||
at: str | None = None
|
||||
|
||||
|
||||
class CriterionIn(BaseModel):
|
||||
criterion_id: str
|
||||
token: str
|
||||
|
||||
|
||||
# ── FastAPI wiring ──────────────────────────────────────────────────────────
|
||||
|
||||
def setup(app, context):
|
||||
config_dir = context["config_dir"]
|
||||
base = Path(config_dir) / "achievements"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
_state["db_path"] = str(base / "achievements.db")
|
||||
_state["dir"] = str(Path(__file__).resolve().parent)
|
||||
_state["config_dir"] = str(config_dir)
|
||||
_state["meta_db"] = context.get("meta_db")
|
||||
_state["log"] = context.get("log") or _state["log"]
|
||||
# Pure helpers via the per-plugin sibling loader (constitution P-III), with a
|
||||
# plain-import fallback for pytest / standalone use.
|
||||
load_sibling = context.get("load_sibling")
|
||||
try:
|
||||
_state["engine"] = load_sibling("engine") if load_sibling else __import__("engine")
|
||||
except Exception: # noqa: BLE001 — last-ditch, keep the plugin alive
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"achievements_engine", str(Path(__file__).resolve().parent / "engine.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
_state["engine"] = mod
|
||||
_init_db()
|
||||
_load_catalogs()
|
||||
log = _state["log"]
|
||||
|
||||
@app.post("/api/plugins/achievements/activity")
|
||||
def post_activity(body: ActivityIn):
|
||||
engine = _state["engine"]
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
# Per-chart play count is the only directly-stateful bit; bump it
|
||||
# first (stable key) so apply_activity() just takes the new max.
|
||||
chart_play_count = None
|
||||
if body.song_done and body.chart:
|
||||
chart_play_count = _bump_counter(conn, _chart_key(body.chart), 1)
|
||||
# Night-window ledger → consecutive-night run. Computed here but
|
||||
# NOT written before the prev snapshot: it is folded into the delta
|
||||
# below so prev_tiers reflects the OLD run and new_tiers the new one
|
||||
# (the same asymmetry chart_encore relies on). Pre-writing it would
|
||||
# make prev already satisfy the Feat, so diff_unlocks would never
|
||||
# see the freshly-earned witching unlock.
|
||||
witching_run = None
|
||||
if body.night_session and body.night_date:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES ('witching', ?)",
|
||||
(body.night_date,),
|
||||
)
|
||||
nights = [r["token"] for r in conn.execute(
|
||||
"SELECT token FROM comp_ledger WHERE criterion_id='witching'")]
|
||||
witching_run = engine.consecutive_run_length(nights)
|
||||
|
||||
counters = _read_counters(conn)
|
||||
prev_tiers = engine.evaluate_feats(_state["feat_defs"], counters)
|
||||
new_counters = engine.apply_activity(counters, {
|
||||
"notes": body.notes,
|
||||
"session_notes": body.session_notes,
|
||||
"in_song_streak": body.in_song_streak,
|
||||
"song_done": body.song_done,
|
||||
"seconds": body.seconds,
|
||||
"chart_play_count": chart_play_count,
|
||||
})
|
||||
if witching_run is not None:
|
||||
new_counters["witching_nights_run"] = max(
|
||||
int(new_counters.get("witching_nights_run", 0) or 0), witching_run)
|
||||
_write_counters(conn, new_counters)
|
||||
new_tiers = engine.evaluate_feats(_state["feat_defs"], new_counters)
|
||||
fresh = engine.diff_unlocks(prev_tiers, new_tiers)
|
||||
unlocked = []
|
||||
for fid in fresh:
|
||||
f = _feat_by_id(fid) or {}
|
||||
tier = new_tiers[fid]
|
||||
at = _now_iso()
|
||||
if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, at):
|
||||
_enqueue_feat_sync(conn, fid, at)
|
||||
unlocked.append(_feat_payload(fid, f, tier))
|
||||
conn.commit()
|
||||
return {"ok": True, "unlocked": unlocked, "counters": new_counters}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.post("/api/plugins/achievements/report-unlock")
|
||||
def post_report_unlock(body: UnlockIn):
|
||||
cls = "feat" if body.kind == "feat" else "competency"
|
||||
at = body.at or _now_iso()
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
changed = _record_unlock(
|
||||
conn, body.id, cls, body.category, body.sourceId, body.tier, at)
|
||||
# Only Feats sync; competency never enqueues (integration law +
|
||||
# data-minimization contract).
|
||||
if changed and cls == "feat":
|
||||
_enqueue_feat_sync(conn, body.id, at)
|
||||
conn.commit()
|
||||
return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.post("/api/plugins/achievements/report-criterion")
|
||||
def post_report_criterion(body: CriterionIn):
|
||||
"""Record a distinct (criterion_id, token); return the distinct count.
|
||||
|
||||
Lets a baseline subscriber aggregate multi-event criteria (e.g. the set
|
||||
of distinct days with a real advance → `steady_hands`) without us
|
||||
re-deriving competency from activity. Bookkeeping over events only.
|
||||
"""
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES (?, ?)",
|
||||
(body.criterion_id, body.token),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM comp_ledger WHERE criterion_id=?",
|
||||
(body.criterion_id,),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return {"ok": True, "count": int(row["n"]) if row else 0}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/plugins/achievements/catalog")
|
||||
def get_catalog():
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
earned = _earned_map(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return {"baseline": _state["baseline"], "earned": earned}
|
||||
|
||||
@app.get("/api/plugins/achievements/earned")
|
||||
def get_earned():
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
return {"earned": list(_earned_map(conn).values())}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/plugins/achievements/feats")
|
||||
def get_feats():
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT achievement_id, tier, unlocked_at FROM unlocks WHERE cls='feat'"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
out = []
|
||||
for row in rows:
|
||||
fid = row["achievement_id"]
|
||||
f = _feat_by_id(fid) or {}
|
||||
payload = _feat_payload(fid, f, int(row["tier"]))
|
||||
payload["unlocked_at"] = row["unlocked_at"]
|
||||
out.append(payload)
|
||||
return {"feats": out}
|
||||
|
||||
@app.post("/api/plugins/achievements/remove-me")
|
||||
def post_remove_me():
|
||||
# Local removal works offline: drop the synced flag so nothing re-syncs,
|
||||
# and enqueue a wall removal (drained in PR3). The wall identity
|
||||
# (player_hash) is resolved server-side at drain time, not stored here.
|
||||
_, player_hash = _identity()
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'")
|
||||
# Enqueue a wall removal only when we have an identity to key it
|
||||
# by; the drain worker (below) POSTs it. Idempotent server-side.
|
||||
if player_hash:
|
||||
conn.execute(
|
||||
"INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', ?, 'pending')",
|
||||
(json.dumps({"player_hash": player_hash}),),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_maybe_start_drain()
|
||||
log.info("achievements engine ready (%d feats, baseline v%s)",
|
||||
len(_state["feat_defs"]), str(_state["baseline"].get("version", "?")))
|
||||
|
||||
|
||||
def _feat_payload(fid, feat, tier):
|
||||
titles = feat.get("tier_titles") or []
|
||||
title = titles[tier] if 0 <= tier < len(titles) else feat.get("title", fid)
|
||||
return {
|
||||
"id": fid,
|
||||
"cls": "feat",
|
||||
"tier": tier,
|
||||
"title": title,
|
||||
"description": feat.get("description", ""),
|
||||
"category": feat.get("category", "global"),
|
||||
"secret": bool(feat.get("secret", False)),
|
||||
}
|
||||
|
||||
|
||||
def _earned_map(conn):
|
||||
out = {}
|
||||
for row in conn.execute(
|
||||
"SELECT achievement_id, cls, disp_category, tier, unlocked_at FROM unlocks"
|
||||
):
|
||||
out[row["achievement_id"]] = {
|
||||
"id": row["achievement_id"],
|
||||
"cls": row["cls"],
|
||||
"category": row["disp_category"],
|
||||
"tier": int(row["tier"]),
|
||||
"unlocked_at": row["unlocked_at"],
|
||||
}
|
||||
return out
|
||||
@@ -0,0 +1,395 @@
|
||||
/*
|
||||
* Achievements & Feats of Power — frontend engine (vanilla, constitution P-II).
|
||||
*
|
||||
* Renders into the two core Profile mount points (achievements epic):
|
||||
* #v3-profile-feats-slot → earned Feats trophy shelf (hidden-until-earned)
|
||||
* #v3-profile-achievements-mount → full competency catalogue (locked = greyed),
|
||||
* grouped by instrument via a secondary pill row.
|
||||
* Re-injects on every `v3:profile-rendered` (core wipes the mounts each render).
|
||||
*
|
||||
* Also exposes the cross-plugin registration API `window.feedBack.achievements`
|
||||
* (v1) so source plugins (Virtuoso, notedetect, …) contribute competency defs +
|
||||
* report unlocks without us hardcoding their vocabulary. Load-order safe via the
|
||||
* `window.__feedBackAchievementsPending` queue + `achievements:ready` event.
|
||||
*
|
||||
* INTEGRATION LAW: Feats read activity counters only (we POST batched activity on
|
||||
* song:ended); competency Achievements are evaluated from progression EVENTS only.
|
||||
* The two paths never cross.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API = '/api/plugins/achievements';
|
||||
var bus = window.feedBack;
|
||||
if (!bus) return; // bus must exist (capabilities.js); nothing to attach to.
|
||||
|
||||
var esc = function (s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
};
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
var registered = {}; // id -> def (contributed + expanded baseline defs)
|
||||
var earned = {}; // id -> { tier, cls, category }
|
||||
var baseline = null; // /catalog baseline blob
|
||||
var CAT_KEY = 'achievements:profile-cat'; // P-III: plugin localStorage keys prefixed with plugin id
|
||||
var INSTRUMENTS = ['guitar', 'bass', 'drums', 'keys'];
|
||||
|
||||
function progState() {
|
||||
return (window.v3Progression && window.v3Progression.get()) || null;
|
||||
}
|
||||
function notedetectPresent() {
|
||||
return typeof window.createNoteDetector === 'function';
|
||||
}
|
||||
|
||||
// ── Backend I/O ──────────────────────────────────────────────────────────
|
||||
function fetchJSON(path, opts) {
|
||||
return fetch(API + path, opts).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
function postUnlock(def, tier) {
|
||||
return fetch(API + '/report-unlock', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: def.id, kind: def.kind || 'achievement',
|
||||
category: def.category || 'global', sourceId: def.sourceId || 'achievements',
|
||||
tier: tier || 0,
|
||||
}),
|
||||
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
function refreshEarned() {
|
||||
return fetchJSON('/earned').then(function (data) {
|
||||
earned = {};
|
||||
((data && data.earned) || []).forEach(function (e) { earned[e.id] = e; });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tier math (mirrors engine.tier_index_for) ────────────────────────────
|
||||
function tierIndexFor(tiers, value) {
|
||||
var idx = -1;
|
||||
(tiers || []).forEach(function (t, i) { if (value >= t) idx = i; });
|
||||
return idx;
|
||||
}
|
||||
function alreadyEarnedAtLeast(id, tier) {
|
||||
var e = earned[id];
|
||||
return e && e.tier >= tier;
|
||||
}
|
||||
|
||||
// ── Registration API (v1) ────────────────────────────────────────────────
|
||||
function register(def) {
|
||||
if (!def || !def.id) return;
|
||||
registered[def.id] = {
|
||||
id: def.id, kind: def.kind || 'achievement', category: def.category || 'global',
|
||||
title: def.title || def.id, description: def.description || '',
|
||||
secret: !!def.secret, sourceId: def.sourceId || 'unknown',
|
||||
};
|
||||
scheduleRender();
|
||||
}
|
||||
function registerAll(defs) { (defs || []).forEach(register); }
|
||||
function unlock(id, opts) {
|
||||
var def = registered[id] || { id: id, kind: 'achievement', category: 'global', sourceId: 'unknown' };
|
||||
var tier = (opts && opts.tier) || 0;
|
||||
if (alreadyEarnedAtLeast(id, tier)) return Promise.resolve();
|
||||
return postUnlock(def, tier).then(function () {
|
||||
return refreshEarned().then(function () {
|
||||
// A contributed Feat unlock would enqueue a wall sync here when
|
||||
// opted-in (PR2/PR3); competency never syncs (integration law).
|
||||
scheduleRender();
|
||||
});
|
||||
});
|
||||
}
|
||||
function progress() { /* accepted, optional — display is greyed/earned, not bars */ }
|
||||
|
||||
var api = { version: 1, register: register, registerAll: registerAll, unlock: unlock, progress: progress };
|
||||
bus.achievements = api;
|
||||
// Drain sources that loaded before us (minigames pending-queue pattern).
|
||||
try { (window.__feedBackAchievementsPending || []).forEach(function (fn) {
|
||||
try { typeof fn === 'function' ? fn(api) : register(fn); } catch (_) { /* noop */ }
|
||||
}); } catch (_) { /* noop */ }
|
||||
window.__feedBackAchievementsPending = null;
|
||||
try { bus.emit && bus.emit('achievements:ready', { version: 1 }); } catch (_) { /* noop */ }
|
||||
|
||||
// ── Baseline competency: evaluate from progression EVENTS only ────────────
|
||||
function expandBaseline() {
|
||||
// Register baseline defs (always present — built-in progression is always
|
||||
// present) so they render greyed even before they're earned. Per-instrument
|
||||
// templates expand across the REAL paths that exist (auto-extends).
|
||||
if (!baseline) return;
|
||||
(baseline.global || []).forEach(function (d) {
|
||||
register({ id: d.id, kind: 'achievement', category: 'global', title: d.title,
|
||||
description: d.description, sourceId: 'achievements' });
|
||||
});
|
||||
var paths = (progState() && progState().paths) || [];
|
||||
var pathIds = paths.length ? paths.map(function (p) { return { id: p.id, name: p.name }; })
|
||||
: INSTRUMENTS.map(function (i) { return { id: i, name: i.charAt(0).toUpperCase() + i.slice(1) }; });
|
||||
(baseline.per_instrument || []).forEach(function (tpl) {
|
||||
pathIds.forEach(function (pi) {
|
||||
var inst = pi.name;
|
||||
register({
|
||||
id: tpl.id + ':' + pi.id, kind: 'achievement', category: pi.id,
|
||||
title: (tpl.title || '').replace(/\{Inst\}/g, inst),
|
||||
description: (tpl.description || '').replace(/\{Inst\}/g, inst),
|
||||
sourceId: 'achievements',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function evaluateBaseline() {
|
||||
var prog = progState();
|
||||
if (!prog || !baseline) return;
|
||||
var defById = {};
|
||||
(baseline.global || []).forEach(function (d) { defById[d.id] = d; });
|
||||
|
||||
// mastery_rank → first_steps / ascendant
|
||||
(baseline.global || []).forEach(function (d) {
|
||||
var crit = d.criterion || {};
|
||||
if (crit.type === 'mastery_rank') {
|
||||
var ti = tierIndexFor(crit.tiers || d.tiers, prog.mastery_rank || 0);
|
||||
if (ti >= 0) baselineUnlock(d.id, 'global', ti);
|
||||
} else if (crit.type === 'paths_at_level') {
|
||||
var n = ((prog.paths) || []).filter(function (p) { return (p.level || 0) >= (crit.level || 10); }).length;
|
||||
var ti2 = tierIndexFor(crit.tiers || d.tiers, n);
|
||||
if (ti2 >= 0) baselineUnlock(d.id, 'global', ti2);
|
||||
}
|
||||
});
|
||||
|
||||
// per-instrument path_rank → reach Lv 10/25/max in that path
|
||||
var tpl = (baseline.per_instrument || []).filter(function (t) { return t.id === 'path_rank'; })[0];
|
||||
if (tpl) {
|
||||
((prog.paths) || []).forEach(function (p) {
|
||||
var thresholds = (tpl.criterion && tpl.criterion.tiers) || [10, 25, 'max'];
|
||||
var resolved = thresholds.map(function (t) { return t === 'max' ? (p.max_level || 9999) : t; });
|
||||
var ti = tierIndexFor(resolved, p.level || 0);
|
||||
if (ti >= 0) baselineUnlock('path_rank:' + p.id, p.id, ti);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function baselineUnlock(id, category, tier) {
|
||||
if (alreadyEarnedAtLeast(id, tier)) return;
|
||||
var def = registered[id] || { id: id, kind: 'achievement', category: category, sourceId: 'achievements' };
|
||||
postUnlock(def, tier).then(function () { refreshEarned().then(scheduleRender); });
|
||||
}
|
||||
|
||||
// Local calendar date 'YYYY-MM-DD' (one source of truth for both the
|
||||
// steady-hands day ledger and the witching-night date below).
|
||||
function localISODate(d) {
|
||||
d = d || new Date();
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// growth-streak + challenger record on real competency events (date ledger /
|
||||
// distinct challenge sets) — kept as bookkeeping over EVENTS, never activity.
|
||||
function recordGrowthDay() {
|
||||
var iso = localISODate();
|
||||
fetchJSON('/report-criterion', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ criterion_id: 'steady_hands_days', token: iso }),
|
||||
}).then(function (res) {
|
||||
if (!res) return;
|
||||
var d = ((baseline && baseline.global) || []).filter(function (x) { return x.id === 'steady_hands'; })[0];
|
||||
var ti = tierIndexFor((d && d.tiers) || [7, 30, 100], res.count || 0);
|
||||
if (ti >= 0) baselineUnlock('steady_hands', 'global', ti);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Activity (Feats): in-memory session counters, flushed on song:ended ───
|
||||
var session = { notesTotal: 0 }; // cumulative across this sitting
|
||||
// `active` gates note counting to an actual song in progress — without it,
|
||||
// note:hit/miss from the tuner or input-calibration would inflate Feats from
|
||||
// non-song input and flush a phantom streak with chart:null.
|
||||
var song = { hits: 0, streak: 0, maxStreak: 0, chart: null, active: false };
|
||||
function resetSong(chart) { song = { hits: 0, streak: 0, maxStreak: 0, chart: chart || null, active: true }; }
|
||||
|
||||
function flushActivity(seconds) {
|
||||
if (!song.active) return; // no active song → nothing to flush (ignore stray events)
|
||||
song.active = false;
|
||||
// No notedetect → song.hits stays 0; notes-based Feats simply don't move
|
||||
// (graceful degradation). song_done / seconds / chart still flow so the
|
||||
// notedetect-free Feats (Road Warrior, Time Served, Encore) progress.
|
||||
var hour = new Date().getHours();
|
||||
var isNight = hour >= 2 && hour < 5;
|
||||
var iso = localISODate();
|
||||
var body = {
|
||||
notes: song.hits,
|
||||
session_notes: session.notesTotal,
|
||||
in_song_streak: song.maxStreak,
|
||||
song_done: 1,
|
||||
seconds: Math.max(0, Math.round(seconds || 0)),
|
||||
chart: song.chart,
|
||||
night_session: isNight,
|
||||
night_date: isNight ? iso : null,
|
||||
};
|
||||
fetchJSON('/activity', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(function (res) {
|
||||
if (res && res.unlocked && res.unlocked.length) {
|
||||
// A Feat just unlocked — refresh the shelf + toast via the bus.
|
||||
fetchFeatsAndRender();
|
||||
res.unlocked.forEach(function (f) {
|
||||
try { bus.emit && bus.emit('achievements:feat-unlocked', f); } catch (_) { /* noop */ }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
var _renderQueued = false;
|
||||
function scheduleRender() {
|
||||
if (_renderQueued) return;
|
||||
_renderQueued = true;
|
||||
(window.requestAnimationFrame || window.setTimeout)(function () { _renderQueued = false; renderAll(); }, 0);
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderCatalog();
|
||||
fetchFeatsAndRender();
|
||||
}
|
||||
|
||||
function categoriesForDisplay() {
|
||||
var cats = [{ id: 'global', name: 'Global' }];
|
||||
var paths = (progState() && progState().paths) || [];
|
||||
if (paths.length) {
|
||||
paths.forEach(function (p) { cats.push({ id: p.id, name: p.name }); });
|
||||
} else {
|
||||
// Fallback before progression loads: show the known instrument cats
|
||||
// that actually have registered items.
|
||||
INSTRUMENTS.forEach(function (i) {
|
||||
if (Object.keys(registered).some(function (id) { return registered[id].category === i; })) {
|
||||
cats.push({ id: i, name: i.charAt(0).toUpperCase() + i.slice(1) });
|
||||
}
|
||||
});
|
||||
}
|
||||
return cats;
|
||||
}
|
||||
|
||||
function itemsForCategory(catId) {
|
||||
return Object.keys(registered).map(function (id) { return registered[id]; })
|
||||
.filter(function (d) { return (d.category || 'global') === catId; })
|
||||
// Hide un-earned secret items (revealed only once earned).
|
||||
.filter(function (d) { return !d.secret || earned[d.id]; });
|
||||
}
|
||||
|
||||
function renderCatalog() {
|
||||
var mount = document.getElementById('v3-profile-achievements-mount');
|
||||
if (!mount) return;
|
||||
// Hide the core empty-state note now that we own this mount.
|
||||
var emptyNote = document.querySelector('[data-empty-for="v3-profile-achievements-mount"]');
|
||||
if (emptyNote) emptyNote.style.display = 'none';
|
||||
|
||||
var cats = categoriesForDisplay();
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem(CAT_KEY); } catch (_) { /* noop */ }
|
||||
// Default to the player's primary path (first path), fallback Global.
|
||||
var primary = (progState() && progState().paths && progState().paths[0] && progState().paths[0].id) || 'global';
|
||||
var active = cats.some(function (c) { return c.id === saved; }) ? saved
|
||||
: (cats.some(function (c) { return c.id === primary; }) ? primary : 'global');
|
||||
|
||||
var pills = cats.map(function (c) {
|
||||
var items = itemsForCategory(c.id);
|
||||
var got = items.filter(function (d) { return earned[d.id]; }).length;
|
||||
return '<button type="button" class="fb-ach-pill' + (c.id === active ? ' active' : '') +
|
||||
'" data-cat="' + esc(c.id) + '">' + esc(c.name) +
|
||||
' <span class="fb-ach-pill-badge">' + got + '/' + items.length + '</span></button>';
|
||||
}).join('');
|
||||
|
||||
var items = itemsForCategory(active);
|
||||
var list = items.length ? items.map(function (d) {
|
||||
var got = !!earned[d.id];
|
||||
var tier = got ? (earned[d.id].tier || 0) : -1;
|
||||
return '<div class="fb-ach-item' + (got ? ' earned' : ' locked') + '">' +
|
||||
'<div class="fb-ach-item-icon">' + (got ? '🏅' : '🔒') + '</div>' +
|
||||
'<div class="fb-ach-item-body">' +
|
||||
'<div class="fb-ach-item-title">' + esc(d.title) +
|
||||
(got && tier > 0 ? ' <span class="fb-ach-tier">tier ' + (tier + 1) + '</span>' : '') + '</div>' +
|
||||
'<div class="fb-ach-item-desc">' + esc(d.description) + '</div>' +
|
||||
'</div></div>';
|
||||
}).join('') : '<p class="fb-tabpanel-empty">No achievements in this category yet.</p>';
|
||||
|
||||
mount.innerHTML =
|
||||
'<div class="fb-ach-pillrow">' + pills + '</div>' +
|
||||
'<div class="fb-ach-list">' + list + '</div>';
|
||||
mount.querySelectorAll('[data-cat]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
try { localStorage.setItem(CAT_KEY, b.dataset.cat); } catch (_) { /* noop */ }
|
||||
renderCatalog();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fetchFeatsAndRender() {
|
||||
return fetchJSON('/feats').then(function (data) { renderFeats((data && data.feats) || []); });
|
||||
}
|
||||
|
||||
function renderFeats(feats) {
|
||||
var slot = document.getElementById('v3-profile-feats-slot');
|
||||
if (!slot) return;
|
||||
if (!feats.length) { slot.innerHTML = ''; return; } // hidden-until-earned
|
||||
var cards = feats.map(function (f) {
|
||||
return '<div class="fb-feat-card" title="' + esc(f.description) + '">' +
|
||||
'<div class="fb-feat-icon">🏆</div>' +
|
||||
'<div class="fb-feat-title">' + esc(f.title) + '</div>' +
|
||||
'<div class="fb-feat-desc">' + esc(f.description) + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
// Opt-out users get a subtle wall hint linking to Settings (PR2 wires it).
|
||||
var hint = '';
|
||||
slot.innerHTML =
|
||||
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
|
||||
'<h3 class="text-lg font-bold text-fb-text mb-3">Feats of Power</h3>' +
|
||||
'<div class="fb-feat-shelf">' + cards + '</div>' + hint +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||
function init() {
|
||||
fetchJSON('/catalog').then(function (data) {
|
||||
baseline = (data && data.baseline) || {};
|
||||
((data && data.earned) && (earned = {}, Object.keys(data.earned).forEach(function (id) { earned[id] = data.earned[id]; })));
|
||||
expandBaseline();
|
||||
evaluateBaseline();
|
||||
scheduleRender();
|
||||
});
|
||||
// Re-inject on every profile entry (core wipes the mounts each render).
|
||||
document.addEventListener('v3:profile-rendered', function () { renderAll(); });
|
||||
|
||||
// Competency events → re-expand (paths may have appeared) + re-evaluate.
|
||||
['progression:updated', 'progression:rank-changed', 'progression:path-level-up'].forEach(function (ev) {
|
||||
bus.on && bus.on(ev, function () { expandBaseline(); evaluateBaseline(); });
|
||||
});
|
||||
// A real competency advance ticks the growth-streak day ledger.
|
||||
['progression:rank-changed', 'progression:path-level-up', 'progression:challenge-completed'].forEach(function (ev) {
|
||||
bus.on && bus.on(ev, function () { recordGrowthDay(); });
|
||||
});
|
||||
// challenger:<inst> — clearing a full level-up set for a path.
|
||||
bus.on && bus.on('progression:path-level-up', function (e) {
|
||||
var pid = e && e.detail && (e.detail.path_id || e.detail.id);
|
||||
if (pid) baselineUnlock('challenger:' + pid, pid, 0);
|
||||
});
|
||||
|
||||
// Activity (Feats) — in-memory counters, flushed once per song.
|
||||
bus.on && bus.on('song:loading', function (e) {
|
||||
resetSong(e && e.detail && e.detail.filename);
|
||||
});
|
||||
bus.on && bus.on('note:hit', function () {
|
||||
if (!song.active) return; // ignore tuner/calibration note events
|
||||
song.hits++; song.streak++; session.notesTotal++;
|
||||
if (song.streak > song.maxStreak) song.maxStreak = song.streak;
|
||||
});
|
||||
bus.on && bus.on('note:miss', function () { if (song.active) song.streak = 0; });
|
||||
bus.on && bus.on('song:ended', function (e) {
|
||||
flushActivity(e && e.detail && (e.detail.time || e.detail.audioT));
|
||||
});
|
||||
// Song stopped/abandoned without a natural end → mark inactive so stray
|
||||
// note events after it don't accrue against a phantom (chart:null) song.
|
||||
bus.on && bus.on('song:stop', function () { song.active = false; });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
<!-- Achievements plugin — Privacy panel (mounts under the Settings "System" tab
|
||||
via settings.category). Owns the wall opt-in toggle (bound to the core
|
||||
`achievements_enabled` setting) + the self-serve "Remove me from the wall"
|
||||
action. Default OFF — nothing publishes until the user opts in. -->
|
||||
<div class="text-sm text-gray-300 space-y-4" data-ach-privacy>
|
||||
<div>
|
||||
<p>Your <strong>Achievements</strong> and <strong>Feats of Power</strong> live on your
|
||||
<em>Profile</em> page and are local & private by default.</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="setting-achievements-enabled"
|
||||
class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-sky-500 focus:ring-sky-500">
|
||||
<span>
|
||||
<span class="font-medium text-gray-200">Share my Feats of Power on the public wall</span>
|
||||
<span class="block text-gray-400">Publishes only your display name and the rare
|
||||
<strong>Feats</strong> you earn (activity milestones) — never songs, skills, or scores.
|
||||
You can turn this off and remove yourself at any time.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<button type="button" id="ach-remove-me"
|
||||
class="px-3 py-1.5 rounded-md text-sm border border-red-500/40 text-red-300 hover:bg-red-500/10 transition">
|
||||
Remove me from the wall
|
||||
</button>
|
||||
<span id="ach-remove-status" class="ml-2 text-xs text-gray-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.currentScript && document.currentScript.previousElementSibling;
|
||||
// The panel re-injects on each Settings entry; bind once per element.
|
||||
var toggle = document.getElementById('setting-achievements-enabled');
|
||||
if (!toggle || toggle.dataset.wired === '1') return;
|
||||
toggle.dataset.wired = '1';
|
||||
|
||||
// Hydrate from the authoritative server setting.
|
||||
fetch('/api/settings').then(function (r) { return r.ok ? r.json() : {}; }).then(function (d) {
|
||||
toggle.checked = d && d.achievements_enabled === true;
|
||||
}).catch(function () { /* offline — leave unchecked */ });
|
||||
|
||||
toggle.addEventListener('change', function () {
|
||||
var on = !!toggle.checked;
|
||||
try { localStorage.setItem('achievementsEnabled', on ? '1' : '0'); } catch (_) {}
|
||||
fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ achievements_enabled: on }),
|
||||
}).catch(function () { /* best-effort; revert on failure */ });
|
||||
});
|
||||
|
||||
var removeBtn = document.getElementById('ach-remove-me');
|
||||
var status = document.getElementById('ach-remove-status');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', function () {
|
||||
removeBtn.disabled = true;
|
||||
if (status) status.textContent = 'Removing…';
|
||||
fetch('/api/plugins/achievements/remove-me', { method: 'POST' })
|
||||
.then(function (r) {
|
||||
if (status) status.textContent = r.ok ? 'Removed. Your Feats stay on your Profile.' : 'Could not reach the server — try again.';
|
||||
})
|
||||
.catch(function () { if (status) status.textContent = 'Offline — queued; it will sync when you reconnect.'; })
|
||||
.finally(function () { removeBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -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).
|
||||
@@ -0,0 +1,100 @@
|
||||
# Folder Library — FeedBack Plugin
|
||||
|
||||

|
||||

|
||||
|
||||
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 — album art cards with title and artist*
|
||||
|
||||

|
||||
*Live search filters instantly across all folders*
|
||||
|
||||

|
||||
*List view — compact rows with album art thumbnails and duration*
|
||||
|
||||

|
||||
*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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
@@ -108,9 +108,19 @@ Each entry names the function or banner you should grep for, plus key sub-blocks
|
||||
- **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats.
|
||||
- **Section labels** → `update()`, `// ── Section labels ──` block. Cyan (`#00cccc`) sprite at fret 12, above the highest string.
|
||||
|
||||
### Scene colors (two independent axes: Background + Highway)
|
||||
- **Scene-color themes** → `BG_THEMES` table near the top of `createFactory()`. One combined table is the single source of truth, but it drives **two independent axes that share the same id-set**:
|
||||
- **Background axis** — setting key `bgTheme`, setter `window.h3dBgSetBgTheme`, state `bgThemeId`. Owns `clear` (WebGL clear color) + `fog`.
|
||||
- **Highway axis** — setting key `hwTheme`, setter `window.h3dBgSetHwTheme`, state `hwThemeId`. Owns `board` (fretboard/highway-surface plane) + optional `lane`/`laneDim` (the lit lane strip).
|
||||
Any background id can mix with any highway id; picking the same id in both gives the original "matched" look. Per-axis accessors are `_bgBackgroundColors(id)` / `_bgHighwayColors(id)` (both alias `_bgThemeColors`). Both axes default to `'default'` (byte-identical to the original look).
|
||||
- **Applying a theme** → `_applyBgTheme()`. Background half sets clear+fog from `bgThemeId` (skipped under the venue scene); highway half sets the board plane + lane materials (`mLaneOdd`/`mLaneEven`) from `hwThemeId`. Re-run on init, `buildBoard()`, and the settings listener (which fires for **both** `bgTheme` and `hwTheme`), so changing either dropdown retints only its half live.
|
||||
- **Backward-compat migration** → `_bgLoadSettings()`: the first time it loads with no stored `hwTheme` (`_bgHasStored` false), it seeds `hwThemeId` from `bgThemeId` **and persists `hwTheme` once** (a one-time backfill, written without `_bgEmitChange`). So a pre-split single-`bgTheme` pick is byte-identical right after the upgrade, and from then on the two axes are fully independent — changing the Background dropdown never drags the Highway surface, and the settings UI's Highway value can't disagree with what's rendered. settings.html shows the same first-load value via `storedHwTheme == null ? bgTheme : coerceHwTheme(...)`.
|
||||
- **Adding/removing a theme** → edit `BG_THEMES` (the colors) AND `settings.html`'s `SCENE_THEMES` array (the `{id,label}` list — the single source the two dropdowns' `<option>`s and the `VALID_BG_THEMES` validator are both generated from). Keep the two id-sets aligned.
|
||||
|
||||
### Highway lane (the highlighted strip under active frets)
|
||||
- **Lane drawing** → `update()`, `// ── Dynamic highway lane ──` block. `pLane` is a single quad on the fretboard plane; `pLaneDivider` is thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets.
|
||||
- **Lane intensity** → `highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it). `_laneTargetColor = 0x4488ff` (set in `initScene()`) is the "lit" color, blended toward from `0x112233`.
|
||||
- **Lane intensity** → `highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it).
|
||||
- **Lane color** → the lit quad color is `mLaneOdd.color` (stock `HWY_LANE_STRIPE_ODD_HEX = 0x103B5C`), the dimmer alternating row `mLaneEven.color` (`HWY_LANE_STRIPE_EVEN_HEX = 0x08283C`). These are now **theme-aware**: `_applyBgTheme()` recolors them from the active HIGHWAY theme's optional `lane`/`laneDim` fields, falling back to the stock hexes when a highway theme omits them. (`_laneTargetColor`, set in `initScene()`, is kept in sync with the lit color but has no live consumer today.)
|
||||
|
||||
### Lyrics & overlays
|
||||
- **Lyrics overlay** → `drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim).
|
||||
@@ -201,7 +211,7 @@ The eight-color palette `S_COL` is the single source of truth for per-string col
|
||||
|
||||
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
|
||||
|
||||
Non-string colors (lane target `0x4488ff`, fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
|
||||
Non-string colors (the stock lane hexes `HWY_LANE_STRIPE_ODD_HEX`/`_EVEN_HEX` — now overridable per Highway theme, see "Scene colors" above; fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
|
||||
|
||||
## Tweaking text-sprite styling
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
3D Highway plugin — third-party notices
|
||||
=======================================
|
||||
|
||||
The "Butterchurn (visualizer)" background style (the 'butterchurn' option in
|
||||
the plugin's Background-style setting) bundles the third-party components below
|
||||
under plugins/highway_3d/assets/vendor/. They are lazy-loaded only when a user
|
||||
selects that background style.
|
||||
|
||||
The 3D Highway plugin and the feedBack project are licensed under the GNU Affero
|
||||
General Public License v3.0 (AGPL-3.0-only). The components below are under their
|
||||
own permissive (MIT) licenses, which are compatible with AGPL-3.0.
|
||||
|
||||
- Butterchurn — a WebGL implementation of the MilkDrop visualizer.
|
||||
https://github.com/jberg/butterchurn
|
||||
Copyright (c) Jordan Berg. MIT License.
|
||||
Vendored at: plugins/highway_3d/assets/vendor/butterchurn.min.js
|
||||
|
||||
- butterchurn-presets — MilkDrop preset pack.
|
||||
https://github.com/jberg/butterchurn-presets
|
||||
Presets authored by the MilkDrop community (Geiss, Flexi, Martin, Rovastar,
|
||||
Zylot, Aderrasi, Cope, Eo.S., Unchained, and many others); refer to each
|
||||
preset's embedded attribution.
|
||||
Vendored at: plugins/highway_3d/assets/vendor/butterchurnPresets.min.js
|
||||
|
||||
- Three.js (r170) — loaded at runtime from feedBack core's vendored copy
|
||||
(/static/vendor/three/three.module.min.js). https://threejs.org MIT License.
|
||||
|
||||
Provenance: the Butterchurn background integration originated as the standalone
|
||||
"3D Highway + Butterchurn" plugin and was merged into the bundled 3D Highway
|
||||
renderer as the opt-in 'butterchurn' background style.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Slopsmith visualizer — audio feed AudioWorkletProcessor.
|
||||
* Generates Butterchurn's drive signal off the main thread (replaces the
|
||||
* deprecated ScriptProcessor): guitar PCM (gained) + a bass-band song-energy
|
||||
* pulse + a mid-band chart-accent pulse. The main thread pushes the latest
|
||||
* guitar frame and the song/chart/gain scalars via port messages.
|
||||
*/
|
||||
class VizFeedProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.frame = null; // latest guitar PCM (Float32Array)
|
||||
this.readIdx = 0;
|
||||
this.song = 0;
|
||||
this.chart = 0;
|
||||
this.gain = 6;
|
||||
this.phase = 0;
|
||||
this.phase2 = 0;
|
||||
this.osc = (2 * Math.PI * 90) / sampleRate; // ~90 Hz → bass band (song)
|
||||
this.osc2 = (2 * Math.PI * 520) / sampleRate; // ~520 Hz → mid band (chart)
|
||||
this.port.onmessage = (e) => {
|
||||
const d = e.data;
|
||||
if (!d) return;
|
||||
if (d.frame) { this.frame = d.frame; this.readIdx = 0; }
|
||||
if (typeof d.song === 'number') this.song = d.song;
|
||||
if (typeof d.chart === 'number') this.chart = d.chart;
|
||||
if (typeof d.gain === 'number') this.gain = d.gain;
|
||||
};
|
||||
}
|
||||
process(inputs, outputs) {
|
||||
const out = outputs[0] && outputs[0][0];
|
||||
if (!out) return true;
|
||||
const f = this.frame, fl = f ? f.length : 0;
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const g = (fl ? f[this.readIdx % fl] : 0) * this.gain;
|
||||
this.readIdx++;
|
||||
const song = this.song * (0.7 * Math.sin(this.phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
|
||||
const chart = this.chart * (0.5 * Math.sin(this.phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
|
||||
this.phase += this.osc; if (this.phase > TWO_PI) this.phase -= TWO_PI;
|
||||
this.phase2 += this.osc2; if (this.phase2 > TWO_PI) this.phase2 -= TWO_PI;
|
||||
const v = g + song + chart;
|
||||
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('viz-feed', VizFeedProcessor);
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.26.0",
|
||||
"version": "3.30.2",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
"styles": "assets/plugin.css",
|
||||
"settings": { "html": "settings.html", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
|
||||
"settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
|
||||
"routes": "routes.py",
|
||||
"tour": "tour.json"
|
||||
}
|
||||
|
||||
+1203
-16
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,15 @@
|
||||
<button type="button" onclick="window.hwcSaveTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Save as…</button>
|
||||
<button type="button" onclick="window.hwcDeleteTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-400">Delete</button>
|
||||
</div>
|
||||
<!-- One-click presets: apply a whole named palette to every string at once.
|
||||
Buttons are generated from window.feedBack.highwayColors.presets so the
|
||||
list stays in sync with core (app.js HWC_PRESETS). Each calls
|
||||
applyPreset(id), which persists + applies to both highways and refreshes
|
||||
the pickers below. Older core without the presets API leaves this empty. -->
|
||||
<div class="mb-3">
|
||||
<span class="text-xs text-gray-500 mb-1 block">Quick presets</span>
|
||||
<div id="hwc-presets" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<div id="hwc-pickers" class="mb-3" style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;"></div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button type="button" onclick="window.hwcReset?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Reset to defaults</button>
|
||||
@@ -36,6 +45,33 @@
|
||||
if (typeof window.hwcInitSettingsUI === 'function') {
|
||||
try { window.hwcInitSettingsUI(); } catch (e) { console.warn('[3D-Hwy] hwcInitSettingsUI failed', e); }
|
||||
}
|
||||
// Render the one-click preset buttons from core's preset list.
|
||||
try {
|
||||
const api = window.feedBack && window.feedBack.highwayColors;
|
||||
const host = document.getElementById('hwc-presets');
|
||||
if (api && Array.isArray(api.presets) && host) {
|
||||
host.innerHTML = '';
|
||||
// Slot order low → high for the preview swatch (bass-side first).
|
||||
const order = ['lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
for (const p of api.presets) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'flex items-center gap-2 bg-dark-700 border border-gray-800 rounded-lg px-2 py-1 text-xs text-gray-300';
|
||||
const sw = document.createElement('span');
|
||||
const stops = order.map((k) => p.colors[k]).filter(Boolean).join(',');
|
||||
sw.style.cssText = 'width:2.5rem;height:0.85rem;border-radius:3px;border:1px solid #0006;'
|
||||
+ 'background:linear-gradient(90deg,' + stops + ');';
|
||||
btn.appendChild(sw);
|
||||
const txt = document.createElement('span');
|
||||
txt.textContent = p.label;
|
||||
btn.appendChild(txt);
|
||||
btn.addEventListener('click', function () {
|
||||
try { api.applyPreset(p.id); } catch (e) { console.warn('[3D-Hwy] applyPreset failed', e); }
|
||||
});
|
||||
host.appendChild(btn);
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('[3D-Hwy] preset render failed', e); }
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -88,11 +124,145 @@
|
||||
<option value="silhouettes">Silhouettes (parallax)</option>
|
||||
<option value="lights">Lights (stage glows)</option>
|
||||
<option value="geometric">Geometric (rotating shapes)</option>
|
||||
<option value="butterchurn">Butterchurn (visualizer)</option>
|
||||
<option value="image" disabled>Custom image (none uploaded)</option>
|
||||
<option value="video" disabled>Custom video (none uploaded)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Scene colors — TWO independent axes sharing one palette family:
|
||||
Background (clear + fog) and Highway (board surface + lit lane). Same
|
||||
id-set in both, so any background can mix with any highway; picking the
|
||||
same in both gives the original "matched" look. "Default" is the
|
||||
original look on both. -->
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bg-theme" class="text-xs font-medium text-gray-400 mb-1 block">Background</label>
|
||||
<!-- Options are populated from SCENE_THEMES (the single id+label source)
|
||||
in the hydration script below, so the two dropdowns can't drift. -->
|
||||
<select id="h3d-bg-theme"
|
||||
onchange="window.h3dBgSetBgTheme && window.h3dBgSetBgTheme(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
</select>
|
||||
<p class="text-[10px] text-gray-500 mt-1">
|
||||
Tints the background + distance fog. Applies to the 3D highway immediately.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="h3d-hw-theme" class="text-xs font-medium text-gray-400 mb-1 block">Highway (surface + lane)</label>
|
||||
<!-- Options populated from SCENE_THEMES (see below), same source as the
|
||||
Background dropdown so the two stay in lockstep. -->
|
||||
<select id="h3d-hw-theme"
|
||||
onchange="window.h3dBgSetHwTheme && window.h3dBgSetHwTheme(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
</select>
|
||||
<p class="text-[10px] text-gray-500 mt-1">
|
||||
Tints the fretboard surface + the lit lane. Applies to the 3D highway immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Butterchurn (visualizer) options. These drive the 'butterchurn'
|
||||
Background style. They used to live in a floating in-canvas panel; the
|
||||
on/off + slider controls now live here in the standard settings UI.
|
||||
The live preset browser (pick / favorite / ban / cycle) stays in-canvas.
|
||||
Persisted in the 'viz3d_settings' localStorage blob (shared with the
|
||||
in-canvas preset controls); window.h3dBcApplySettings() pushes changes
|
||||
to a mounted highway live. -->
|
||||
<div id="h3d-bc-section" class="mt-4">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">Butterchurn (visualizer)</h3>
|
||||
<p class="text-xs text-gray-500 mb-2">Active when Background style is set to <em>Butterchurn (visualizer)</em>.</p>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" id="h3d-bc-enabled" class="accent-accent"> Background on
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bc-opacity" class="text-xs font-medium text-gray-400 mb-1 block">Opacity: <span id="h3d-bc-opacity-label">100%</span></label>
|
||||
<input type="range" id="h3d-bc-opacity" min="0" max="100" class="w-full accent-accent">
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" id="h3d-bc-laneDim" class="accent-accent"> Dim behind lane
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bc-laneDimStrength" class="text-xs font-medium text-gray-400 mb-1 block">Dim strength</label>
|
||||
<input type="range" id="h3d-bc-laneDimStrength" min="0" max="100" class="w-full accent-accent">
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" id="h3d-bc-chartAccents" class="accent-accent"> Chart accents
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bc-chartStrength" class="text-xs font-medium text-gray-400 mb-1 block">Chart strength</label>
|
||||
<input type="range" id="h3d-bc-chartStrength" min="0" max="200" class="w-full accent-accent">
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" id="h3d-bc-colorTint" class="accent-accent"> Color tint
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bc-tintStrength" class="text-xs font-medium text-gray-400 mb-1 block">Tint strength</label>
|
||||
<input type="range" id="h3d-bc-tintStrength" min="0" max="100" class="w-full accent-accent">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bc-guitarGain" class="text-xs font-medium text-gray-400 mb-1 block">Guitar gain: <span id="h3d-bc-guitarGain-label">×6</span></label>
|
||||
<input type="range" id="h3d-bc-guitarGain" min="1" max="12" step="0.5" class="w-full accent-accent">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bc-songGain" class="text-xs font-medium text-gray-400 mb-1 block">Song gain: <span id="h3d-bc-songGain-label">×1.8</span></label>
|
||||
<input type="range" id="h3d-bc-songGain" min="0" max="5" step="0.1" class="w-full accent-accent">
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Self-contained Butterchurn settings wiring. Reads/writes the same
|
||||
// 'viz3d_settings' blob the in-canvas preset controls use (read-modify-
|
||||
// write preserves cyclePool/hold and the favorites/bans lists), then
|
||||
// calls window.h3dBcApplySettings() so a mounted highway applies the
|
||||
// change live. Guarded so it no-ops if the section isn't present.
|
||||
(function () {
|
||||
const BC_LS = 'viz3d_settings';
|
||||
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8 };
|
||||
function readBlob() { let s = {}; try { s = JSON.parse(localStorage.getItem(BC_LS) || '{}') || {}; } catch (e) {} return s; }
|
||||
function patch(p) {
|
||||
const s = readBlob();
|
||||
Object.assign(s, p);
|
||||
try { localStorage.setItem(BC_LS, JSON.stringify(s)); } catch (e) {}
|
||||
try { if (typeof window.h3dBcApplySettings === 'function') window.h3dBcApplySettings(); } catch (e) {}
|
||||
}
|
||||
const s = Object.assign({}, BC_DEFAULTS, readBlob());
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const en = $('h3d-bc-enabled');
|
||||
if (!en) return;
|
||||
const op = $('h3d-bc-opacity'), opv = $('h3d-bc-opacity-label');
|
||||
const ld = $('h3d-bc-laneDim'), lds = $('h3d-bc-laneDimStrength');
|
||||
const ca = $('h3d-bc-chartAccents'), cs = $('h3d-bc-chartStrength');
|
||||
const ct = $('h3d-bc-colorTint'), ts = $('h3d-bc-tintStrength');
|
||||
const gg = $('h3d-bc-guitarGain'), ggv = $('h3d-bc-guitarGain-label');
|
||||
const sg = $('h3d-bc-songGain'), sgv = $('h3d-bc-songGain-label');
|
||||
// Hydrate from saved settings.
|
||||
en.checked = !!s.enabled;
|
||||
op.value = Math.round((s.opacity != null ? s.opacity : 1) * 100); if (opv) opv.textContent = op.value + '%';
|
||||
ld.checked = !!s.laneDim; lds.value = Math.round((s.laneDimStrength != null ? s.laneDimStrength : 0.45) * 100);
|
||||
ca.checked = !!s.chartAccents; cs.value = Math.round((s.chartStrength != null ? s.chartStrength : 1) * 100);
|
||||
ct.checked = !!s.colorTint; ts.value = Math.round((s.tintStrength != null ? s.tintStrength : 0.65) * 100);
|
||||
gg.value = s.guitarGain != null ? s.guitarGain : 6; if (ggv) ggv.textContent = '×' + gg.value;
|
||||
sg.value = s.songGain != null ? s.songGain : 1.8; if (sgv) sgv.textContent = '×' + sg.value;
|
||||
// Wire changes (read-modify-write + live apply).
|
||||
en.addEventListener('change', () => patch({ enabled: en.checked }));
|
||||
op.addEventListener('input', () => { if (opv) opv.textContent = op.value + '%'; patch({ opacity: op.value / 100 }); });
|
||||
ld.addEventListener('change', () => patch({ laneDim: ld.checked }));
|
||||
lds.addEventListener('input', () => patch({ laneDimStrength: lds.value / 100 }));
|
||||
ca.addEventListener('change', () => patch({ chartAccents: ca.checked }));
|
||||
cs.addEventListener('input', () => patch({ chartStrength: cs.value / 100 }));
|
||||
ct.addEventListener('change', () => patch({ colorTint: ct.checked }));
|
||||
ts.addEventListener('input', () => patch({ tintStrength: ts.value / 100 }));
|
||||
gg.addEventListener('input', () => { if (ggv) ggv.textContent = '×' + gg.value; patch({ guitarGain: parseFloat(gg.value) }); });
|
||||
sg.addEventListener('input', () => { if (sgv) sgv.textContent = '×' + sg.value; patch({ songGain: parseFloat(sg.value) }); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Intensity -->
|
||||
<div class="mt-3">
|
||||
<label for="h3d-bg-intensity" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
@@ -407,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>
|
||||
@@ -712,8 +956,27 @@
|
||||
no-selection / NaN state. -->
|
||||
<script>
|
||||
(function () {
|
||||
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: '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 VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']);
|
||||
const 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 VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video']);
|
||||
// Scene color themes — the single id+label source for BOTH the
|
||||
// Background and Highway dropdowns AND the validator below. The
|
||||
// <option>s are generated from this list (see hydration), so the two
|
||||
// dropdowns can't drift from each other or from VALID_BG_THEMES.
|
||||
// Mirror these ids with screen.js BG_THEMES (the color table).
|
||||
const SCENE_THEMES = [
|
||||
{ id: 'default', label: 'Default (blue-black)' },
|
||||
{ id: 'midnight', label: 'Midnight (deep blue)' },
|
||||
{ id: 'charcoal', label: 'Charcoal (neutral gray)' },
|
||||
{ id: 'deeppurple', label: 'Deep purple' },
|
||||
{ id: 'forest', label: 'Forest (dark green)' },
|
||||
{ id: 'warmslate', label: 'Warm Slate (espresso)' },
|
||||
{ id: 'deepfocus', label: 'Deep Focus (near-black)' },
|
||||
{ id: 'deepsea', label: 'Deep Sea (dark teal)' },
|
||||
{ id: 'cathode', label: 'Cathode (amber CRT)' },
|
||||
{ id: 'cathodegreen', label: 'Cathode Green (green CRT)' },
|
||||
{ id: 'hearth', label: 'Hearth (warm red)' },
|
||||
];
|
||||
const VALID_BG_THEMES = new Set(SCENE_THEMES.map((t) => t.id));
|
||||
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
|
||||
// Chord diagram is top-only (bl/br removed).
|
||||
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
|
||||
@@ -771,6 +1034,9 @@
|
||||
const VIDEO_UPLOAD_URL = '/api/plugins/highway_3d/files';
|
||||
|
||||
function coerceStyle(v) { return VALID_STYLES.has(v) ? v : DEFAULTS.style; }
|
||||
function coerceBgTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.bgTheme; }
|
||||
// Highway axis shares the same valid id-set as the background axis.
|
||||
function coerceHwTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.hwTheme; }
|
||||
function coerceCameraMode(v) {
|
||||
if (v === 'classic') v = 'steady';
|
||||
return VALID_CAMERA_MODES.has(v) ? v : DEFAULTS.cameraMode;
|
||||
@@ -812,7 +1078,7 @@
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let storedStyle = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
|
||||
let storedStyle = null, storedBgTheme = null, storedHwTheme = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
|
||||
let storedCameraSmoothing = null;
|
||||
let storedCameraMode = null;
|
||||
let storedCustomImageDataUrl = null, storedCustomImageName = null;
|
||||
@@ -848,6 +1114,8 @@
|
||||
let storedSlideArrowChainPreviewVisible = null;
|
||||
try {
|
||||
storedStyle = localStorage.getItem('h3d_bg_style');
|
||||
storedBgTheme = localStorage.getItem('h3d_bg_bgTheme');
|
||||
storedHwTheme = localStorage.getItem('h3d_bg_hwTheme');
|
||||
storedI = localStorage.getItem('h3d_bg_intensity');
|
||||
storedR = localStorage.getItem('h3d_bg_reactive');
|
||||
storedFretOnNote = localStorage.getItem('h3d_bg_showFretOnNote');
|
||||
@@ -889,6 +1157,15 @@
|
||||
} catch (_) { /* storage blocked */ }
|
||||
|
||||
const style = coerceStyle(storedStyle);
|
||||
const bgTheme = coerceBgTheme(storedBgTheme);
|
||||
// Highway axis. BACKWARD-COMPAT: when hwTheme was never written
|
||||
// (pre-split installs, or the panel opened before the highway has
|
||||
// mounted and run its one-time backfill), show the background pick —
|
||||
// exactly the value screen.js will persist on first load — so the
|
||||
// dropdown matches what's rendered. Once hwTheme is stored (which the
|
||||
// renderer's backfill or any explicit pick does) it reads
|
||||
// independently and the two axes no longer track each other.
|
||||
const hwTheme = (storedHwTheme == null) ? bgTheme : coerceHwTheme(storedHwTheme);
|
||||
const intensity = coerceIntensity(storedI);
|
||||
const reactive = coerceBool(storedR, DEFAULTS.reactive);
|
||||
const showFretOnNote = coerceBool(storedFretOnNote, DEFAULTS.showFretOnNote);
|
||||
@@ -959,6 +1236,20 @@
|
||||
const customVideoName = (typeof storedCustomVideoName === 'string') ? storedCustomVideoName : DEFAULTS.customVideoName;
|
||||
|
||||
const sel = document.getElementById('h3d-bg-style');
|
||||
const bgThemeSel = document.getElementById('h3d-bg-theme');
|
||||
const hwThemeSel = document.getElementById('h3d-hw-theme');
|
||||
// Populate both scene-theme dropdowns from the single SCENE_THEMES
|
||||
// source so they can't drift. (Values are selected further below.)
|
||||
for (const selEl of [bgThemeSel, hwThemeSel]) {
|
||||
if (!selEl) continue;
|
||||
selEl.innerHTML = '';
|
||||
for (const t of SCENE_THEMES) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = t.label;
|
||||
selEl.appendChild(opt);
|
||||
}
|
||||
}
|
||||
const sli = document.getElementById('h3d-bg-intensity');
|
||||
const lbl = document.getElementById('h3d-bg-intensity-label');
|
||||
const rea = document.getElementById('h3d-bg-reactive');
|
||||
@@ -1082,6 +1373,35 @@
|
||||
if (thsi) thsi.value = String(toneHudSize);
|
||||
if (thslbl) thslbl.textContent = toneHudSize.toFixed(2);
|
||||
|
||||
// Hit-feedback "juice" controls — hydrate from saved state so the
|
||||
// panel reflects persistence on reopen (the renderer already reads
|
||||
// these via _bgReadSetting; without this the controls always showed
|
||||
// their default markup, misrepresenting a saved non-default). Reads
|
||||
// h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
|
||||
// hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' rules.
|
||||
try {
|
||||
const _bgBool = (k, def) => {
|
||||
const v = localStorage.getItem('h3d_bg_' + k);
|
||||
return v == null ? def : !(v === 'false' || v === '0');
|
||||
};
|
||||
const _setChk = (id, on) => { const el = document.getElementById(id); if (el) el.checked = on; };
|
||||
_setChk('h3d-sparks', _bgBool('sparks', true));
|
||||
_setChk('h3d-cinematic', _bgBool('cinematic', true));
|
||||
_setChk('h3d-streakfx', _bgBool('streakFx', true));
|
||||
_setChk('h3d-verdictmarks', _bgBool('verdictMarks', true));
|
||||
_setChk('h3d-bloom', _bgBool('bloom', true));
|
||||
_setChk('h3d-timingfx', _bgBool('timingFx', true));
|
||||
const _hf = document.getElementById('h3d-hitfx');
|
||||
if (_hf) {
|
||||
let v = parseFloat(localStorage.getItem('h3d_bg_hitFx'));
|
||||
if (!isFinite(v)) v = 0.70;
|
||||
v = Math.max(0, Math.min(1, v));
|
||||
_hf.value = String(v);
|
||||
const _hfl = document.getElementById('h3d-hitfx-label');
|
||||
if (_hfl) _hfl.textContent = v.toFixed(2);
|
||||
}
|
||||
} catch (_) { /* storage blocked — controls keep their default markup */ }
|
||||
|
||||
// (3D Highway palette picker removed — string colors are now set
|
||||
// via the core "Highway String Colors" UI above, which drives both
|
||||
// the 2D and 3D highways. The bg-settings 'palette' key still exists
|
||||
@@ -1200,6 +1520,8 @@
|
||||
sel.value = style;
|
||||
}
|
||||
}
|
||||
if (bgThemeSel) bgThemeSel.value = bgTheme;
|
||||
if (hwThemeSel) hwThemeSel.value = hwTheme;
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function () {
|
||||
|
||||
@@ -51,15 +51,16 @@
|
||||
sources = sources.filter((s) => s
|
||||
&& !/midi/i.test(String(s.providerId || ''))
|
||||
&& !/^midi-input/i.test(String(s.label || '')));
|
||||
// De-dupe by display label — the desktop engine enumerates the same
|
||||
// device under several driver types, so the same name can repeat.
|
||||
const seen = new Set();
|
||||
sources = sources.filter((s) => {
|
||||
const key = String(s.label || '').toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
// No label de-dupe here. The audio-input capability already
|
||||
// collapses exact duplicates by logicalSourceKey
|
||||
// (_visibleInputSources), so nothing it returns shares a key. A
|
||||
// device that enumerates under several driver types (ASIO / Windows
|
||||
// Audio / DirectSound) has a DISTINCT key per type and is now
|
||||
// labelled with its driver type (e.g. "Focusrite (ASIO)") — each is
|
||||
// a real, separately-selectable input the user must be able to see.
|
||||
// The old bare-label collapse also kept whichever variant sorted
|
||||
// first, which could silently drop the one that was actually
|
||||
// `selected` below.
|
||||
const selected = sources.find((s) => s && s.selected) || null;
|
||||
return { sources, selected };
|
||||
} catch (_) { return { sources: [], selected: null }; }
|
||||
@@ -163,10 +164,25 @@
|
||||
|
||||
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
||||
if (hasDetector) {
|
||||
// Hide our own full-screen overlay while note_detect's
|
||||
// Calibration Wizard runs on top. That wizard goes
|
||||
// transparent (pointer-events:none) when it minimizes to
|
||||
// expose the Tuner; if our overlay stayed up it would show
|
||||
// through — covering the tuner with the still-mounted
|
||||
// "select your input" card and looking like a second
|
||||
// wizard at the input step. Restore it on done/cancel
|
||||
// (one of which always fires when that wizard closes).
|
||||
const ov = document.getElementById('input-setup-overlay');
|
||||
const prevDisplay = ov ? ov.style.display : '';
|
||||
if (ov) ov.style.display = 'none';
|
||||
const restore = () => {
|
||||
const o = document.getElementById('input-setup-overlay');
|
||||
if (o) o.style.display = prevDisplay;
|
||||
};
|
||||
window.noteDetect.launchCalibration({
|
||||
instrument: inst,
|
||||
onDone: () => advance(inst, true),
|
||||
onCancel: () => { /* stay on this panel; user can skip or retry */ },
|
||||
onDone: () => { restore(); advance(inst, true); },
|
||||
onCancel: () => { restore(); /* stay on this panel; user can skip or retry */ },
|
||||
});
|
||||
} else {
|
||||
advance(inst, true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "minigames",
|
||||
"name": "Minigames",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"screen": "screen.html",
|
||||
|
||||
@@ -961,7 +961,25 @@
|
||||
// Drain queue of plugins that loaded before us.
|
||||
(window.__feedBackMinigamesPending || []).forEach(register);
|
||||
window.__feedBackMinigamesPending = null;
|
||||
|
||||
// ── Back-compat for pre-rename minigame plugins ───────────────────────
|
||||
// Minigame plugins published before the slopsmith→feedBack rename (#537)
|
||||
// register against `window.slopsmithMinigames` and, when the SDK isn't up
|
||||
// yet, queue to `window.__slopsmithMinigamesPending`. The rename moved the
|
||||
// SDK to `window.feedBackMinigames` and only drains the feedBack queue, so
|
||||
// those plugins' specs — including community ones we don't control — get
|
||||
// stranded in the legacy queue and never register. Their FeedBarcade tiles
|
||||
// then render as dead "Loading…" placeholders that do nothing on click.
|
||||
// Alias the SDK under the old name and drain the legacy pending queue too.
|
||||
// register() is keyed on spec.id, so a plugin that queued under both names
|
||||
// still registers exactly once.
|
||||
window.slopsmithMinigames = sdk;
|
||||
(window.__slopsmithMinigamesPending || []).forEach(register);
|
||||
window.__slopsmithMinigamesPending = null;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('feedBack-minigames-ready'));
|
||||
// Legacy event name for any pre-rename listener still bound to it.
|
||||
window.dispatchEvent(new CustomEvent('slopsmith-minigames-ready'));
|
||||
|
||||
// ── Wire hub render to screen lifecycle ───────────────────────────────
|
||||
// FeedBack mounts plugin screens with id "plugin-<plugin_id>" and
|
||||
|
||||
@@ -34,17 +34,17 @@ This plugin adds a floating "Tuner" button to the FeedBack interface, providing
|
||||
## Installation
|
||||
|
||||
### Download a Release
|
||||
1. Download one of the [Releases](https://github.com/OmikronApex/feedBack-plugin-tuner/releases)
|
||||
1. Download one of the [Releases](https://github.com/OmikronApex/slopsmith-plugin-tuner/releases)
|
||||
2. Extract it to your plugins folder
|
||||
3. Restart FeedBack
|
||||
|
||||
### Update Manager
|
||||
The plugin is listed in the official plugin repository, so it can also be installed directly via the [Update Manager](https://github.com/masc0t/feedBack-update-manager)
|
||||
The plugin is listed in the official plugin repository, so it can also be installed directly via the [Update Manager](https://github.com/masc0t/slopsmith-update-manager)
|
||||
|
||||
### Git
|
||||
```bash
|
||||
cd /path/to/feedBack/plugins
|
||||
git clone https://github.com/OmikronApex/feedBack-plugin-tuner.git tuner
|
||||
git clone https://github.com/OmikronApex/slopsmith-plugin-tuner.git tuner
|
||||
# Restart FeedBack (or restart your docker container)
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "tuner",
|
||||
"name": "Guitar/Bass Tuner",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
var _TUNER_LABEL_H = 12; // px height of each drum label
|
||||
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
|
||||
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
|
||||
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
|
||||
var _TUNER_IN_TUNE_THRESHOLD = 2;
|
||||
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
|
||||
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
|
||||
@@ -321,9 +323,34 @@
|
||||
currentAngle += (targetAngle - currentAngle) * lf;
|
||||
_setNeedle(currentAngle);
|
||||
|
||||
// Stop once the needle has settled on its target — a static needle
|
||||
// needs no repaint. update() re-kicks the loop when a new reading
|
||||
// moves the target, so this idles the always-on tuner (no signal /
|
||||
// steady pitch) instead of pinning a core at 60 fps forever.
|
||||
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
|
||||
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
|
||||
currentDrumY = targetDrumY; currentAngle = targetAngle;
|
||||
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
||||
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
||||
_setNeedle(currentAngle);
|
||||
rafId = null;
|
||||
return;
|
||||
}
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
// Restart the loop only when there's actually something to animate toward
|
||||
// (a new target). Reset lastTime so the first frame after an idle gap
|
||||
// doesn't take one big easing step.
|
||||
function _kick() {
|
||||
if (rafId === null
|
||||
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|
||||
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
|
||||
lastTime = performance.now();
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
@@ -360,6 +387,7 @@
|
||||
bulbEl.style.backgroundColor = '#2a1010';
|
||||
bulbEl.style.border = '2px solid #4a2020';
|
||||
bulbEl.style.boxShadow = 'none';
|
||||
_kick(); // animate back to rest, then the loop self-stops
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -376,6 +404,7 @@
|
||||
bulbEl.style.border = '2px solid #4a2020';
|
||||
bulbEl.style.boxShadow = 'none';
|
||||
}
|
||||
_kick(); // a new reading moved the target → run until it settles
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -620,8 +620,20 @@
|
||||
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
|
||||
|
||||
_applyTickStates();
|
||||
|
||||
// No signal and both the glow and strobe drift have fully settled →
|
||||
// idle the loop. update() re-kicks it on the next note.
|
||||
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
|
||||
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
|
||||
_mt3RafId = null;
|
||||
_mt3LastTime = null;
|
||||
return;
|
||||
}
|
||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||
}
|
||||
function _kick() {
|
||||
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
|
||||
}
|
||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||
|
||||
// ── MODE button ───────────────────────────────────────────────
|
||||
@@ -677,6 +689,7 @@
|
||||
_renderNote(' ');
|
||||
_applyAccidental();
|
||||
}
|
||||
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
|
||||
}
|
||||
|
||||
// ── Public: destroy ───────────────────────────────────────────
|
||||
|
||||
@@ -354,10 +354,19 @@
|
||||
if (_smoothedCents > 0) { speed = -speed; }
|
||||
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
|
||||
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
|
||||
} else if (_currentCents === 0) {
|
||||
// Fully decelerated and no live signal → idle the loop instead of
|
||||
// rescheduling forever. update() re-kicks it on the next note.
|
||||
_rafId = null;
|
||||
_lastTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_rafId = requestAnimationFrame(_animateStrobe);
|
||||
}
|
||||
function _kick() {
|
||||
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
|
||||
}
|
||||
_rafId = requestAnimationFrame(_animateStrobe);
|
||||
|
||||
// ── Helper: derive octave number from frequency ───────────────
|
||||
@@ -439,6 +448,7 @@
|
||||
|
||||
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
|
||||
_currentCents = hasNote ? cents : 0;
|
||||
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
|
||||
}
|
||||
|
||||
// ── Public: destroy ───────────────────────────────────────────
|
||||
|
||||
@@ -142,9 +142,20 @@ window._tunerViz_strobe = function (container) {
|
||||
strobeEl.style.opacity = '0';
|
||||
}
|
||||
|
||||
// Idle the loop when there's no live signal — the strobe only needs to
|
||||
// paint while a note is sounding. update() re-kicks it on the next note,
|
||||
// so a silent tuner stops repainting instead of spinning at 60 fps.
|
||||
if (!strobeActive) { rafId = null; return; }
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
function _kick() {
|
||||
if (rafId === null) {
|
||||
lastAnimateTime = performance.now();
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────
|
||||
@@ -188,6 +199,7 @@ window._tunerViz_strobe = function (container) {
|
||||
const inTune = Math.abs(cents) < 5;
|
||||
strobeEl.style.opacity = inTune ? '1' : '0.6';
|
||||
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
|
||||
_kick();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -122,6 +122,26 @@
|
||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
||||
plungerEl.style.top = _topPct.toFixed(2) + '%';
|
||||
|
||||
// No live signal and the plunger has eased back to its resting centre
|
||||
// → idle the loop. update() re-kicks it on the next note.
|
||||
if (_currentNote === null && !_plungerDipped
|
||||
&& Math.abs(targetLeft - _leftPct) < 0.05) {
|
||||
_leftPct = targetLeft;
|
||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
||||
_rafId = null;
|
||||
_lastTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
function _kick() {
|
||||
if (_rafId !== null) return;
|
||||
// Already parked at rest with no signal → nothing to animate, stay idle.
|
||||
if (_currentNote === null && !_plungerDipped
|
||||
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
|
||||
_lastTime = null;
|
||||
_rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
@@ -130,6 +150,7 @@
|
||||
_currentNote = note;
|
||||
_currentCents = note === null ? 0 : cents;
|
||||
if (!_plungerDipped) { noteEl.textContent = note || '–'; }
|
||||
_kick(); // a new reading may move the plunger → ensure the loop runs
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
+1065
-53
File diff suppressed because it is too large
Load Diff
@@ -1766,6 +1766,16 @@
|
||||
const providerResult = _providerOutcome(raw);
|
||||
openSession.state = providerResult.outcome === 'handled' ? 'open' : (providerResult.status || providerResult.outcome);
|
||||
openSession.reason = providerResult.reason;
|
||||
// Read-back: the provider (desktop renderer) reports which device the
|
||||
// native engine ACTUALLY bound. Surface it to the in-process caller so
|
||||
// the wizard can show "Now listening to: <device>" and catch a silent
|
||||
// mismatch (picked BlackHole, got the internal mic). Kept OUT of the
|
||||
// redacted summary/event below: that flows into diagnostics, where a raw
|
||||
// device name (e.g. "Byron's AirPods") is PII — but it is fine to return
|
||||
// verbatim to the trusted same-renderer caller, exactly as list-sources
|
||||
// already returns the device `label` verbatim.
|
||||
const boundInfo = _plainObject(providerResult.payload);
|
||||
const bound = { type: _string(boundInfo.boundType, ''), name: _string(boundInfo.boundName, '') };
|
||||
if (providerResult.outcome === 'handled') {
|
||||
openSession.openedAt = _now();
|
||||
currentSession.openInputSessions.set(key, openSession);
|
||||
@@ -1773,7 +1783,7 @@
|
||||
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'handled', status: 'open' });
|
||||
capabilities.emitEvent('audio-input', 'source-opened', summary);
|
||||
_touch();
|
||||
return _handled(summary);
|
||||
return _handled((bound.name || bound.type) ? { ...summary, bound } : summary);
|
||||
}
|
||||
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
|
||||
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: providerResult.outcome, status: openSession.state, reason: providerResult.reason });
|
||||
|
||||
+28
-2
@@ -248,6 +248,7 @@ function createHighway() {
|
||||
let _frameMsEMA = 0; // smoothed frame interval (for the HUD)
|
||||
let _lastFramePerf = 0;
|
||||
let _lastAutoAdjustAt = 0;
|
||||
let _lastUpscaleAt = 0; // separate, longer clock for UPscaling (lazy)
|
||||
let _perfHud = null;
|
||||
let _hudOn = false; // cached highwayPerfHud flag (re-read ~2x/sec, not per-frame)
|
||||
let _hudFlagAt = 0;
|
||||
@@ -266,6 +267,11 @@ function createHighway() {
|
||||
return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN;
|
||||
})();
|
||||
const _AUTO_ADJUST_COOLDOWN_MS = 600;
|
||||
// Upscaling is deliberately LAZY (longer cooldown than the downscale path) so
|
||||
// the resolution doesn't visibly hunt up/down on passages that hover near the
|
||||
// budget — testers saw "quality going up and down" as parts got busier (#618
|
||||
// charrette). Downscale stays prompt to protect the frame rate.
|
||||
const _AUTO_UPSCALE_COOLDOWN_MS = 2500;
|
||||
let _inverted = localStorage.getItem('invertHighway') === 'true';
|
||||
let _lefty = localStorage.getItem('lefty') === '1';
|
||||
let _lastChordOnFretLine = null; // chord object currently shown on fret line
|
||||
@@ -1291,9 +1297,22 @@ function createHighway() {
|
||||
const eff = _effectiveRenderScale();
|
||||
let next = _autoScale;
|
||||
if (_drawMsEMA > _DRAW_BUDGET_HI_MS && eff > _autoScaleMin) {
|
||||
// Over budget — downscale promptly to protect the frame rate, and reset
|
||||
// the upscale clock so we don't immediately bounce back up.
|
||||
next = _autoScale * 0.85;
|
||||
} else if (_drawMsEMA < _DRAW_BUDGET_LO_MS && eff < 1) {
|
||||
next = _autoScale * 1.1;
|
||||
_lastUpscaleAt = nowP;
|
||||
} else if (_drawMsEMA < _DRAW_BUDGET_LO_MS && eff < 1
|
||||
&& nowP - _lastUpscaleAt >= _AUTO_UPSCALE_COOLDOWN_MS) {
|
||||
// Headroom — upscale LAZILY: a smaller step on a longer cooldown, and
|
||||
// only when the projected cost AFTER the step (cost scales ~with the
|
||||
// pixel count, i.e. step²) still clears the high budget. That predictive
|
||||
// guard is what stops the up→over-budget→down ping-pong testers saw: the
|
||||
// scale settles just inside the deadband instead of oscillating across it.
|
||||
const step = 1.06;
|
||||
if (_drawMsEMA * step * step < _DRAW_BUDGET_HI_MS) {
|
||||
next = _autoScale * step;
|
||||
_lastUpscaleAt = nowP;
|
||||
}
|
||||
}
|
||||
// Clamp so _renderScale * _autoScale stays within [_autoScaleMin, 1].
|
||||
// Cap `lo` at 1: when the floor exceeds the manual ceiling (e.g. quality
|
||||
@@ -3511,6 +3530,13 @@ function createHighway() {
|
||||
// matchesArrangement on this rather than the
|
||||
// arrangement name.
|
||||
hasNotation: Boolean(msg.has_notation),
|
||||
// Feedpak contributor credits (manifest
|
||||
// `authors:`, spec §5.4): [{name, role}].
|
||||
// Only real feedpak plays carry these; loose/
|
||||
// archive sources and synthetic highway uses
|
||||
// (minigames) get []. app.js shows a credits
|
||||
// overlay on song load when this is non-empty.
|
||||
authors: Array.isArray(msg.authors) ? msg.authors : [],
|
||||
};
|
||||
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
|
||||
}
|
||||
|
||||
+14
-1
@@ -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) ═════════════════════ -->
|
||||
@@ -578,6 +584,13 @@
|
||||
<option value="">Saved Loops</option>
|
||||
</select>
|
||||
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="px-2 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-500 hover:text-red-400 transition hidden" title="Delete selected loop">✕</button>
|
||||
<!-- Editor ⇄ 3D Highway round-trip. "Edit region" opens the Song Editor
|
||||
scrolled to the active loop (or the section at the playhead).
|
||||
"↩ Editor" returns to the editing position you came from; it only
|
||||
appears after a Loop-in-3D handoff. Both are hidden when the editor
|
||||
plugin isn't loaded (state managed by _updateEditRegionBtn). -->
|
||||
<button onclick="editRegionInEditor()" id="btn-edit-region" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
|
||||
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Return to the editor where you left off">↩ Editor</button>
|
||||
<button onclick="showScreen('home')" class="ml-auto px-3 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-400 hover:text-red-400 transition">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -863,3 +863,93 @@ html { scroll-behavior: smooth; }
|
||||
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Feedpak contributor credits shown over the highway when a song loads
|
||||
(manifest `authors:`, spec §5.4). Anchored to the upper third so it sits
|
||||
ABOVE the vertically-centered count-in number; click-through. */
|
||||
.song-credits-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 15%;
|
||||
/* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and
|
||||
the count-in number (z-[100]) so the credits stay prominent through the
|
||||
whole load → count-in → play window. */
|
||||
z-index: 205;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.song-credits-card {
|
||||
position: relative;
|
||||
min-width: 16rem;
|
||||
max-width: min(90vw, 34rem);
|
||||
padding: 1.4rem 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
background:
|
||||
radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%),
|
||||
linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%);
|
||||
border: 1px solid rgb(129 140 248 / 0.28);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 18px 50px rgb(0 0 0 / 0.55),
|
||||
0 0 0 1px rgb(0 0 0 / 0.35),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Accent bar across the top edge of the card. */
|
||||
.song-credits-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 3.25rem;
|
||||
height: 3px;
|
||||
border-radius: 0 0 3px 3px;
|
||||
background: linear-gradient(90deg, #38bdf8, #818cf8);
|
||||
box-shadow: 0 0 12px rgb(99 102 241 / 0.7);
|
||||
}
|
||||
|
||||
.song-credits-eyebrow {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(165 180 252 / 0.9);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.song-credits-heading {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.7rem;
|
||||
letter-spacing: 0.01em;
|
||||
text-shadow: 0 1px 8px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.song-credits-line {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.55;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.song-credits-role {
|
||||
color: rgb(148 163 184 / 0.95);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.song-credits-name {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@keyframes song-credits-fade-in {
|
||||
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+373
-156
@@ -343,76 +343,63 @@
|
||||
|
||||
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
|
||||
<div id="settings" class="screen">
|
||||
<div class="max-w-2xl mx-auto px-6 pt-24 pb-16">
|
||||
<button onclick="showScreen('home')" class="text-gray-500 hover:text-white text-sm mb-6 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Back
|
||||
<!-- fb-selectable: Settings is read-only content (paths, device names,
|
||||
version, diagnostics, About) the user copies — opt the whole panel
|
||||
back in under the v3 non-select default. See static/v3/v3.css. -->
|
||||
<div class="fb-settings fb-selectable">
|
||||
<button onclick="showScreen('home')" class="fb-settings-back">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Home
|
||||
</button>
|
||||
<h2 class="text-3xl font-bold text-white mb-8">Settings</h2>
|
||||
<h2 class="fb-settings-title">Settings</h2>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- App Updates — Velopack auto-update, desktop only. Stays
|
||||
hidden in the plain web app; setupAppUpdates() unhides
|
||||
this block when window.feedBackDesktop.update exists,
|
||||
and shows a disabled "not available on Linux" fallback
|
||||
when running on Linux. -->
|
||||
<div id="app-updates-block" class="hidden border border-gray-800 rounded-xl bg-dark-800/40 p-5">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">App Updates</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block" for="app-update-channel">Update channel</label>
|
||||
<select id="app-update-channel"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="stable">Stable</option>
|
||||
<option value="rc">Release candidate</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha</option>
|
||||
</select>
|
||||
<!-- Tab bar — click handling + active-tab persistence wired by
|
||||
static/v3/settings.js. data-tab keys match the .fb-tabpanel
|
||||
below; the fallback "Plugins" tab hosts uncategorized plugin
|
||||
panels (manifest settings.category routes the others). -->
|
||||
<div class="fb-tabbar" id="settings-tabbar">
|
||||
<button type="button" class="fb-tab" data-tab="gameplay">Gameplay</button>
|
||||
<button type="button" class="fb-tab" data-tab="audio">Audio</button>
|
||||
<button type="button" class="fb-tab" data-tab="graphics">Graphics</button>
|
||||
<button type="button" class="fb-tab" data-tab="keybinds">Keybinds</button>
|
||||
<button type="button" class="fb-tab" data-tab="progression">Progression</button>
|
||||
<button type="button" class="fb-tab" data-tab="mic">Mic</button>
|
||||
<button type="button" class="fb-tab" data-tab="plugins">Plugins</button>
|
||||
<button type="button" class="fb-tab" data-tab="system">System</button>
|
||||
</div>
|
||||
|
||||
<!-- ══ GAMEPLAY ════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="gameplay">
|
||||
<div class="fb-tabpanel-head">
|
||||
<h3>Gameplay Settings</h3>
|
||||
<button type="button" class="fb-reset-btn" data-reset="gameplay">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Reset Gameplay Settings
|
||||
</button>
|
||||
</div>
|
||||
<div class="fb-srows">
|
||||
<!-- Left-handed -->
|
||||
<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="M8 7h12m0 0l-4-4m4 4l-4 4M16 17H4m0 0l4 4m-4-4l4-4"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Left-handed mode</div>
|
||||
<div class="fb-srow-desc">Invert frets on the note highway for left-handed players.</div>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button id="app-update-check-now"
|
||||
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
|
||||
Check for updates
|
||||
</button>
|
||||
<div class="fb-srow-control">
|
||||
<label class="fb-switch">
|
||||
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)">
|
||||
<span class="fb-switch-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p id="app-update-status" class="text-xs text-gray-500 mt-3">Loading updater status…</p>
|
||||
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 mt-2">
|
||||
Auto-update is not available on Linux —
|
||||
<a href="https://github.com/got-feedback/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Core FeedBack settings ─────────────────────────────── -->
|
||||
<section>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">FeedBack</h3>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Library Folder Path</label>
|
||||
<div class="flex gap-3">
|
||||
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
|
||||
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
<!-- Default arrangement -->
|
||||
<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="M4 6h16M4 12h16M4 18h16"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Default arrangement</div>
|
||||
<div class="fb-srow-desc">Which arrangement loads first when you open a song.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)"
|
||||
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
|
||||
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
|
||||
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
|
||||
<span class="text-sm text-gray-300">Autoplay & auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
|
||||
<select id="default-arrangement"
|
||||
onchange="persistSetting('default_arrangement', this.value)"
|
||||
<div class="fb-srow-control">
|
||||
<select id="default-arrangement" onchange="persistSetting('default_arrangement', this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="">Most notes (auto)</option>
|
||||
<option value="Lead">Lead</option>
|
||||
@@ -420,112 +407,327 @@
|
||||
<option value="Bass">Bass</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Arrangement Names</label>
|
||||
<select id="arrangement-naming-mode"
|
||||
onchange="_onNamingModeChange(this.value)"
|
||||
</div>
|
||||
<!-- Arrangement routes (naming mode) -->
|
||||
<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="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Arrangement routes</div>
|
||||
<div class="fb-srow-desc">How arrangement variants are labelled across the app.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="arrangement-naming-mode" onchange="_onNamingModeChange(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="smart">Smart (Lead, Alt. Lead, Rhythm, Bass…)</option>
|
||||
<option value="legacy">Legacy (Combo, Bass)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="setting-av-offset" class="text-sm font-medium text-gray-400 mb-2 block">
|
||||
A/V Sync Offset: <span id="setting-av-offset-val">0</span> ms
|
||||
</label>
|
||||
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
|
||||
oninput="setAvOffsetMs(this.value)"
|
||||
class="w-full slider-input">
|
||||
<p class="text-xs text-gray-600 mt-1">Positive = audio plays ahead of visual notes; raise this value to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves on every change.</p>
|
||||
</div>
|
||||
<!-- A/V sync offset -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">A/V sync offset: <span id="setting-av-offset-val" style="margin-left:.35rem;">0</span> ms</div>
|
||||
<div class="fb-srow-desc">Positive = audio plays ahead of visual notes; raise to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="setting-live-guitar-tone-source" class="text-sm font-medium text-gray-400 mb-2 block">Live guitar tone source</label>
|
||||
<select id="setting-live-guitar-tone-source"
|
||||
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
|
||||
oninput="setAvOffsetMs(this.value)" class="fb-srow-wide slider-input">
|
||||
</div>
|
||||
<!-- Note highway speed (master difficulty) -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<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 10V3L4 14h7v7l9-11h-7z"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Note highway speed: <span id="setting-highway-speed-val" style="margin-left:.35rem;">100</span>%</div>
|
||||
<div class="fb-srow-desc">Master difficulty — lower simplifies the chart (fewer notes); 100% plays the full arrangement.</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="range" id="setting-highway-speed" min="0" max="100" step="5" value="100"
|
||||
oninput="setMastery(this.value)" class="fb-srow-wide slider-input">
|
||||
</div>
|
||||
<!-- Miss penalty (stub) -->
|
||||
<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="M12 9v2m0 4h.01M5.07 19H19a2 2 0 001.71-3L13.71 4a2 2 0 00-3.42 0L3.34 16a2 2 0 001.73 3z"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Miss penalty <span class="fb-stub-note">Not yet active</span></div>
|
||||
<div class="fb-srow-desc">How harshly missed notes are scored. Saved now; scoring wiring lands in a later release.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="setting-miss-penalty" onchange="persistSetting('miss_penalty', this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="internal">fee[dB]ack internal tone</option>
|
||||
<option value="external_hardware">External amp / hardware pedalboard</option>
|
||||
<option value="spark_control_x">Spark LIVE + Spark Control X</option>
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Standard</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-600 mt-1">Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. fee[dB]ack will still score your playing but won’t warn that no internal amp tone is loaded.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="demucs-server-url" class="text-sm font-medium text-gray-400 mb-2 block">Demucs Server (for stem separation)</label>
|
||||
<div class="flex gap-3">
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Optional. Run <a href="https://github.com/got-feedBack/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a machine with a GPU to offload stem splitting and avoid resource exhaustion on the host running FeedBack.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Library</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
|
||||
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
|
||||
<span id="rescan-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Backup</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
|
||||
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
|
||||
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
|
||||
<span id="backup-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</p>
|
||||
</div>
|
||||
<!-- ── Diagnostics (feedBack#166) ────────────────────── -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Diagnostics</label>
|
||||
<div class="grid grid-cols-2 gap-2 mb-3 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths & song names</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
|
||||
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
|
||||
<span id="diag-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default. Attach to GitHub issues; AI agents can parse the included <code>manifest.json</code>.</p>
|
||||
<div id="diag-preview" class="hidden mt-3 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto"></div>
|
||||
</div>
|
||||
<div id="settings-status" class="text-sm text-gray-500"></div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Countdown before song -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m-3 5a9 9 0 100-18 9 9 0 000 18z"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Countdown before song</div>
|
||||
<div class="fb-srow-desc">Play a four-beat count-in before a song starts so you can get ready.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<label class="fb-switch">
|
||||
<input type="checkbox" id="setting-countdown-before-song" onchange="setCountdownBeforeSong(this.checked)">
|
||||
<span class="fb-switch-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fail behavior (stub) -->
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Fail behavior <span class="fb-stub-note">Not yet active</span></div>
|
||||
<div class="fb-srow-desc">What happens when you fail a section. Saved now; gameplay wiring lands in a later release.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="setting-fail-behavior" onchange="persistSetting('fail_behavior', this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="continue">Continue playing</option>
|
||||
<option value="restart">Restart section</option>
|
||||
<option value="stop">Stop song</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Autoplay & auto-exit -->
|
||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Autoplay & auto-exit</div>
|
||||
<div class="fb-srow-desc">Start songs/lessons automatically and return to the menu when the score screen closes.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<label class="fb-switch">
|
||||
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)">
|
||||
<span class="fb-switch-track"></span>
|
||||
</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 “Up Next”</div>
|
||||
<div class="fb-srow-desc">Display the upcoming-section pill in the top-right of the player during playback.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<label class="fb-switch">
|
||||
<input type="checkbox" id="setting-show-upnext" checked onchange="setShowUpNext(this.checked)">
|
||||
<span class="fb-switch-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ask before leaving a song -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Ask before leaving a song</div>
|
||||
<div class="fb-srow-desc">Confirm before Escape (or the player’s ✕) exits a song. Off by default — Escape leaves instantly. With it on, a confirm appears; Space, Enter, or “Leave” exits, while Escape dismisses it.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<label class="fb-switch">
|
||||
<input type="checkbox" id="setting-confirm-exit" onchange="setConfirmExitSong(this.checked)">
|
||||
<span class="fb-switch-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Plugin settings ─────────────────────────────────────── -->
|
||||
<section id="plugin-settings-area" class="hidden">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Plugins</h3>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Plugin Updates</label>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
|
||||
<span id="updates-status" class="text-xs text-gray-500"></span>
|
||||
<!-- ══ AUDIO ═══════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="audio">
|
||||
<div class="fb-tabpanel-head"><h3>Audio Settings</h3></div>
|
||||
<div class="fb-srows">
|
||||
<!-- Live guitar tone source -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<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="M15.536 8.464a5 5 0 010 7.072M19 5a9 9 0 010 14M5 9v6h4l5 5V4L9 9H5z"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Live guitar tone source</div>
|
||||
<div class="fb-srow-desc">Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. fee[dB]ack still scores your playing but won't warn that no internal amp tone is loaded.</div>
|
||||
</div>
|
||||
<div id="plugin-updates-list" class="space-y-2"></div>
|
||||
</div>
|
||||
<!-- Per-plugin collapsible sections injected here -->
|
||||
<div id="plugin-settings" class="space-y-3"></div>
|
||||
<select id="setting-live-guitar-tone-source"
|
||||
class="fb-srow-wide bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="internal">fee[dB]ack internal tone</option>
|
||||
<option value="external_hardware">External amp / hardware pedalboard</option>
|
||||
<option value="spark_control_x">Spark LIVE + Spark Control X</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Demucs server -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Demucs server (for stem separation)</div>
|
||||
<div class="fb-srow-desc">Optional. Run <a href="https://github.com/got-feedback/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a GPU machine to offload stem splitting and avoid resource exhaustion on the host.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── About / Source / License (AGPL §13 disclosure) ──────── -->
|
||||
<section>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">About</h3>
|
||||
<div class="space-y-2 text-sm text-gray-400">
|
||||
<div>FeedBack <span id="app-version-about" class="text-gray-500"></span></div>
|
||||
<div>Licensed under <a id="about-license-link" href="https://github.com/got-feedback/feedBack/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a>.</div>
|
||||
<div><a id="about-source-link" href="https://github.com/got-feedback/feedBack" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a></div>
|
||||
<p class="text-xs text-gray-600 mt-2">FeedBack is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.</p>
|
||||
<!-- ══ GRAPHICS ════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="graphics">
|
||||
<div class="fb-tabpanel-head"><h3>Graphics Settings</h3></div>
|
||||
<!-- highway_3d (manifest settings.category="graphics") mounts here. -->
|
||||
<div id="plugin-settings-graphics" class="space-y-3"></div>
|
||||
<p class="fb-tabpanel-empty" data-empty-for="plugin-settings-graphics">No graphics plugins are installed.</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ KEYBINDS ════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="keybinds">
|
||||
<div class="fb-tabpanel-head"><h3>Keyboard Shortcuts</h3></div>
|
||||
<!-- Populated by settings.js from the live shortcut registry. -->
|
||||
<div id="settings-keybinds"></div>
|
||||
</div>
|
||||
|
||||
<!-- ══ PROGRESSION ═════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="progression">
|
||||
<div class="fb-tabpanel-head"><h3>Progression Settings</h3></div>
|
||||
<div id="plugin-settings-progression" class="space-y-3"></div>
|
||||
<p class="fb-tabpanel-empty" data-empty-for="plugin-settings-progression">No progression plugins are installed.</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ MIC ═════════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="mic">
|
||||
<div class="fb-tabpanel-head"><h3>Microphone & Detection</h3></div>
|
||||
<div id="plugin-settings-mic" class="space-y-3"></div>
|
||||
<p class="fb-tabpanel-empty" data-empty-for="plugin-settings-mic">No microphone/detection plugins are installed.</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ PLUGINS (fallback for uncategorized panels) ═════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="plugins">
|
||||
<div class="fb-tabpanel-head"><h3>Plugins</h3></div>
|
||||
<div class="fb-srows">
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Plugin updates</div>
|
||||
<div class="fb-srow-desc">Check installed plugins for newer versions.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control" style="justify-content:flex-start;">
|
||||
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
|
||||
<span id="updates-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<div id="plugin-updates-list" class="space-y-2 fb-srow-wide"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Per-plugin collapsible sections injected here (uncategorized). -->
|
||||
<div id="plugin-settings" class="space-y-3" style="margin-top:.6rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SYSTEM ══════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="system">
|
||||
<div class="fb-tabpanel-head"><h3>System</h3></div>
|
||||
<div class="fb-srows">
|
||||
<!-- App Updates — desktop-only; setupAppUpdates() unhides. -->
|
||||
<div id="app-updates-block" class="hidden fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">App updates</div>
|
||||
<div class="fb-srow-desc">Velopack auto-update channel for the desktop app.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control" style="flex-wrap:wrap;">
|
||||
<select id="app-update-channel"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="stable">Stable</option>
|
||||
<option value="rc">Release candidate</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha</option>
|
||||
</select>
|
||||
<button id="app-update-check-now"
|
||||
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
|
||||
Check for updates
|
||||
</button>
|
||||
</div>
|
||||
<p id="app-update-status" class="text-xs text-gray-500 fb-srow-wide">Loading updater status…</p>
|
||||
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 fb-srow-wide">
|
||||
Auto-update is not available on Linux —
|
||||
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Library folder path -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Library folder path</div>
|
||||
<div class="fb-srow-desc">The folder fee[dB]ack scans for your song library.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Library rescan -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Library</div>
|
||||
<div class="fb-srow-desc">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
|
||||
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
|
||||
<span id="rescan-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backup -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Backup</div>
|
||||
<div class="fb-srow-desc">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
|
||||
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
|
||||
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
|
||||
<span id="backup-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Diagnostics -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Diagnostics</div>
|
||||
<div class="fb-srow-desc">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths & song names</label>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
|
||||
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
|
||||
<span id="diag-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<div id="diag-preview" class="hidden mt-1 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto fb-srow-wide"></div>
|
||||
</div>
|
||||
<!-- About -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">About</div>
|
||||
<div class="fb-srow-desc">
|
||||
fee[dB]ack <span id="app-version-about" class="text-gray-500"></span> · Licensed under
|
||||
<a id="about-license-link" href="https://github.com/got-feedback/feedback/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a> ·
|
||||
<a id="about-source-link" href="https://github.com/got-feedback/feedback" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a>.
|
||||
Free software — if you run a modified version that interacts with users over a network, you must make the modified source available to those users.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-status" class="text-sm text-gray-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -626,7 +828,13 @@
|
||||
|
||||
<!-- Top HUD — persistent (song info, time, Up Next) -->
|
||||
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between items-start px-5 py-4 pointer-events-none z-20">
|
||||
<div class="text-sm leading-tight">
|
||||
<!-- fb-selectable: now-playing song metadata (title / artist /
|
||||
arrangement / tuning) is copy-worthy content, not chrome.
|
||||
pointer-events-auto: the #player-hud parent is pointer-events-none
|
||||
(so the HUD doesn't eat clicks meant for the highway), which would
|
||||
also block the mouse from reaching this text to select it — opt
|
||||
just this block back into hit-testing. -->
|
||||
<div class="text-sm leading-tight fb-selectable pointer-events-auto">
|
||||
<div><span id="hud-artist" class="text-gray-300"></span> — <span id="hud-title" class="text-white font-semibold"></span></div>
|
||||
<div id="hud-arrangement" class="text-gray-500 text-xs mt-0.5"></div>
|
||||
<div id="hud-tuning" class="text-gray-500 text-xs mt-0.5"></div>
|
||||
@@ -806,7 +1014,15 @@
|
||||
</select>
|
||||
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="v3-pop-btn hidden" title="Delete selected loop">✕</button>
|
||||
</div>
|
||||
<button onclick="showScreen('home')" class="v3-pop-close" title="Close player">✕ Close player</button>
|
||||
<!-- Editor ⇄ 3D Highway round-trip (mirrors the v2 loop cluster). -->
|
||||
<div class="v3-pop-row">
|
||||
<span class="v3-pop-label">Editor</span>
|
||||
<span class="flex items-center gap-1 flex-wrap">
|
||||
<button onclick="editRegionInEditor()" id="btn-edit-region" class="v3-pop-btn hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
|
||||
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="v3-pop-btn hidden" title="Return to the editor where you left off">↩ Editor</button>
|
||||
</span>
|
||||
</div>
|
||||
<button onclick="requestExitSong()" class="v3-pop-close" title="Close player">✕ Close player</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -892,6 +1108,7 @@
|
||||
<script src="/static/v3/songs.js"></script>
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
<script src="/static/v3/settings.js"></script>
|
||||
<!-- First-run home tour: spotlights the home cards via the shared tour
|
||||
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
|
||||
(triggered from profile.js finish()); replayable from the "?" menu. -->
|
||||
|
||||
@@ -118,6 +118,14 @@
|
||||
// "Waiting for notes" overlay for the whole song.
|
||||
let revealed = false;
|
||||
let counters = createCounters();
|
||||
// Position-aware ledger: { t: chart-note time, hit } per judged note,
|
||||
// so a BACKWARD reposition (Restart button / scrub-back) can rebuild the
|
||||
// visible tally to reflect only the notes up to the new playhead instead
|
||||
// of keeping the stale cumulative total. Mirrors the notedetect HUD's own
|
||||
// ledger (note:hit/note:miss carry the judgment, incl. noteTime). The
|
||||
// running `counters` stay incremental for the live path; the ledger is
|
||||
// only replayed on a seek.
|
||||
let ledger = [];
|
||||
const els = domEls || {
|
||||
root: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-hud') : null,
|
||||
percent: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-percent') : null,
|
||||
@@ -134,6 +142,39 @@
|
||||
|
||||
function resetCounters() {
|
||||
counters = createCounters();
|
||||
ledger = [];
|
||||
}
|
||||
|
||||
// Chart-note time for a judgment event, or null when unknown (argless
|
||||
// test calls, or a judgment without timing). note:hit/note:miss carry
|
||||
// the notedetect judgment object as `detail`.
|
||||
function judgmentTime(e) {
|
||||
const d = e && e.detail;
|
||||
if (!d) return null;
|
||||
if (Number.isFinite(d.noteTime)) return d.noteTime;
|
||||
if (d.chartNote && Number.isFinite(d.chartNote.t)) return d.chartNote.t;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rebuild counters from the ledger up to (excluding) chart time `t`.
|
||||
// Drop judgments at/after `t` so replaying forward re-counts them, then
|
||||
// replay survivors in time order through the same hit/streak rules.
|
||||
function rebuildToPosition(t) {
|
||||
if (!Number.isFinite(t)) return;
|
||||
ledger = ledger.filter((e) => !(Number.isFinite(e.t) && e.t >= t));
|
||||
const sorted = ledger.slice().sort((a, b) => (a.t == null ? -Infinity : a.t) - (b.t == null ? -Infinity : b.t));
|
||||
counters = createCounters();
|
||||
for (const e of sorted) {
|
||||
if (e.hit) {
|
||||
counters.hits++;
|
||||
counters.streak++;
|
||||
if (counters.streak > counters.bestStreak) counters.bestStreak = counters.streak;
|
||||
} else {
|
||||
counters.misses++;
|
||||
counters.streak = 0;
|
||||
}
|
||||
}
|
||||
paint();
|
||||
}
|
||||
|
||||
function currentStats() {
|
||||
@@ -180,18 +221,20 @@
|
||||
paint();
|
||||
}
|
||||
|
||||
function onHit() {
|
||||
function onHit(e) {
|
||||
if (!active) return;
|
||||
reveal();
|
||||
ledger.push({ t: judgmentTime(e), hit: true });
|
||||
counters.hits++;
|
||||
counters.streak++;
|
||||
if (counters.streak > counters.bestStreak) counters.bestStreak = counters.streak;
|
||||
paint();
|
||||
}
|
||||
|
||||
function onMiss() {
|
||||
function onMiss(e) {
|
||||
if (!active) return;
|
||||
reveal();
|
||||
ledger.push({ t: judgmentTime(e), hit: false });
|
||||
counters.misses++;
|
||||
counters.streak = 0;
|
||||
paint();
|
||||
@@ -207,6 +250,22 @@
|
||||
sm.on('song:ended', () => { hideSession(); });
|
||||
sm.on('note:hit', onHit);
|
||||
sm.on('note:miss', onMiss);
|
||||
// Restart / scrub-back: rebuild the tally to the new playhead. song:seek
|
||||
// is core's single repositioning funnel ({ from, to, reason }). Only a
|
||||
// BACKWARD jump recomputes; a forward seek leaves earlier notes counted.
|
||||
// Skip loop-wrap (drill mode) so a practiced A-B loop keeps accumulating,
|
||||
// matching the notedetect HUD.
|
||||
sm.on('song:seek', (e) => {
|
||||
if (!active) return;
|
||||
const d = (e && e.detail) || {};
|
||||
if (d.reason === 'loop-wrap') return;
|
||||
const to = Number(d.to);
|
||||
if (!Number.isFinite(to)) return;
|
||||
const from = Number(d.from);
|
||||
const movedBack = Number.isFinite(from) ? (to < from - 0.05) : true;
|
||||
if (!movedBack) return;
|
||||
rebuildToPosition(to);
|
||||
});
|
||||
|
||||
return {
|
||||
getCounters: () => ({ ...counters }),
|
||||
@@ -216,6 +275,7 @@
|
||||
hideSession,
|
||||
onHit,
|
||||
onMiss,
|
||||
rebuildToPosition,
|
||||
paint,
|
||||
els,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
+42
-5
@@ -28,6 +28,22 @@
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Content-dependent playlist cover: a custom uploaded cover wins; otherwise
|
||||
// the playlist's own song art — the icon when empty, one cover for a few
|
||||
// songs, a 2×2 mosaic at 4+. `art_urls` / `cover_url` come from /api/playlists.
|
||||
function playlistCoverHtml(p) {
|
||||
const box = 'w-full aspect-square rounded-lg overflow-hidden bg-fb-bg/50 mb-3';
|
||||
const img = (u, cls) => '<img src="' + esc(u) + '" alt="" class="' + cls + '" onerror="this.style.visibility=\'hidden\'">';
|
||||
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
|
||||
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
|
||||
if (!arts.length) {
|
||||
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>';
|
||||
}
|
||||
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
|
||||
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
|
||||
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
|
||||
}
|
||||
|
||||
function songRow(s, opts) {
|
||||
opts = opts || {};
|
||||
const handle = opts.draggable
|
||||
@@ -96,15 +112,14 @@
|
||||
(lists.length
|
||||
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
|
||||
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
'<div class="w-full aspect-square rounded-lg bg-fb-bg/50 mb-3 flex items-center justify-center text-fb-textDim">' +
|
||||
(p.system_key ? '🔖' : '🎵') + '</div>' +
|
||||
playlistCoverHtml(p) +
|
||||
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'</button>').join('') + '</div>'
|
||||
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
|
||||
'</div>';
|
||||
root.querySelector('#v3-pl-new')?.addEventListener('click', async () => {
|
||||
const name = (window.prompt('Playlist name?') || '').trim();
|
||||
const name = ((await window.uiPrompt({ title: 'New Playlist', label: 'Playlist name', okLabel: 'Create', placeholder: 'My Playlist' })) || '').trim();
|
||||
if (!name) return;
|
||||
await jsend('POST', '/api/playlists', { name });
|
||||
renderPlaylists();
|
||||
@@ -126,8 +141,11 @@
|
||||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
|
||||
(isSystem ? '' :
|
||||
'<div class="flex gap-2 shrink-0">' +
|
||||
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
|
||||
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
|
||||
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
|
||||
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button></div>') +
|
||||
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
|
||||
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden"></div>') +
|
||||
'</div>' +
|
||||
(pl.songs.length
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
|
||||
@@ -137,7 +155,7 @@
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||||
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
|
||||
const name = (window.prompt('Rename playlist', pl.name) || '').trim();
|
||||
const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
|
||||
if (!name) return;
|
||||
await jsend('PATCH', '/api/playlists/' + pid, { name });
|
||||
renderPlaylistDetail(pid);
|
||||
@@ -147,6 +165,25 @@
|
||||
await fetch('/api/playlists/' + pid, { method: 'DELETE' });
|
||||
renderPlaylists();
|
||||
});
|
||||
// Custom cover: pick an image → upload as a data URL → the playlist card
|
||||
// shows it (overriding the song-art cover). Re-render the detail so the
|
||||
// Remove-cover button appears; the grid picks up the new cover on return.
|
||||
const coverFile = root.querySelector('#v3-pl-cover-file');
|
||||
root.querySelector('#v3-pl-cover')?.addEventListener('click', () => coverFile && coverFile.click());
|
||||
coverFile?.addEventListener('change', () => {
|
||||
const f = coverFile.files && coverFile.files[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
await jsend('POST', '/api/playlists/' + pid + '/cover', { image: e.target.result });
|
||||
renderPlaylistDetail(pid);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
});
|
||||
root.querySelector('#v3-pl-cover-rm')?.addEventListener('click', async () => {
|
||||
await fetch('/api/playlists/' + pid + '/cover', { method: 'DELETE' });
|
||||
renderPlaylistDetail(pid);
|
||||
});
|
||||
}
|
||||
|
||||
// ── #v3-saved ─────────────────────────────────────────────────────────--
|
||||
|
||||
+204
-44
@@ -96,6 +96,47 @@
|
||||
}
|
||||
|
||||
// ── Profile screen (#v3-profile) ────────────────────────────────────────-
|
||||
// The screen is tabbed exactly like the v3 Settings page (.fb-tabbar /
|
||||
// .fb-tab[data-tab] / .fb-tabpanel[data-tab]; achievements epic): a
|
||||
// **Profile** (main) tab and an **Achievements** tab. Core only ships the
|
||||
// shell + two mount points; the bundled achievements plugin renders the
|
||||
// Feats trophy shelf (#v3-profile-feats-slot, earned-only) and the full
|
||||
// competency catalogue (#v3-profile-achievements-mount) into them on the
|
||||
// `v3:profile-rendered` event below. Active tab persists in localStorage,
|
||||
// mirroring settings.js.
|
||||
var PROFILE_TAB_KEY = 'v3-profile-tab';
|
||||
var PROFILE_DEFAULT_TAB = 'profile';
|
||||
|
||||
function activateProfileTab(tab) {
|
||||
var root = document.getElementById('v3-profile');
|
||||
if (!root) return;
|
||||
var bar = root.querySelector('#v3-profile-tabbar');
|
||||
var tabs = [];
|
||||
if (bar) bar.querySelectorAll('.fb-tab').forEach(function (b) { if (b.dataset.tab) tabs.push(b.dataset.tab); });
|
||||
if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : PROFILE_DEFAULT_TAB;
|
||||
root.querySelectorAll('#v3-profile-tabbar .fb-tab').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.tab === tab);
|
||||
});
|
||||
root.querySelectorAll('.fb-tabpanel').forEach(function (p) {
|
||||
p.classList.toggle('active', p.dataset.tab === tab);
|
||||
});
|
||||
try { localStorage.setItem(PROFILE_TAB_KEY, tab); } catch (_) { /* private mode */ }
|
||||
}
|
||||
|
||||
function wireProfileTabs() {
|
||||
var bar = document.getElementById('v3-profile-tabbar');
|
||||
if (bar && bar.dataset.wired !== '1') {
|
||||
bar.dataset.wired = '1';
|
||||
bar.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest ? e.target.closest('.fb-tab') : null;
|
||||
if (btn && btn.dataset.tab) activateProfileTab(btn.dataset.tab);
|
||||
});
|
||||
}
|
||||
var saved = PROFILE_DEFAULT_TAB;
|
||||
try { saved = localStorage.getItem(PROFILE_TAB_KEY) || PROFILE_DEFAULT_TAB; } catch (_) { /* noop */ }
|
||||
activateProfileTab(saved);
|
||||
}
|
||||
|
||||
function renderProfileScreen() {
|
||||
const root = document.getElementById('v3-profile');
|
||||
if (!root) return;
|
||||
@@ -110,9 +151,8 @@
|
||||
const pathChips = ((prog && prog.paths) || []).map((path) =>
|
||||
'<span class="inline-flex items-center gap-1 bg-fb-bg/40 border border-fb-border/50 rounded-full px-3 py-1 text-xs text-fb-text">' +
|
||||
esc(path.name) + ' <span class="text-fb-primary font-semibold">Lv ' + path.level + '</span></span>').join(' ');
|
||||
root.innerHTML =
|
||||
'<div class="max-w-4xl mx-auto p-6 md:p-8 space-y-6">' +
|
||||
// Header card
|
||||
// Header card (Profile main tab).
|
||||
const headerCard =
|
||||
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50 flex flex-col sm:flex-row items-center gap-6">' +
|
||||
'<span data-v3-avatar-frame class="inline-block rounded-full">' +
|
||||
avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '</span>' +
|
||||
@@ -128,17 +168,39 @@
|
||||
'<div class="mt-4 flex items-center justify-center sm:justify-start gap-4">' +
|
||||
'<button type="button" data-v3-edit-profile class="text-sm text-fb-primary hover:text-fb-primaryHi">Edit name & avatar</button>' +
|
||||
'<button type="button" data-v3-open-progress class="text-sm text-fb-primary hover:text-fb-primaryHi">View challenges & quests →</button>' +
|
||||
'</div></div></div>' +
|
||||
// Per-song bests — top scored songs from /api/stats/top, filled by
|
||||
// renderBests() after innerHTML is set. The placeholder text shows
|
||||
// during load and when nothing's been scored yet.
|
||||
'</div></div></div>';
|
||||
// Per-song bests — top scored songs from /api/stats/top, filled by
|
||||
// renderBests() after innerHTML is set. The placeholder text shows
|
||||
// during load and when nothing's been scored yet.
|
||||
const bestsCard =
|
||||
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
|
||||
'<h3 class="text-lg font-bold text-fb-text mb-2">Your best scores</h3>' +
|
||||
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
|
||||
'</div>';
|
||||
const playerIdFooter = (_profile && _profile.player_hash
|
||||
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
: '');
|
||||
root.innerHTML =
|
||||
'<div class="max-w-4xl mx-auto p-6 md:p-8">' +
|
||||
'<div class="fb-tabbar" id="v3-profile-tabbar">' +
|
||||
'<button type="button" class="fb-tab" data-tab="profile">Profile</button>' +
|
||||
'<button type="button" class="fb-tab" data-tab="achievements">Achievements</button>' +
|
||||
'</div>' +
|
||||
// ── Profile (main) panel ──────────────────────────────────────────
|
||||
'<div class="fb-tabpanel" data-tab="profile">' +
|
||||
'<div class="space-y-6">' +
|
||||
headerCard +
|
||||
bestsCard +
|
||||
// Feats of Power trophy shelf — rendered by the achievements plugin
|
||||
// (earned Feats only; hidden-until-earned, so empty when none).
|
||||
'<div id="v3-profile-feats-slot"></div>' +
|
||||
playerIdFooter +
|
||||
'</div></div>' +
|
||||
// ── Achievements panel ────────────────────────────────────────────
|
||||
'<div class="fb-tabpanel" data-tab="achievements">' +
|
||||
'<div id="v3-profile-achievements-mount"></div>' +
|
||||
'<p class="fb-tabpanel-empty" data-empty-for="v3-profile-achievements-mount">Install the Achievements plugin to track your skill milestones.</p>' +
|
||||
'</div>' +
|
||||
(_profile && _profile.player_hash
|
||||
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
: '') +
|
||||
'</div>';
|
||||
const edit = root.querySelector('[data-v3-edit-profile]');
|
||||
if (edit) edit.addEventListener('click', () => show(_profile, { editing: true }));
|
||||
@@ -147,7 +209,13 @@
|
||||
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
|
||||
window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]'));
|
||||
}
|
||||
wireProfileTabs();
|
||||
renderBests();
|
||||
// Tell the achievements plugin (or any profile consumer) the shell +
|
||||
// mount points exist now, so it can (re)inject on every profile entry —
|
||||
// innerHTML above wipes prior injected content. Mirrors
|
||||
// `v3:settings-rendered`. Harmless if no listener is attached.
|
||||
try { document.dispatchEvent(new CustomEvent('v3:profile-rendered')); } catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
// Fill the "Your best scores" panel from /api/stats/top (top scored songs,
|
||||
@@ -222,23 +290,39 @@
|
||||
if (!instruments.length) return;
|
||||
// Don't let a plugin-load race skip the mandatory input-setup step.
|
||||
if (!(await waitForInputSetup(8000))) return;
|
||||
const caps = window.feedBack && window.feedBack.capabilities;
|
||||
if (!caps || typeof caps.command !== 'function') {
|
||||
try { await window.feedBackInputSetup.launch(instruments); } catch (e) { /* proceed */ }
|
||||
return;
|
||||
// Hide this onboarding modal while the input-setup wizard (its own
|
||||
// full-screen overlay) runs on top. Otherwise both stay stacked, and when
|
||||
// the note-detect calibration wizard minimizes to expose the Tuner the
|
||||
// onboarding modal shows through behind the tuner. Restored in `finally`
|
||||
// before we advance to the calibration-challenge step.
|
||||
const ob = document.getElementById('v3-onboarding');
|
||||
const obPrevDisplay = ob ? ob.style.display : '';
|
||||
if (ob) ob.style.display = 'none';
|
||||
const restoreOnboarding = () => {
|
||||
const o = document.getElementById('v3-onboarding');
|
||||
if (o) o.style.display = obPrevDisplay;
|
||||
};
|
||||
try {
|
||||
const caps = window.feedBack && window.feedBack.capabilities;
|
||||
if (!caps || typeof caps.command !== 'function') {
|
||||
try { await window.feedBackInputSetup.launch(instruments); } catch (e) { /* proceed */ }
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let unsub = null;
|
||||
const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); };
|
||||
try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; }
|
||||
// `run` is fire-and-launch; completion arrives via the event above.
|
||||
// A non-handled outcome (no owner / plugin absent / error) means
|
||||
// nothing was launched, so proceed immediately.
|
||||
caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } })
|
||||
.then((r) => { if (!r || r.outcome !== 'handled') done(); })
|
||||
.catch(() => done());
|
||||
});
|
||||
} finally {
|
||||
restoreOnboarding();
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let unsub = null;
|
||||
const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); };
|
||||
try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; }
|
||||
// `run` is fire-and-launch; completion arrives via the event above.
|
||||
// A non-handled outcome (no owner / plugin absent / error) means
|
||||
// nothing was launched, so proceed immediately.
|
||||
caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } })
|
||||
.then((r) => { if (!r || r.outcome !== 'handled') done(); })
|
||||
.catch(() => done());
|
||||
});
|
||||
}
|
||||
|
||||
function show(profile, opts) {
|
||||
@@ -246,9 +330,16 @@
|
||||
const editing = !!opts.editing;
|
||||
document.getElementById('v3-onboarding')?.remove();
|
||||
|
||||
// The amp-sim opt-in step (step 5) only exists in the desktop app — the
|
||||
// pure-web build has no native amp sims to monitor through, so the step
|
||||
// is skipped there (calibration is the last step at index 5 on web, 6 on
|
||||
// desktop). See feedBack-desktop#46.
|
||||
const isDesktop = !!window.feedBackDesktop;
|
||||
const lastStep = isDesktop ? 6 : 5;
|
||||
|
||||
const stepDots = editing ? '' :
|
||||
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
|
||||
[1, 2, 3, 4].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
|
||||
Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
|
||||
'</div>';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
@@ -281,13 +372,35 @@
|
||||
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' +
|
||||
'<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' +
|
||||
'</div></div>' +
|
||||
// Step 3 — instrument paths (first-run only; tiles filled on entry).
|
||||
// Step 3 — Achievements wall opt-in (first-run only; default OFF).
|
||||
'<div id="v3-ob-step3" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">As you practise you’ll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
|
||||
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
|
||||
'<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
|
||||
'<span class="text-sm text-fb-text">Share my Feats on the wall' +
|
||||
'<span class="block text-xs text-fb-textDim mt-1">Publishes only your display name and the Feats you earn — never songs, skills, or scores. You can change this any time in Settings, and remove yourself with one click.</span></span>' +
|
||||
'</label>' +
|
||||
'<p class="text-xs text-fb-textDim mt-2">Leave it unticked to keep everything private. This is off by default.</p></div>' +
|
||||
// Step 4 — instrument paths (first-run only; tiles filled on entry).
|
||||
'<div id="v3-ob-step4" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
|
||||
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
|
||||
// Step 4 — calibration offer (first-run only).
|
||||
'<div id="v3-ob-step4" class="hidden">' +
|
||||
// Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first).
|
||||
// Hidden div is always present in the DOM; setStep only navigates to
|
||||
// it on desktop. See feedBack-desktop#46.
|
||||
'<div id="v3-ob-step5" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">How do you want to hear yourself?</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">fee[dB]ack can run your guitar through built-in <span class="text-fb-text">amp simulations</span> (NAM / IRs / plugins) so you hear a processed tone. If you already play through your <span class="text-fb-text">own amp or rig</span>, leave this off — you’ll get clean, silent monitoring and never an idle buzz.</p>' +
|
||||
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
|
||||
'<input type="checkbox" id="v3-ob-ampsims" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
|
||||
'<span class="text-sm text-fb-text">Use in-app amp simulations' +
|
||||
'<span class="block text-xs text-fb-textDim mt-1">Loads your saved tone chain for monitoring. You can change this any time in the desktop Audio settings.</span></span>' +
|
||||
'</label>' +
|
||||
'<p class="text-xs text-fb-textDim mt-2">Leave it unticked if you monitor through your own gear. This is off by default.</p></div>' +
|
||||
// Step 6 — calibration offer (first-run only).
|
||||
'<div id="v3-ob-step6" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
|
||||
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
|
||||
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and you’ll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
|
||||
@@ -333,6 +446,9 @@
|
||||
// for now" is available for users who'll set it later.
|
||||
submit.disabled = !songDir.trim();
|
||||
} else if (step === 3) {
|
||||
// Achievements opt-in — either choice is valid; always enabled.
|
||||
submit.disabled = false;
|
||||
} else if (step === 4) {
|
||||
// ≥1 path required — unless none could be offered (offline /
|
||||
// empty content), where blocking would strand onboarding.
|
||||
submit.disabled = pathsAvailable && selectedPaths.length < 1;
|
||||
@@ -344,7 +460,7 @@
|
||||
function setStep(n) {
|
||||
step = n;
|
||||
errEl.classList.add('hidden');
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
|
||||
}
|
||||
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
|
||||
@@ -355,13 +471,15 @@
|
||||
if (subtitle) {
|
||||
subtitle.textContent = n === 1 ? 'Set up your player profile'
|
||||
: n === 2 ? 'Point us at your songs'
|
||||
: n === 3 ? 'Choose your instrument paths'
|
||||
: n === 3 ? 'Feats of Power (optional)'
|
||||
: n === 4 ? 'Choose your instrument paths'
|
||||
: n === 5 ? 'How do you want to monitor?'
|
||||
: 'One last thing — calibrate your setup';
|
||||
}
|
||||
submit.textContent = n === 4 ? 'Play it now' : 'Next';
|
||||
submit.textContent = n === 6 ? 'Play it now' : 'Next';
|
||||
// Skip is offered on the song-directory step (configure later) and
|
||||
// the calibration challenge.
|
||||
skipBtn.classList.toggle('hidden', !(n === 2 || n === 4));
|
||||
// the calibration challenge (the last step).
|
||||
skipBtn.classList.toggle('hidden', !(n === 2 || n === 6));
|
||||
refreshSubmit();
|
||||
}
|
||||
|
||||
@@ -546,23 +664,42 @@
|
||||
}
|
||||
if (step === 2) {
|
||||
// Save the song directory + kick a library scan, then continue
|
||||
// to instrument paths. "Skip for now" leaves it unconfigured.
|
||||
// to the achievements opt-in. "Skip for now" leaves it unconfigured.
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await saveSongDir();
|
||||
setStep(3);
|
||||
loadPathTiles();
|
||||
} catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
if (step === 3) {
|
||||
// Persist the wall opt-in choice (default OFF) then continue to
|
||||
// instrument paths. Best-effort — a failed write must not block
|
||||
// onboarding; the user can still set it later in Settings.
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const optEl = overlay.querySelector('#v3-ob-optin');
|
||||
const optedIn = !!(optEl && optEl.checked);
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ achievements_enabled: optedIn }),
|
||||
});
|
||||
try { localStorage.setItem('achievementsEnabled', optedIn ? '1' : '0'); } catch (_) { /* noop */ }
|
||||
} catch (e) { /* best-effort — settable later */ }
|
||||
setStep(4);
|
||||
loadPathTiles();
|
||||
} finally { refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
if (step === 4) {
|
||||
// Create the profile (onboarded=1) BEFORE the calibration choice
|
||||
// so closing the overlay at the challenge can never lose the profile.
|
||||
submit.disabled = true;
|
||||
try {
|
||||
_profile = await postProfile();
|
||||
if (selectedPaths.length) {
|
||||
// A failed path save must NOT advance — step 3's skip
|
||||
// A failed path save must NOT advance — step 4's skip
|
||||
// requires ≥1 selected path (spec invariant) and would
|
||||
// otherwise leave a pathless rank-1 profile.
|
||||
const res = await fetch('/api/progression/paths', {
|
||||
@@ -578,11 +715,29 @@
|
||||
// New step: input-device selection + calibration, between
|
||||
// path selection and the note-detect calibration challenge.
|
||||
await runInputSetup(selectedPaths);
|
||||
setStep(4);
|
||||
setStep(isDesktop ? 5 : 6);
|
||||
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
// Step 4 — "Play it now": leave calibration pending (it completes
|
||||
if (step === 5) {
|
||||
// Step 5 (desktop only) — persist the amp-sim opt-in (default OFF
|
||||
// / own-rig). Best-effort: a failed write must not block onboarding;
|
||||
// it's settable later from the desktop Audio settings.
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const ampEl = overlay.querySelector('#v3-ob-ampsims');
|
||||
const useAmpSims = !!(ampEl && ampEl.checked);
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ use_amp_sims: useAmpSims }),
|
||||
});
|
||||
} catch (e) { /* best-effort — settable later */ }
|
||||
setStep(6);
|
||||
} finally { refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
// Step 6 — "Play it now": leave calibration pending (it completes
|
||||
// through the normal scored-stats path) and launch the diagnostic.
|
||||
const target = diagnosticFilename;
|
||||
await finish({ launchingSong: !!target });
|
||||
@@ -591,14 +746,13 @@
|
||||
|
||||
skipBtn.addEventListener('click', async () => {
|
||||
// Step 2 — skip the song directory (the user can set it later in
|
||||
// Settings). Proceed straight to instrument paths.
|
||||
// Settings). Proceed to the achievements opt-in.
|
||||
if (step === 2) {
|
||||
setStep(3);
|
||||
loadPathTiles();
|
||||
return;
|
||||
}
|
||||
// Step 4 — skip: Mastery Rank 1 immediately, calibration stays
|
||||
// replayable from the Progress screen.
|
||||
// Calibration step (last) — skip: Mastery Rank 1 immediately,
|
||||
// calibration stays replayable from the Progress screen.
|
||||
skipBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/progression/onboarding', {
|
||||
@@ -630,6 +784,12 @@
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
window.feedBack.on('progression:updated', () => { renderBadge(); renderProfileScreen(); });
|
||||
window.feedBack.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); });
|
||||
// Re-render on every Profile entry so the Feats shelf + Achievements
|
||||
// catalogue refresh (and `v3:profile-rendered` re-fires for the
|
||||
// plugin) — the plugin may have loaded after the initial boot render.
|
||||
window.feedBack.on('screen:changed', (e) => {
|
||||
if (e && e.detail && e.detail.id === 'v3-profile') renderProfileScreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// v3 tabbed settings page — behaviour layer (feat/v3-settings-tabbed)
|
||||
//
|
||||
// The markup (tab bar, card rows, per-tab plugin mount containers) lives
|
||||
// statically in static/v3/index.html so the element ids exist before app.js's
|
||||
// loadSettings() hydrates them. This module owns the *behaviour*:
|
||||
// • tab switching + active-tab persistence (localStorage 'v3-settings-tab')
|
||||
// • the per-category "Reset" button(s)
|
||||
// • the read-only Keybinds reference (from window.getAllShortcuts())
|
||||
// • empty-state notes for plugin tabs with no installed plugins
|
||||
//
|
||||
// It is a plain non-module script (matches the rest of static/v3/*). All
|
||||
// reads are null-guarded so it no-ops gracefully on the classic v2 page (which
|
||||
// ships its own settings markup and never creates #settings-tabbar).
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TAB_KEY = 'v3-settings-tab';
|
||||
var DEFAULT_TAB = 'gameplay';
|
||||
|
||||
// Per-category reset descriptors. `server` keys are cleared via
|
||||
// POST /api/settings/reset (so the next GET falls back to defaults);
|
||||
// `local` keys are client-only localStorage prefs; `after` re-applies any
|
||||
// live-object default that won't pick itself back up from a cleared key.
|
||||
// Only tabs with a [data-reset] button in the markup need an entry — today
|
||||
// that's Gameplay; others can be added alongside a button later.
|
||||
var RESET_MAP = {
|
||||
gameplay: {
|
||||
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
after: function () {
|
||||
// Left-handed is held on the highway object, not re-derived
|
||||
// from localStorage on load — flip it back to the default.
|
||||
try { if (window.highway && window.highway.setLefty) window.highway.setLefty(false); } catch (_) { /* noop */ }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ── Tab switching ────────────────────────────────────────────────────
|
||||
function knownTabs() {
|
||||
var out = [];
|
||||
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
|
||||
if (b.dataset.tab) out.push(b.dataset.tab);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function activateTab(tab) {
|
||||
var tabs = knownTabs();
|
||||
if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : DEFAULT_TAB;
|
||||
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('#settings .fb-tabpanel').forEach(function (p) {
|
||||
p.classList.toggle('active', p.dataset.tab === tab);
|
||||
});
|
||||
try { localStorage.setItem(TAB_KEY, tab); } catch (_) { /* private mode */ }
|
||||
}
|
||||
|
||||
function wireTabs() {
|
||||
var bar = document.getElementById('settings-tabbar');
|
||||
if (!bar || bar.dataset.wired === '1') return;
|
||||
bar.dataset.wired = '1';
|
||||
bar.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest ? e.target.closest('.fb-tab') : null;
|
||||
if (btn && btn.dataset.tab) activateTab(btn.dataset.tab);
|
||||
});
|
||||
var saved = DEFAULT_TAB;
|
||||
try { saved = localStorage.getItem(TAB_KEY) || DEFAULT_TAB; } catch (_) { /* noop */ }
|
||||
activateTab(saved);
|
||||
}
|
||||
|
||||
// ── Per-category reset ────────────────────────────────────────────────
|
||||
function wireResets() {
|
||||
document.querySelectorAll('#settings [data-reset]').forEach(function (btn) {
|
||||
if (btn.dataset.wired === '1') return;
|
||||
btn.dataset.wired = '1';
|
||||
btn.addEventListener('click', function () { resetCategory(btn.dataset.reset); });
|
||||
});
|
||||
}
|
||||
|
||||
function resetCategory(cat) {
|
||||
var map = RESET_MAP[cat];
|
||||
if (!map) return;
|
||||
var confirmFn = (typeof window._confirmDialog === 'function')
|
||||
? window._confirmDialog({
|
||||
title: 'Reset ' + cat.charAt(0).toUpperCase() + cat.slice(1) + ' Settings',
|
||||
body: '<p class="text-sm text-gray-300">Restore these settings to their defaults? This can\'t be undone.</p>',
|
||||
confirmText: 'Reset', cancelText: 'Cancel', danger: true,
|
||||
})
|
||||
: Promise.resolve(window.confirm('Reset ' + cat + ' settings to defaults?'));
|
||||
confirmFn.then(function (ok) {
|
||||
if (!ok) return;
|
||||
var done = Promise.resolve();
|
||||
if (map.server && map.server.length) {
|
||||
done = fetch('/api/settings/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keys: map.server }),
|
||||
}).catch(function () { /* best-effort */ });
|
||||
}
|
||||
done.then(function () {
|
||||
(map.local || []).forEach(function (k) {
|
||||
try { localStorage.removeItem(k); } catch (_) { /* noop */ }
|
||||
});
|
||||
if (typeof map.after === 'function') { try { map.after(); } catch (_) { /* noop */ } }
|
||||
// Re-hydrate every control from the now-default server + local state.
|
||||
if (typeof window.loadSettings === 'function') {
|
||||
try { window.loadSettings(); } catch (_) { /* noop */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keybinds reference (read-only) ────────────────────────────────────
|
||||
var SCOPE_TITLES = {
|
||||
global: 'Global', player: 'Player', library: 'Library', settings: 'Settings',
|
||||
};
|
||||
function scopeTitle(scope) {
|
||||
if (SCOPE_TITLES[scope]) return SCOPE_TITLES[scope];
|
||||
if (scope && scope.indexOf('plugin-') === 0) return 'Plugin: ' + scope.slice(7);
|
||||
return scope || 'Other';
|
||||
}
|
||||
|
||||
function renderKeybinds() {
|
||||
var host = document.getElementById('settings-keybinds');
|
||||
if (!host) return;
|
||||
var list = [];
|
||||
try { if (typeof window.getAllShortcuts === 'function') list = window.getAllShortcuts() || []; } catch (_) { list = []; }
|
||||
if (!list.length) {
|
||||
host.innerHTML = '<p class="fb-tabpanel-empty">No keyboard shortcuts are registered yet.</p>';
|
||||
return;
|
||||
}
|
||||
// Group by scope, stable scope order with anything unknown last.
|
||||
var order = ['global', 'player', 'library', 'settings'];
|
||||
var groups = {};
|
||||
list.forEach(function (s) {
|
||||
(groups[s.scope] = groups[s.scope] || []).push(s);
|
||||
});
|
||||
var scopes = Object.keys(groups).sort(function (a, b) {
|
||||
var ia = order.indexOf(a), ib = order.indexOf(b);
|
||||
if (ia === -1) ia = order.length;
|
||||
if (ib === -1) ib = order.length;
|
||||
return ia - ib || a.localeCompare(b);
|
||||
});
|
||||
var html = '';
|
||||
scopes.forEach(function (scope) {
|
||||
html += '<div class="fb-kbd-group-title">' + esc(scopeTitle(scope)) + '</div>';
|
||||
html += '<div class="fb-srows">';
|
||||
groups[scope].forEach(function (s) {
|
||||
html += '<div class="fb-srow">'
|
||||
+ '<div class="fb-srow-main"><div class="fb-srow-title">' + esc(s.description || s.combo) + '</div></div>'
|
||||
+ '<div class="fb-srow-control"><span class="fb-kbd">' + esc(s.combo) + '</span></div>'
|
||||
+ '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
html += '<p class="fb-settings-note">Remapping shortcuts is not yet supported.</p>';
|
||||
host.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Empty-state notes for plugin tabs ─────────────────────────────────
|
||||
function refreshEmptyStates() {
|
||||
document.querySelectorAll('#settings [data-empty-for]').forEach(function (note) {
|
||||
var target = document.getElementById(note.dataset.emptyFor);
|
||||
var empty = !target || target.children.length === 0;
|
||||
note.style.display = empty ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Boot + refresh on settings entry ──────────────────────────────────
|
||||
function init() {
|
||||
if (!document.getElementById('settings-tabbar')) return; // not the v3 page
|
||||
wireTabs();
|
||||
wireResets();
|
||||
renderKeybinds();
|
||||
refreshEmptyStates();
|
||||
// Safety net for plugin-panel injection ordering: tell app.js the
|
||||
// settings containers exist now (it injects plugin <details> into the
|
||||
// per-category containers). Harmless if no listener is attached.
|
||||
try { document.dispatchEvent(new CustomEvent('v3:settings-rendered')); } catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
// Re-derive the dynamic bits whenever the user enters Settings: shortcuts
|
||||
// and plugin panels may have registered/mounted since the last visit.
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
window.feedBack.on('screen:changed', function (e) {
|
||||
if (e && e.id === 'settings') {
|
||||
// Plugin panels (and shortcuts) may have mounted since the last
|
||||
// visit — re-derive the dynamic bits on every Settings entry.
|
||||
wireTabs(); wireResets(); renderKeybinds(); refreshEmptyStates();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
+26
-2
@@ -17,6 +17,13 @@
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
// Plugin ids whose manifest declared `"fullscreen": true`. Populated from
|
||||
// /api/plugins in renderPromotedNav(); read by syncActive() to toggle the
|
||||
// immersive (chrome-collapsed) shell whenever such a plugin's screen is the
|
||||
// active one. Empty until plugins resolve — the worst case is one extra
|
||||
// syncActive() once the fetch lands, which re-applies the class.
|
||||
const FULLSCREEN_PLUGIN_IDS = new Set();
|
||||
|
||||
// ── Navigation registry ────────────────────────────────────────────────
|
||||
// Each entry maps a stable hash key → a screen id (showScreen target) and a
|
||||
// label. Legacy screens are reused: "Songs" = #home (library), "Favorites"
|
||||
@@ -43,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' },
|
||||
];
|
||||
@@ -54,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'];
|
||||
@@ -112,6 +121,15 @@
|
||||
el.classList.toggle('text-fb-textDim', !on);
|
||||
});
|
||||
setTopbarTitle(titleFor(screenId));
|
||||
// Immersive (full-screen) plugin screens: when the active screen belongs
|
||||
// to a plugin that opted in via `"fullscreen": true`, collapse the host
|
||||
// chrome (topbar hidden, sidebar → icon rail; see v3.css) and let the
|
||||
// plugin own the content area. Toggled here so it tracks every
|
||||
// navigation, including deep-link load and programmatic showScreen().
|
||||
const fsPluginId = (screenId && screenId.indexOf('plugin-') === 0)
|
||||
? screenId.slice('plugin-'.length) : null;
|
||||
const immersive = !!(fsPluginId && FULLSCREEN_PLUGIN_IDS.has(fsPluginId));
|
||||
document.documentElement.classList.toggle('fb-immersive', immersive);
|
||||
// Show the song search only on the library screen. Everywhere else the
|
||||
// box is irrelevant (and would silently no-op against v3Songs.search).
|
||||
const searchWrap = document.getElementById('v3-search-wrap');
|
||||
@@ -144,7 +162,7 @@
|
||||
return '<a href="#/' + entry.key + '" data-v3-nav="' + entry.key + '" ' +
|
||||
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim ' +
|
||||
'hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
|
||||
iconSvg(entry.icon) + '<span class="truncate">' + esc(labelOverride != null ? labelOverride : entry.label) + '</span></a>';
|
||||
iconSvg(entry.icon) + '<span class="truncate v3-nav-label">' + esc(labelOverride != null ? labelOverride : entry.label) + '</span></a>';
|
||||
}
|
||||
// Empty slot for a promoted plugin, anchored after a nav item. Filled by
|
||||
// renderPromotedNav() only when the plugin is installed, so an absent
|
||||
@@ -161,7 +179,7 @@
|
||||
const items = NAV.filter((n) => n.group === group);
|
||||
if (!items.length) continue;
|
||||
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
|
||||
html += '<div><div class="px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
html += '<div><div class="v3-nav-group px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
|
||||
}
|
||||
nav.innerHTML = html;
|
||||
@@ -270,6 +288,12 @@
|
||||
if (res.ok) plugins = await res.json();
|
||||
} catch (e) { return; } // degrade: no promoted slots
|
||||
const list = Array.isArray(plugins) ? plugins : [];
|
||||
// Record which installed plugins requested immersive (full-screen)
|
||||
// screens, then re-sync the active screen so the chrome collapses even
|
||||
// if we navigated to a fullscreen plugin before /api/plugins resolved.
|
||||
FULLSCREEN_PLUGIN_IDS.clear();
|
||||
for (const p of list) { if (p && p.fullscreen && p.id) FULLSCREEN_PLUGIN_IDS.add(p.id); }
|
||||
try { syncActive(currentScreenId()); } catch (e) { /* non-fatal */ }
|
||||
for (const promo of PROMOTED_PLUGINS) {
|
||||
const host = document.getElementById(promo.slotId);
|
||||
const entry = byKey(promo.navKey);
|
||||
|
||||
+879
-106
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,62 @@
|
||||
* `fb` palette in tailwind.config.js.
|
||||
*/
|
||||
|
||||
/* ── Text-selection policy (v3) ──────────────────────────────────────────────
|
||||
Accidental drag/double-click selection of app chrome (sidebar, transport, the
|
||||
note highway/HUD, buttons, labels) makes the UI look broken and is never
|
||||
useful — so default the interface to non-selectable, then opt *content* back
|
||||
in. v3-only: this sheet loads only on /v3 (v2 is unchanged). The panel's
|
||||
guardrails are baked in:
|
||||
- NEVER a `* { user-select:none }` rule — it breaks input carets / IME
|
||||
composition on WebKit (bug 82692); we scope to `html` and re-enable below.
|
||||
- This is cosmetic only; it protects nothing (DevTools defeats it) and must
|
||||
never be used to "lock" copy-worthy text away (a11y: keep errors, IDs,
|
||||
paths, versions, metadata, lyrics selectable — incl. in modals/toasts). */
|
||||
html { -webkit-user-select: none; user-select: none; }
|
||||
|
||||
/* Form fields are ALWAYS selectable/editable — protects the caret + IME
|
||||
(including CJK / dead-key composition). The default must never swallow typing.
|
||||
`.fb-selectable *` forces descendants so a child element's own non-select
|
||||
can't strand copy-worthy text inside a content island. */
|
||||
input, textarea, select,
|
||||
[contenteditable]:not([contenteditable="false"]),
|
||||
[contenteditable]:not([contenteditable="false"]) * {
|
||||
-webkit-user-select: text; user-select: text;
|
||||
}
|
||||
|
||||
/* Plugin screens are content surfaces (editor, tabview, lyrics, theory, chord
|
||||
text, …). Re-enable their mounted subtree by INHERITANCE (no `*`) so the host
|
||||
policy can't silently make a plugin's copyable text un-selectable — including
|
||||
community / out-of-tree plugins that never adopt `.fb-selectable`. A plugin
|
||||
that wants its own chrome non-selectable still wins via its own element rule
|
||||
(which this inherited value doesn't override). */
|
||||
.screen[id^="plugin-"] { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* Core read-only content opts back in by CONTAINER (lower-drift than tagging
|
||||
each value — a new setting added later inherits "selectable" for free):
|
||||
the Settings panel (values, paths, device names, version, diagnostics,
|
||||
About) and the now-playing song metadata (both tagged `.fb-selectable`).
|
||||
Plugins re-enable their own copyable regions with this same class
|
||||
(documented in CLAUDE.md).
|
||||
|
||||
The focused, transient surfaces below ALWAYS carry copy-worthy text (errors,
|
||||
IDs, file paths, device/version strings) per the a11y guardrail, so they're
|
||||
blanket-opted-in by selector rather than hand-tagged — they're single focused
|
||||
panels, not dense card lists, so re-enabling selection there can't recreate
|
||||
the across-cards marquee mess the policy prevents:
|
||||
- modals / dialogs: `.feedBack-modal`, `[role="dialog"]` (confirm, edit-meta,
|
||||
retune result/error, calibration, filter drawer);
|
||||
- toasts: `#fb-notify-stack`, `#v3-fb-toast`;
|
||||
- the library scan banner (`#scan-banner` — shows the current file path).
|
||||
(Dense card lists — the library grid, dashboard, profile — are intentionally
|
||||
left non-selectable; copy their text from the now-playing HUD / Settings.) */
|
||||
.fb-selectable, .fb-selectable *,
|
||||
.feedBack-modal, .feedBack-modal *,
|
||||
[role="dialog"], [role="dialog"] *,
|
||||
#fb-notify-stack, #fb-notify-stack *,
|
||||
#v3-fb-toast, #v3-fb-toast *,
|
||||
#scan-banner, #scan-banner * { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */
|
||||
#tuner-toggle-btn { display: none !important; }
|
||||
|
||||
@@ -425,6 +481,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;
|
||||
@@ -968,3 +1031,270 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; }
|
||||
color: #ddd6fe; background: rgba(76, 29, 149, .6);
|
||||
border: 1px solid rgba(139, 92, 246, .4); border-radius: .3rem; padding: .04rem .28rem;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Tabbed settings page (v3) — feat/v3-settings-tabbed
|
||||
Card-row layout: leading icon + title/description + right-aligned control,
|
||||
grouped under a horizontal tab bar. Pure CSS (no Tailwind rebuild); colors
|
||||
mirror the fb-* design tokens so the page matches the rest of the v3 shell.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
#settings .fb-settings { max-width: 56rem; margin: 0 auto; padding: 6rem 1.5rem 4rem; }
|
||||
.fb-settings-back {
|
||||
display: inline-flex; align-items: center; gap: .25rem;
|
||||
font-size: .8rem; color: #94a3b8; background: none; border: none; cursor: pointer;
|
||||
padding: 0; margin-bottom: 1rem; transition: color .15s;
|
||||
}
|
||||
.fb-settings-back:hover { color: #f8fafc; }
|
||||
.fb-settings-back svg { width: 1rem; height: 1rem; }
|
||||
.fb-settings-title { font-size: 1.875rem; font-weight: 800; color: #f8fafc; }
|
||||
|
||||
/* Tab bar */
|
||||
.fb-tabbar {
|
||||
display: flex; flex-wrap: wrap; gap: .25rem;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, .6);
|
||||
margin: 1.25rem 0 1.5rem;
|
||||
}
|
||||
.fb-tab {
|
||||
appearance: none; background: none; border: none; cursor: pointer;
|
||||
padding: .55rem .85rem; font-size: .85rem; font-weight: 600;
|
||||
color: #94a3b8; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px; transition: color .15s, border-color .15s; white-space: nowrap;
|
||||
}
|
||||
.fb-tab:hover { color: #e2e8f0; }
|
||||
.fb-tab.active { color: #f8fafc; border-bottom-color: #0ea5e9; }
|
||||
|
||||
/* Panels */
|
||||
.fb-tabpanel { display: none; }
|
||||
.fb-tabpanel.active { display: block; }
|
||||
.fb-tabpanel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
||||
.fb-tabpanel-head h3 { font-size: 1.1rem; font-weight: 700; color: #f8fafc; }
|
||||
|
||||
/* Card rows */
|
||||
.fb-srows { display: flex; flex-direction: column; gap: .6rem; }
|
||||
.fb-srow {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
background: #1e293b; border: 1px solid rgba(51, 65, 85, .55);
|
||||
border-radius: .75rem; padding: .85rem 1rem;
|
||||
}
|
||||
.fb-srow-stack { flex-direction: column; align-items: stretch; gap: .65rem; }
|
||||
.fb-srow-icon {
|
||||
flex: none; width: 2.25rem; height: 2.25rem; border-radius: .6rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(14, 165, 233, .12); color: #38bdf8;
|
||||
}
|
||||
.fb-srow-icon svg { width: 1.15rem; height: 1.15rem; }
|
||||
.fb-srow-main { flex: 1 1 auto; min-width: 0; }
|
||||
.fb-srow-title { font-size: .9rem; font-weight: 600; color: #e2e8f0; display: flex; align-items: center; }
|
||||
.fb-srow-desc { font-size: .75rem; color: #94a3b8; margin-top: .15rem; }
|
||||
.fb-srow-control { flex: none; display: flex; align-items: center; gap: .5rem; }
|
||||
.fb-srow-control select,
|
||||
.fb-srow-control input[type="text"] { min-width: 11rem; }
|
||||
.fb-srow-stack .fb-srow-control { width: 100%; }
|
||||
.fb-srow-stack .fb-srow-control input[type="text"] { flex: 1 1 auto; min-width: 0; }
|
||||
.fb-srow-wide { width: 100%; }
|
||||
|
||||
/* Toggle switch */
|
||||
.fb-switch { position: relative; display: inline-block; width: 2.6rem; height: 1.5rem; flex: none; }
|
||||
.fb-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.fb-switch .fb-switch-track {
|
||||
position: absolute; inset: 0; cursor: pointer;
|
||||
background: #334155; border-radius: 999px; transition: background .15s;
|
||||
}
|
||||
.fb-switch .fb-switch-track::before {
|
||||
content: ""; position: absolute; height: 1.1rem; width: 1.1rem; left: .2rem; top: .2rem;
|
||||
background: #f8fafc; border-radius: 50%; transition: transform .15s;
|
||||
}
|
||||
.fb-switch input:checked + .fb-switch-track { background: #0ea5e9; }
|
||||
.fb-switch input:checked + .fb-switch-track::before { transform: translateX(1.1rem); }
|
||||
.fb-switch input:focus-visible + .fb-switch-track { box-shadow: 0 0 0 2px rgba(56, 189, 248, .5); }
|
||||
|
||||
/* "Not yet active" stub badge */
|
||||
.fb-stub-note {
|
||||
font-size: .6rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: #fde68a; background: rgba(234, 179, 8, .12);
|
||||
border-radius: 999px; padding: .1rem .45rem; margin-left: .5rem; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Per-category reset */
|
||||
.fb-reset-btn {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
font-size: .8rem; color: #94a3b8; background: none; border: none; cursor: pointer;
|
||||
padding: .35rem .5rem; border-radius: .5rem; transition: color .15s, background .15s;
|
||||
}
|
||||
.fb-reset-btn:hover { color: #fca5a5; background: rgba(239, 68, 68, .08); }
|
||||
.fb-reset-btn svg { width: .9rem; height: .9rem; }
|
||||
|
||||
/* Keybinds reference */
|
||||
.fb-kbd {
|
||||
display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: .72rem; color: #e2e8f0; background: #0b1220;
|
||||
border: 1px solid rgba(51, 65, 85, .8); border-bottom-width: 2px;
|
||||
border-radius: .35rem; padding: .1rem .4rem; min-width: 1.4rem; text-align: center;
|
||||
}
|
||||
.fb-kbd-group-title {
|
||||
font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: #94a3b8; margin: 1.25rem 0 .5rem;
|
||||
}
|
||||
.fb-settings-note { font-size: .75rem; color: #64748b; margin-top: 1rem; }
|
||||
.fb-tabpanel-empty { font-size: .8rem; color: #64748b; padding: .5rem 0; }
|
||||
|
||||
/* ── Immersive (full-screen) plugin screens ──────────────────────────────────
|
||||
A plugin that declares `"fullscreen": true` in its manifest gets the whole
|
||||
content area when its screen is active. shell.js toggles `html.fb-immersive`
|
||||
on navigation (see syncActive). DAW-style plugin UIs (e.g. a practice studio)
|
||||
need the viewport, not a scrolling content page below the topbar — the cause
|
||||
of the "cut off at the bottom / too much top padding" reports on the embedded
|
||||
layout. Mirrors the proven chrome-hide pattern in static/v3/index.html
|
||||
(`html.ss-follower-pre …`), but keeps the sidebar as a functional icon rail
|
||||
so the user is never trapped (Escape is bound only on player/settings
|
||||
scopes, not plugin screens). */
|
||||
html.fb-immersive #v3-topbar { display: none !important; }
|
||||
|
||||
/* Sidebar → icon rail: narrow it, drop the wordmark + group headers + labels,
|
||||
center the remaining icons. The .v3-nav-label / .v3-nav-group hooks are added
|
||||
by shell.js so these rules don't depend on Tailwind utility class strings. */
|
||||
html.fb-immersive #v3-sidebar { width: 4.5rem; }
|
||||
html.fb-immersive #v3-brand { display: none; }
|
||||
html.fb-immersive #v3-nav { padding-left: .5rem; padding-right: .5rem; }
|
||||
html.fb-immersive #v3-nav .v3-nav-group,
|
||||
html.fb-immersive #v3-nav .v3-nav-label { display: none; }
|
||||
html.fb-immersive #v3-nav a {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* The active plugin screen fills the now-topbar-less content area. #v3-main is
|
||||
position:relative, so pinning the screen avoids relying on the auto-height
|
||||
block flow that made a 100vh plugin overflow #v3-main and force a scroll. */
|
||||
html.fb-immersive #v3-main > .screen.active {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* — A–Z jump rail (v3 Songs grid; static/v3/songs.js) — */
|
||||
/* Fixed to the right edge next to the scroller's scrollbar; vertically
|
||||
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
|
||||
.v3-azrail {
|
||||
position: fixed;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-height: 84vh;
|
||||
padding: 4px 1px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none; /* let a drag scrub the rail without scrolling the page */
|
||||
}
|
||||
.v3-azrail-letter {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #94a3b8; /* fb-textDim */
|
||||
font-size: .62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
padding: 1px 4px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.v3-azrail-letter:hover:not([disabled]),
|
||||
.v3-azrail-letter.is-active {
|
||||
color: #0ea5e9; /* fb-primary */
|
||||
}
|
||||
.v3-azrail-letter:focus-visible {
|
||||
outline: 2px solid #38bdf8; /* fb-primaryHi */
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.v3-azrail-letter[disabled] {
|
||||
color: rgba(148, 163, 184, .28);
|
||||
cursor: default;
|
||||
}
|
||||
/* Drag indicator bubble (Android fast-scroll pattern). */
|
||||
.v3-azbubble {
|
||||
position: fixed;
|
||||
right: 2.6rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 26;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: .7rem;
|
||||
background: #0ea5e9; /* fb-primary */
|
||||
color: #f8fafc; /* fb-text */
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, .45);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3-azrail.hidden,
|
||||
.v3-azbubble.hidden { display: none; }
|
||||
/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge.
|
||||
Tighten it; a collapse-to-anchors pass is a follow-up. */
|
||||
@media (max-height: 640px) {
|
||||
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
|
||||
}
|
||||
|
||||
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
|
||||
#v3-lib-home.hidden { display: none; }
|
||||
.v3-rep-meter { max-width: 30rem; }
|
||||
.v3-rep-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, .22); /* fb-textDim @ low alpha */
|
||||
overflow: hidden;
|
||||
}
|
||||
.v3-rep-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: #0ea5e9; /* fb-primary */
|
||||
transition: width .4s ease;
|
||||
}
|
||||
/* Horizontal, scroll-snapping shelf of fixed-width cards. */
|
||||
.v3-kp-row {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x proximity;
|
||||
padding-bottom: 6px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.v3-kp-card {
|
||||
flex: 0 0 8.5rem;
|
||||
width: 8.5rem;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
/* — Windowed (virtualized) Songs grid (#636 item 3 stage 2) — */
|
||||
/* The grid is absolutely positioned inside #v3-songs-gridsizer, whose height is
|
||||
set to the FULL library (ceil(total/cols)*rowH) so the scrollbar reflects the
|
||||
whole library while only the visible window's cards are in the DOM. The inline
|
||||
`top` (set by renderWindow) offsets the window to the first visible row. */
|
||||
.v3-grid-window {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
/* The arrangement-chip row is rendered on EVERY card (even when empty) at a fixed
|
||||
single-line height — uniform card height is what makes the window's
|
||||
absolute-position math exact. Extra chips are clipped rather than wrapping. */
|
||||
.v3-card-chips {
|
||||
height: 1.5rem;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
/* Skeleton placeholder shown only if a window's fetch hasn't landed; mirrors a
|
||||
real card's vertical structure so it occupies an identical row height. */
|
||||
.v3-card-skel { pointer-events: none; }
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
Clock,
|
||||
HalfFloatType,
|
||||
NoBlending,
|
||||
Vector2,
|
||||
WebGLRenderTarget
|
||||
} from '../../three.module.min.js';
|
||||
import { CopyShader } from '../shaders/CopyShader.js';
|
||||
import { ShaderPass } from './ShaderPass.js';
|
||||
import { MaskPass } from './MaskPass.js';
|
||||
import { ClearMaskPass } from './MaskPass.js';
|
||||
|
||||
class EffectComposer {
|
||||
|
||||
constructor( renderer, renderTarget ) {
|
||||
|
||||
this.renderer = renderer;
|
||||
|
||||
this._pixelRatio = renderer.getPixelRatio();
|
||||
|
||||
if ( renderTarget === undefined ) {
|
||||
|
||||
const size = renderer.getSize( new Vector2() );
|
||||
this._width = size.width;
|
||||
this._height = size.height;
|
||||
|
||||
renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
|
||||
renderTarget.texture.name = 'EffectComposer.rt1';
|
||||
|
||||
} else {
|
||||
|
||||
this._width = renderTarget.width;
|
||||
this._height = renderTarget.height;
|
||||
|
||||
}
|
||||
|
||||
this.renderTarget1 = renderTarget;
|
||||
this.renderTarget2 = renderTarget.clone();
|
||||
this.renderTarget2.texture.name = 'EffectComposer.rt2';
|
||||
|
||||
this.writeBuffer = this.renderTarget1;
|
||||
this.readBuffer = this.renderTarget2;
|
||||
|
||||
this.renderToScreen = true;
|
||||
|
||||
this.passes = [];
|
||||
|
||||
this.copyPass = new ShaderPass( CopyShader );
|
||||
this.copyPass.material.blending = NoBlending;
|
||||
|
||||
this.clock = new Clock();
|
||||
|
||||
}
|
||||
|
||||
swapBuffers() {
|
||||
|
||||
const tmp = this.readBuffer;
|
||||
this.readBuffer = this.writeBuffer;
|
||||
this.writeBuffer = tmp;
|
||||
|
||||
}
|
||||
|
||||
addPass( pass ) {
|
||||
|
||||
this.passes.push( pass );
|
||||
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
|
||||
|
||||
}
|
||||
|
||||
insertPass( pass, index ) {
|
||||
|
||||
this.passes.splice( index, 0, pass );
|
||||
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
|
||||
|
||||
}
|
||||
|
||||
removePass( pass ) {
|
||||
|
||||
const index = this.passes.indexOf( pass );
|
||||
|
||||
if ( index !== - 1 ) {
|
||||
|
||||
this.passes.splice( index, 1 );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
isLastEnabledPass( passIndex ) {
|
||||
|
||||
for ( let i = passIndex + 1; i < this.passes.length; i ++ ) {
|
||||
|
||||
if ( this.passes[ i ].enabled ) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
render( deltaTime ) {
|
||||
|
||||
// deltaTime value is in seconds
|
||||
|
||||
if ( deltaTime === undefined ) {
|
||||
|
||||
deltaTime = this.clock.getDelta();
|
||||
|
||||
}
|
||||
|
||||
const currentRenderTarget = this.renderer.getRenderTarget();
|
||||
|
||||
let maskActive = false;
|
||||
|
||||
for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
|
||||
|
||||
const pass = this.passes[ i ];
|
||||
|
||||
if ( pass.enabled === false ) continue;
|
||||
|
||||
pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
|
||||
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
|
||||
|
||||
if ( pass.needsSwap ) {
|
||||
|
||||
if ( maskActive ) {
|
||||
|
||||
const context = this.renderer.getContext();
|
||||
const stencil = this.renderer.state.buffers.stencil;
|
||||
|
||||
//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
|
||||
stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
|
||||
|
||||
this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
|
||||
|
||||
//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
|
||||
stencil.setFunc( context.EQUAL, 1, 0xffffffff );
|
||||
|
||||
}
|
||||
|
||||
this.swapBuffers();
|
||||
|
||||
}
|
||||
|
||||
if ( MaskPass !== undefined ) {
|
||||
|
||||
if ( pass instanceof MaskPass ) {
|
||||
|
||||
maskActive = true;
|
||||
|
||||
} else if ( pass instanceof ClearMaskPass ) {
|
||||
|
||||
maskActive = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
this.renderer.setRenderTarget( currentRenderTarget );
|
||||
|
||||
}
|
||||
|
||||
reset( renderTarget ) {
|
||||
|
||||
if ( renderTarget === undefined ) {
|
||||
|
||||
const size = this.renderer.getSize( new Vector2() );
|
||||
this._pixelRatio = this.renderer.getPixelRatio();
|
||||
this._width = size.width;
|
||||
this._height = size.height;
|
||||
|
||||
renderTarget = this.renderTarget1.clone();
|
||||
renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
|
||||
|
||||
}
|
||||
|
||||
this.renderTarget1.dispose();
|
||||
this.renderTarget2.dispose();
|
||||
this.renderTarget1 = renderTarget;
|
||||
this.renderTarget2 = renderTarget.clone();
|
||||
|
||||
this.writeBuffer = this.renderTarget1;
|
||||
this.readBuffer = this.renderTarget2;
|
||||
|
||||
}
|
||||
|
||||
setSize( width, height ) {
|
||||
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
|
||||
const effectiveWidth = this._width * this._pixelRatio;
|
||||
const effectiveHeight = this._height * this._pixelRatio;
|
||||
|
||||
this.renderTarget1.setSize( effectiveWidth, effectiveHeight );
|
||||
this.renderTarget2.setSize( effectiveWidth, effectiveHeight );
|
||||
|
||||
for ( let i = 0; i < this.passes.length; i ++ ) {
|
||||
|
||||
this.passes[ i ].setSize( effectiveWidth, effectiveHeight );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setPixelRatio( pixelRatio ) {
|
||||
|
||||
this._pixelRatio = pixelRatio;
|
||||
|
||||
this.setSize( this._width, this._height );
|
||||
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
this.renderTarget1.dispose();
|
||||
this.renderTarget2.dispose();
|
||||
|
||||
this.copyPass.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { EffectComposer };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,124 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Opt-in "Ask before leaving a song" confirm. Default OFF → Escape/✕ leave
|
||||
// instantly. When ON, a true-modal confirm appears and PAUSES the song; Escape
|
||||
// (like every other modal) DISMISSES it → Stay, so a second Escape returns to
|
||||
// the song rather than leaving, and Space/Enter activate the default-focused
|
||||
// "Leave". (The mock song has no backing audio, so the pause-on-open /
|
||||
// resume-on-Stay is verified manually on web + desktop; these specs lock the
|
||||
// navigation + keyboard semantics.)
|
||||
|
||||
const CONFIRM_KEY = 'confirmExitSong';
|
||||
|
||||
async function installMockSong(page) {
|
||||
await page.evaluate(() => {
|
||||
const messages = [
|
||||
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
|
||||
{ type: 'ready' },
|
||||
];
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen = null; onmessage = null; onerror = null; onclose = null; url;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event('open'));
|
||||
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
|
||||
}, 0);
|
||||
}
|
||||
send() {}
|
||||
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
|
||||
}
|
||||
// @ts-ignore
|
||||
window.WebSocket = MockWebSocket;
|
||||
});
|
||||
}
|
||||
|
||||
async function openPlayerWithMockSong(page) {
|
||||
await installMockSong(page);
|
||||
await page.evaluate(async () => { /* @ts-ignore */ await window.playSong('mock-song.sloppak'); });
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Exit-confirm toggle', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (a modal that intercepts
|
||||
// pointer/keyboard events) so Escape reaches the player, not the overlay.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate((k) => localStorage.removeItem(k), CONFIRM_KEY);
|
||||
});
|
||||
|
||||
test('default OFF: Escape exits the song immediately, no confirm', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('ON: Escape opens the confirm and the song stays', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
// "Leave" is focused so Space/Enter leaves immediately.
|
||||
await expect(page.locator('#fb-exit-confirm button', { hasText: 'Leave' })).toBeFocused();
|
||||
});
|
||||
|
||||
test('ON: a second Escape dismisses the prompt and stays in the song', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
// Escape = dismiss (Stay), matching every other modal — NOT leave.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('ON: clicking the backdrop dismisses the prompt and stays', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
// mousedown on the overlay backdrop (top-left, away from the centered card)
|
||||
// is Stay — never an accidental leave.
|
||||
await page.locator('#fb-exit-confirm').click({ position: { x: 5, y: 5 } });
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('ON: "Stay" keeps you in the song; "Leave" exits', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#fb-exit-confirm button', { hasText: 'Stay' }).click();
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#fb-exit-confirm button', { hasText: 'Leave' }).click();
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('ON: Enter on the default-focused "Leave" leaves', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,14 @@ async function openPlayerWithMockSong(page) {
|
||||
|
||||
test.describe('Keyboard Shortcuts', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (#v3-onboarding) — a modal that
|
||||
// intercepts pointer/keyboard events — so the app behaves like a returning
|
||||
// user, which is the state these tests assume.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
});
|
||||
@@ -90,7 +98,6 @@ test.describe('Keyboard Shortcuts', () => {
|
||||
const required = [
|
||||
{ key: '?', scope: 'global' },
|
||||
{ key: '/', scope: 'library' },
|
||||
{ key: 'c', scope: 'library' },
|
||||
{ key: 'f', scope: 'library' },
|
||||
{ key: 'e', scope: 'library' },
|
||||
{ key: 'Space', scope: 'player' },
|
||||
@@ -125,9 +132,7 @@ test.describe('Keyboard Shortcuts', () => {
|
||||
// Library shortcuts should be visible on library screen
|
||||
await expect(modal).toContainText('Focus search');
|
||||
await expect(modal).toContainText('/');
|
||||
await expect(modal).toContainText('Convert library entry');
|
||||
await expect(modal).toContainText('c');
|
||||
|
||||
|
||||
// Player shortcuts should NOT be visible on library screen
|
||||
await expect(modal).not.toContainText('Play/Pause');
|
||||
});
|
||||
@@ -149,7 +154,6 @@ test.describe('Keyboard Shortcuts', () => {
|
||||
const expectedShortcuts = [
|
||||
{ key: '?', scope: 'global' },
|
||||
{ key: '/', scope: 'library' },
|
||||
{ key: 'c', scope: 'library' },
|
||||
{ key: 'f', scope: 'library' },
|
||||
{ key: 'e', scope: 'library' },
|
||||
{ key: 'Space', scope: 'player' },
|
||||
@@ -651,6 +655,314 @@ test('should support condition callbacks', async ({ page }) => {
|
||||
expect(result.calledByKey).toBe(true);
|
||||
});
|
||||
|
||||
test('Space toggles play/pause when a player rail button is focused (#593)', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// Inject a focusable <button> into the player (the bug: BUTTON elements
|
||||
// are "interactive controls" so Space was blocked before reaching the
|
||||
// shortcut dispatcher) and spy on the player-scope Space shortcut so the
|
||||
// assertion does not depend on the real audio path. The dispatcher calls
|
||||
// preventDefault() before the handler, so the focused button must NOT
|
||||
// also activate.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__spacePlayCount = 0;
|
||||
// @ts-ignore
|
||||
window.__railBtnClicked = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Space',
|
||||
description: 'Play/Pause (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__spacePlayCount++; },
|
||||
});
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-rail-btn';
|
||||
btn.textContent = 'Mixer';
|
||||
// @ts-ignore
|
||||
btn.addEventListener('click', () => { window.__railBtnClicked++; });
|
||||
document.getElementById('player')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.locator('#__test-rail-btn').focus();
|
||||
await expect(page.locator('#__test-rail-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const result = await page.evaluate(() => ({
|
||||
// @ts-ignore
|
||||
played: window.__spacePlayCount,
|
||||
// @ts-ignore
|
||||
clicked: window.__railBtnClicked,
|
||||
}));
|
||||
// Play/pause fired despite the button holding focus…
|
||||
expect(result.played).toBe(1);
|
||||
// …and the focused button did not also activate (dispatcher preventDefault()).
|
||||
expect(result.clicked).toBe(0);
|
||||
});
|
||||
|
||||
test('Space in a player-screen text input still types a space, not play/pause (#593)', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The text-input exemption (_isTextInput) is checked before the player
|
||||
// Space carve-out, so typing space in an input must never toggle playback.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__spacePlayCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Space',
|
||||
description: 'Play/Pause (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__spacePlayCount++; },
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = '__test-player-input';
|
||||
document.getElementById('player')!.appendChild(input);
|
||||
});
|
||||
|
||||
await page.locator('#__test-player-input').focus();
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const result = await page.evaluate(() => ({
|
||||
// @ts-ignore
|
||||
played: window.__spacePlayCount,
|
||||
value: (document.getElementById('__test-player-input') as HTMLInputElement).value,
|
||||
}));
|
||||
expect(result.played).toBe(0);
|
||||
expect(result.value).toBe(' ');
|
||||
});
|
||||
|
||||
test('Space inside a modal dialog over the player reaches the modal, not play/pause (#593)', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// A true modal dialog (role="dialog" aria-modal="true" / .feedBack-modal)
|
||||
// layered over the player must trap interaction: Space activates the
|
||||
// modal's focused control (native), it does NOT toggle playback behind it.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__spacePlayCount = 0;
|
||||
// @ts-ignore
|
||||
window.__modalBtnClicked = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Space',
|
||||
description: 'Play/Pause (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__spacePlayCount++; },
|
||||
});
|
||||
const modal = document.createElement('div');
|
||||
modal.id = '__test-modal';
|
||||
modal.className = 'feedBack-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-modal-btn';
|
||||
btn.textContent = 'Close';
|
||||
// @ts-ignore
|
||||
btn.addEventListener('click', () => { window.__modalBtnClicked++; });
|
||||
modal.appendChild(btn);
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
|
||||
await page.locator('#__test-modal-btn').focus();
|
||||
await expect(page.locator('#__test-modal-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const result = await page.evaluate(() => ({
|
||||
// @ts-ignore
|
||||
played: window.__spacePlayCount,
|
||||
// @ts-ignore
|
||||
clicked: window.__modalBtnClicked,
|
||||
}));
|
||||
// Playback is NOT toggled behind the modal…
|
||||
expect(result.played).toBe(0);
|
||||
// …and Space activated the modal's focused button natively.
|
||||
expect(result.clicked).toBe(1);
|
||||
});
|
||||
|
||||
// ── Escape = universal "Back" carve-out ──────────────────────────────────
|
||||
// Escape must escape a focused non-modal control exactly like Space does,
|
||||
// so a focused transport/rail button can't swallow it ("Escape in song not
|
||||
// consistent"). These mirror the #593 Space tests above. Each registers an
|
||||
// Escape spy in the relevant scope (which replaces the built-in handler for
|
||||
// that composite key) so the assertion doesn't depend on showScreen teardown.
|
||||
|
||||
test('Escape exits the song when a player rail button is focused', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The bug: a focused <button> is an "interactive control", so Escape was
|
||||
// blocked before reaching the dispatcher and the song wouldn't exit until
|
||||
// the user clicked empty canvas to blur the control.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-rail-btn';
|
||||
btn.textContent = 'Restart';
|
||||
document.getElementById('player')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.locator('#__test-rail-btn').focus();
|
||||
await expect(page.locator('#__test-rail-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Back-to-library fired despite the control button holding focus.
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('Escape in a player-screen text input does NOT exit the song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The text-input exemption (_isTextInput) is checked before the Escape
|
||||
// carve-out, so Escape in a field is the field's own concern (clear/blur),
|
||||
// never a song exit.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = '__test-player-input';
|
||||
document.getElementById('player')!.appendChild(input);
|
||||
});
|
||||
|
||||
await page.locator('#__test-player-input').focus();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape inside a modal over the player closes the modal, not back-to-library', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// A true modal (role="dialog" aria-modal="true" / .feedBack-modal) layered
|
||||
// over the player is a focus trap: Escape there must NOT eject past it to
|
||||
// exit the song — the modal owns Escape. The carve-out's modal-overlay
|
||||
// guard keeps the player-back shortcut from firing.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const modal = document.createElement('div');
|
||||
modal.id = '__test-modal';
|
||||
modal.className = 'feedBack-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-modal-btn';
|
||||
btn.textContent = 'Close';
|
||||
modal.appendChild(btn);
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
|
||||
await page.locator('#__test-modal-btn').focus();
|
||||
await expect(page.locator('#__test-modal-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Playback is NOT exited behind the modal.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape does NOT exit the song while the Section Practice popover is open', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The Section Practice popover claims Escape earlier in
|
||||
// _shortcutDispatchBlocked (line ~447, before the Escape carve-out), so an
|
||||
// open popover suppresses the player-scope back-to-library Escape — the
|
||||
// popover's own handler owns closing it. This locks that ordering guard.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
let bar = document.getElementById('section-practice-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'section-practice-bar';
|
||||
document.getElementById('player')!.appendChild(bar);
|
||||
}
|
||||
bar.classList.add('section-practice-bar--open');
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Player-back did NOT fire while the popover was open.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape goes back from settings when a control is focused (twin-bug)', async ({ page }) => {
|
||||
// The same focus bug existed on the settings screen (the carve-out was
|
||||
// player-only). The fix covers settings too: Escape returns to the
|
||||
// previous screen even when a settings control holds focus.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escSettingsBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Go back from settings (test spy)',
|
||||
scope: 'settings',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escSettingsBackCount++; },
|
||||
});
|
||||
// @ts-ignore
|
||||
window.showScreen('settings');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-settings-btn';
|
||||
btn.textContent = 'Some setting';
|
||||
document.getElementById('settings')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.waitForSelector('#settings.active', { timeout: 5000 });
|
||||
await page.locator('#__test-settings-btn').focus();
|
||||
await expect(page.locator('#__test-settings-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escSettingsBackCount);
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should warn on invalid scope', async ({ page }) => {
|
||||
const messages: string[] = [];
|
||||
page.on('console', msg => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Resume-last-session: leaving the player snapshots {song, arrangement,
|
||||
// position, speed} so an exit is recoverable via a non-blocking "Resume" pill.
|
||||
// These exercise the deterministic plumbing (snapshot guards, staleness, the
|
||||
// pill, and resume consumption) without depending on real audio timing.
|
||||
|
||||
const RESUME_KEY = 'feedBack.resumeSession';
|
||||
|
||||
// Make playSong()'s WebSocket a no-network mock that emits a song_info + ready.
|
||||
async function installMockSong(page) {
|
||||
await page.evaluate(() => {
|
||||
const messages = [
|
||||
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
|
||||
{ type: 'ready' },
|
||||
];
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen = null; onmessage = null; onerror = null; onclose = null; url;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event('open'));
|
||||
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
|
||||
}, 0);
|
||||
}
|
||||
send() {}
|
||||
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
|
||||
}
|
||||
// @ts-ignore
|
||||
window.WebSocket = MockWebSocket;
|
||||
});
|
||||
}
|
||||
|
||||
async function openPlayerWithMockSong(page) {
|
||||
await installMockSong(page);
|
||||
await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
await window.playSong('mock-song.sloppak');
|
||||
});
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Resume last session', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (a modal that intercepts
|
||||
// pointer/keyboard events) so the player isn't covered.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate((k) => localStorage.removeItem(k), RESUME_KEY);
|
||||
});
|
||||
|
||||
test('snapshots song + arrangement + position once you are mid-song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
const snap = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(30);
|
||||
// @ts-ignore
|
||||
return window._readResumeSession();
|
||||
});
|
||||
expect(snap).not.toBeNull();
|
||||
expect(snap.f).toBe('mock-song.sloppak');
|
||||
expect(snap.a).toBe(0);
|
||||
expect(Math.round(snap.t)).toBe(30);
|
||||
expect(snap.title).toBe('Mock Song');
|
||||
});
|
||||
|
||||
test('does NOT snapshot a barely-started or basically-finished song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
const result = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(1); // < 3s min → ignored
|
||||
// @ts-ignore
|
||||
const tooEarly = window._readResumeSession();
|
||||
// duration is 90; end-guard is 5s, so 88 > 85 → ignored
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(88);
|
||||
// @ts-ignore
|
||||
const tooLate = window._readResumeSession();
|
||||
return { tooEarly, tooLate };
|
||||
});
|
||||
expect(result.tooEarly).toBeNull();
|
||||
expect(result.tooLate).toBeNull();
|
||||
});
|
||||
|
||||
test('a stale (>24h) snapshot is ignored', async ({ page }) => {
|
||||
const got = await page.evaluate((k) => {
|
||||
const old = { f: 'old.sloppak', a: 0, t: 42, sp: 1, title: 'Old', ts: Date.now() - 25 * 60 * 60 * 1000 };
|
||||
localStorage.setItem(k, JSON.stringify(old));
|
||||
// @ts-ignore
|
||||
return window._readResumeSession();
|
||||
}, RESUME_KEY);
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
test('the Resume pill appears off-player and hides on the player', async ({ page }) => {
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', artist: 'Mock Artist', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
// @ts-ignore
|
||||
window.feedBack._maybeShowResumePill();
|
||||
}, RESUME_KEY);
|
||||
|
||||
await expect(page.locator('#fb-resume-pill')).toBeVisible();
|
||||
await expect(page.locator('#fb-resume-pill')).toContainText('Mock Song');
|
||||
|
||||
// Entering the player hides it (screen:changed → _hideResumePill()).
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.showScreen('player'); });
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dismissing the pill removes it and does not re-show it this session', async ({ page }) => {
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
// @ts-ignore
|
||||
window.feedBack._maybeShowResumePill();
|
||||
}, RESUME_KEY);
|
||||
|
||||
await expect(page.locator('#fb-resume-pill')).toBeVisible();
|
||||
await page.locator('#fb-resume-pill button[aria-label="Dismiss"]').click();
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
|
||||
// A re-offer attempt within the same session is suppressed.
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.feedBack._maybeShowResumePill(); });
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('resumeLastSession() re-enters the song and consumes the snapshot', async ({ page }) => {
|
||||
await installMockSong(page);
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
}, RESUME_KEY);
|
||||
|
||||
await page.evaluate(async () => { /* @ts-ignore */ await window.resumeLastSession(); });
|
||||
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
// The snapshot is consumed (cleared) so it isn't offered again.
|
||||
const remaining = await page.evaluate((k) => localStorage.getItem(k), RESUME_KEY);
|
||||
expect(remaining).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Verifies the v3 tabbed settings page (feat/v3-settings-tabbed): the tab bar
|
||||
// renders, tabs switch panels, the active tab persists, existing controls
|
||||
// still hydrate from /api/settings, the new countdown toggle persists, and the
|
||||
// per-category reset hits /api/settings/reset.
|
||||
|
||||
interface SettingsPayload {
|
||||
dlc_dir: string;
|
||||
default_arrangement: string;
|
||||
demucs_server_url: string;
|
||||
master_difficulty: number;
|
||||
av_offset_ms: number;
|
||||
countdown_before_song: boolean;
|
||||
miss_penalty: string;
|
||||
fail_behavior: string;
|
||||
}
|
||||
|
||||
const basePayload: SettingsPayload = {
|
||||
dlc_dir: '',
|
||||
default_arrangement: 'Rhythm',
|
||||
demucs_server_url: '',
|
||||
master_difficulty: 70,
|
||||
av_offset_ms: 0,
|
||||
countdown_before_song: false,
|
||||
miss_penalty: 'none',
|
||||
fail_behavior: 'continue',
|
||||
};
|
||||
|
||||
// A fresh profile shows the blocking onboarding overlay; onboard via the API
|
||||
// so the tab clicks below aren't intercepted (idempotent once onboarded).
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/api/profile', { data: { display_name: 'Settings Tester' } });
|
||||
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
|
||||
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
|
||||
});
|
||||
|
||||
// Open the v3 settings screen with the first-run onboarding overlay neutralised
|
||||
// (the API skip in beforeEach handles the common path; this also hides the
|
||||
// overlay element so a slow async profile render can't intercept tab clicks).
|
||||
async function openSettings(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('#settings-tabbar', { state: 'attached' });
|
||||
await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' });
|
||||
await page.evaluate(() => (window as any).showScreen('settings'));
|
||||
}
|
||||
|
||||
async function mockSettings(page, posts: any[], resets: any[]) {
|
||||
await page.route('**/api/settings', async route => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: basePayload });
|
||||
return;
|
||||
}
|
||||
posts.push(route.request().postDataJSON());
|
||||
await route.fulfill({ json: { message: 'Settings saved' } });
|
||||
});
|
||||
await page.route('**/api/settings/reset', async route => {
|
||||
resets.push(route.request().postDataJSON());
|
||||
await route.fulfill({ json: { message: 'Settings reset', reset: [] } });
|
||||
});
|
||||
}
|
||||
|
||||
test('tab bar renders the settings tabs and Gameplay is default', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
const tabs = await page.locator('#settings-tabbar .fb-tab').allTextContents();
|
||||
expect(tabs).toEqual(['Gameplay', 'Audio', 'Graphics', 'Keybinds', 'Progression', 'Mic', 'Plugins', 'System']);
|
||||
|
||||
// Gameplay panel is active by default and its controls are present.
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).toHaveClass(/active/);
|
||||
await expect(page.locator('#setting-lefty')).toBeAttached();
|
||||
await expect(page.locator('#setting-countdown-before-song')).toBeAttached();
|
||||
});
|
||||
|
||||
test('clicking a tab switches the visible panel', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('#settings-tabbar .fb-tab[data-tab="audio"]').click();
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="audio"]')).toHaveClass(/active/);
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).not.toHaveClass(/active/);
|
||||
await expect(page.locator('#setting-live-guitar-tone-source')).toBeVisible();
|
||||
});
|
||||
|
||||
test('active tab persists across reload', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
await page.locator('#settings-tabbar .fb-tab[data-tab="system"]').click();
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="system"]')).toHaveClass(/active/);
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector('#settings-tabbar', { state: 'attached' });
|
||||
// Restored from localStorage even before navigating back to settings.
|
||||
await expect(page.locator('#settings-tabbar .fb-tab[data-tab="system"]')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('existing controls hydrate from /api/settings', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.locator('#default-arrangement')).toHaveValue('Rhythm');
|
||||
// Note highway speed shares master_difficulty (70 in the mock).
|
||||
await expect(page.locator('#setting-highway-speed')).toHaveValue('70');
|
||||
await expect(page.locator('#setting-highway-speed-val')).toHaveText('70'); // span holds number; '%' is literal in markup
|
||||
});
|
||||
|
||||
test('countdown toggle persists countdown_before_song', async ({ page }) => {
|
||||
const posts: any[] = [];
|
||||
await mockSettings(page, posts, []);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('label.fb-switch:has(#setting-countdown-before-song) .fb-switch-track').click();
|
||||
await expect.poll(() => posts.some(p => p && p.countdown_before_song === true)).toBe(true);
|
||||
});
|
||||
|
||||
test('reset gameplay posts to /api/settings/reset', async ({ page }) => {
|
||||
const resets: any[] = [];
|
||||
await mockSettings(page, [], resets);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('[data-reset="gameplay"]').click();
|
||||
// _confirmDialog modal — confirm it.
|
||||
await page.locator('.slopsmith-modal [data-confirm]').click();
|
||||
|
||||
await expect.poll(() => resets.length).toBeGreaterThan(0);
|
||||
expect(resets[0].keys).toContain('countdown_before_song');
|
||||
expect(resets[0].keys).toContain('master_difficulty');
|
||||
});
|
||||
|
||||
test('keybinds tab renders the shortcut reference', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('#settings-tabbar .fb-tab[data-tab="keybinds"]').click();
|
||||
// Either real shortcuts (kbd chips) or the empty-state note — never blank.
|
||||
await expect(page.locator('#settings-keybinds')).not.toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3
|
||||
// stage 2). Before virtualization the grid appended every scrolled page, so for
|
||||
// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001).
|
||||
// Now only the visible window (± overscan) is ever in the DOM while a sizer
|
||||
// element gives the scrollbar the full-library geometry.
|
||||
//
|
||||
// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is
|
||||
// deterministic in CI without a seeded 2000-row library: /api/library serves a
|
||||
// synthetic page from the page/after param with total 2001, and the keyset
|
||||
// cursor is mocked as the next absolute offset.
|
||||
|
||||
const TOTAL = 2001;
|
||||
const PAGE_SIZE = 24;
|
||||
const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
// Same bucketing as the seed/server: index % 26 → a first letter, so the A–Z
|
||||
// rail has real buckets and a jump has somewhere to land.
|
||||
function songAt(i: number) {
|
||||
const letter = COLS[i % 26];
|
||||
return {
|
||||
filename: `seed/${String(i).padStart(5, '0')}.sloppak`,
|
||||
title: `Song ${String(i).padStart(4, '0')}`,
|
||||
artist: `${letter}Band ${String(i).padStart(4, '0')}`,
|
||||
album: `${letter} Album`,
|
||||
format: 'sloppak',
|
||||
arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }],
|
||||
};
|
||||
}
|
||||
|
||||
// sort_letters song-counts per bucket for index%26 over [0, TOTAL).
|
||||
function sortLetters() {
|
||||
const m: Record<string, number> = {};
|
||||
for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; }
|
||||
return m;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/library?**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const after = url.searchParams.get('after');
|
||||
const size = Number(url.searchParams.get('size') || PAGE_SIZE);
|
||||
const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size;
|
||||
const songs = [];
|
||||
for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i));
|
||||
const nextOffset = offset + size;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
songs, total: TOTAL, page: Math.floor(offset / size), size,
|
||||
next_cursor: nextOffset < TOTAL ? String(nextOffset) : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/library/stats**', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} };
|
||||
if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters();
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } }));
|
||||
await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
|
||||
await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } }));
|
||||
await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] }));
|
||||
});
|
||||
|
||||
async function openSongs(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — neutralize playback so a stray click can't navigate away.
|
||||
window.playSong = () => Promise.resolve();
|
||||
// @ts-ignore
|
||||
window.showScreen('v3-songs');
|
||||
});
|
||||
await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 });
|
||||
}
|
||||
|
||||
test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
|
||||
// The count reflects the FULL library even though only a window is rendered.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
|
||||
// The sizer reserves the full scroll height (so the scrollbar is library-wide).
|
||||
const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight);
|
||||
expect(scrollHeight).toBeGreaterThan(20000);
|
||||
|
||||
// Scroll the whole library; the in-DOM card count must stay bounded throughout.
|
||||
const CAP = 150;
|
||||
let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
for (let s = 0; s < 50; s++) {
|
||||
await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; });
|
||||
await page.waitForTimeout(60);
|
||||
const n = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
maxNodes = Math.max(maxNodes, n);
|
||||
expect(n).toBeLessThanOrEqual(CAP);
|
||||
}
|
||||
// Sanity: we actually rendered a window (not zero), and stayed well under the
|
||||
// unbounded 2001 the old append-everything grid would have produced.
|
||||
expect(maxNodes).toBeGreaterThan(0);
|
||||
expect(maxNodes).toBeLessThanOrEqual(CAP);
|
||||
|
||||
// The count is still correct after scrolling to the end.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
});
|
||||
|
||||
test('the A–Z rail jumps directly to a letter without loading every page', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 });
|
||||
|
||||
// Jump to 'M'; the window scrolls to the row holding the first 'M' card.
|
||||
await page.evaluate(() => {
|
||||
const b = [...document.querySelectorAll('.v3-azrail-letter')]
|
||||
.find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined;
|
||||
if (!b) throw new Error('no M rail letter'); b.click();
|
||||
});
|
||||
|
||||
// After the jump+window render, an 'M' card is present near the top of the
|
||||
// viewport (the jump is O(1) via sort_letters, not a full page-through).
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const main = document.getElementById('v3-main')!;
|
||||
const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0);
|
||||
return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320;
|
||||
});
|
||||
}), { timeout: 5000 }).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Regression coverage for the v3 Section Map "leftmost section unclickable" bug.
|
||||
//
|
||||
// The Section Map plugin pins a ~20px clickable bar (#section-map, z-index:5)
|
||||
// to the very top of #player. The v3 chrome has a full-height invisible rail
|
||||
// "catcher" (.v3-railzone::before, z-index:30, width:96px, pinned left/top:0)
|
||||
// that reveals the hover rail. Because the catcher sat at top:0 and outranks
|
||||
// the bar, its top-left corner swallowed every click on the section map's first
|
||||
// section. Fix (static/v3/v3.css): `#section-map ~ #v3-railzone::before { top: 20px }`
|
||||
// drops the catcher below the bar when the section map is present.
|
||||
//
|
||||
// We reproduce the plugin's bar exactly (first child of #player, the rendered
|
||||
// position:relative / z-index:5 / 20px-tall state) and hit-test the top-left
|
||||
// corner with elementFromPoint — that is precisely what a real click resolves
|
||||
// against. A negative control re-raises the catcher to prove the test catches
|
||||
// the bug.
|
||||
|
||||
// A fresh profile shows the blocking onboarding overlay; onboard via the API so
|
||||
// it isn't (re)created over the player. Idempotent once onboarded.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/api/profile', { data: { display_name: 'Section Map Tester' } });
|
||||
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
|
||||
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
|
||||
});
|
||||
|
||||
async function openPlayerWithSectionMap(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
// The bug affects an already-onboarded user mid-song. The API skip above
|
||||
// handles the common path; this persistent hide also covers a slow async
|
||||
// profile render that could otherwise re-create the full-screen overlay and
|
||||
// intercept the top-left hit-test (mirrors settings-tabbed.spec.ts).
|
||||
await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — show the player screen (static #v3-railzone markup lives here).
|
||||
window.showScreen('player');
|
||||
const player = document.getElementById('player');
|
||||
if (!player) throw new Error('#player missing');
|
||||
|
||||
// Reproduce the section_map plugin's rendered bar: first child of #player,
|
||||
// 20px tall, full width, z-index:5, position:relative (its post-_smRender
|
||||
// state), with a left-edge "first section" block at left:0.
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'section-map';
|
||||
bar.style.cssText =
|
||||
'position:relative;top:0;left:0;right:0;z-index:5;height:20px;background:rgba(8,8,16,0.7);cursor:pointer;';
|
||||
const block = document.createElement('div');
|
||||
block.id = 'sm-first-block';
|
||||
block.style.cssText =
|
||||
'position:absolute;left:0;width:30%;top:0;bottom:0;background:#3b82f6;';
|
||||
bar.appendChild(block);
|
||||
player.insertBefore(bar, player.firstChild);
|
||||
});
|
||||
await page.waitForSelector('#section-map', { state: 'attached', timeout: 5000 });
|
||||
await page.waitForSelector('#v3-railzone', { state: 'attached', timeout: 5000 });
|
||||
}
|
||||
|
||||
// What element does a click at the top-left strip land on? (x within the 96px
|
||||
// catcher, y within the 20px bar.)
|
||||
function hitTopLeft(page, x = 10, y = 8) {
|
||||
return page.evaluate(({ x, y }) => {
|
||||
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
return el ? { id: el.id, cls: el.className, tag: el.tagName } : null;
|
||||
}, { x, y });
|
||||
}
|
||||
|
||||
test('top-left of the section map receives clicks, not the rail catcher (fix present)', async ({ page }) => {
|
||||
await openPlayerWithSectionMap(page);
|
||||
|
||||
const hit = await hitTopLeft(page);
|
||||
// Click must resolve to the section map (the bar or its first-section block),
|
||||
// never the rail hover-zone.
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.id).not.toBe('v3-railzone');
|
||||
expect(['section-map', 'sm-first-block']).toContain(hit!.id);
|
||||
});
|
||||
|
||||
test('negative control: re-raising the catcher to top:0 reproduces the bug', async ({ page }) => {
|
||||
await openPlayerWithSectionMap(page);
|
||||
|
||||
// Undo the fix at runtime (highest-specificity inline-ish override) so the
|
||||
// catcher again covers the bar's top-left — this is the pre-fix layout.
|
||||
await page.evaluate(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '#section-map ~ #v3-railzone::before { top: 0 !important; }';
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
|
||||
const hit = await hitTopLeft(page);
|
||||
// Without the fix, the rail catcher swallows the click.
|
||||
expect(hit!.id).toBe('v3-railzone');
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Regression coverage for the list/tree view select-mode fix (PR #585, which
|
||||
// re-lands a change that was reverted). The core bug: entering select mode
|
||||
// re-renders the tree (setSelectMode -> reload -> loadTree), and the rebuild
|
||||
// wiped every expanded <details>, collapsing the tree and making selection
|
||||
// unusable. The fix captures the open artist groups before the wipe and
|
||||
// restores them. We also cover: clicking a row in select mode selects instead
|
||||
// of playing.
|
||||
//
|
||||
// Navigation uses programmatic element.click() rather than Playwright's
|
||||
// actionability-gated click: this screen briefly re-renders its toolbar and
|
||||
// the harness can show transient overlays, but element.click() still
|
||||
// dispatches a real bubbling event through the capture-phase select handler.
|
||||
|
||||
const ARTISTS = {
|
||||
artists: [
|
||||
{
|
||||
name: 'Alpha Band',
|
||||
song_count: 2,
|
||||
albums: [{ name: 'First Album', songs: [
|
||||
{ filename: 'alpha/one.sloppak', title: 'Alpha One', artist: 'Alpha Band', album: 'First Album' },
|
||||
{ filename: 'alpha/two.sloppak', title: 'Alpha Two', artist: 'Alpha Band', album: 'First Album' },
|
||||
] }],
|
||||
},
|
||||
{
|
||||
name: 'Beta Crew',
|
||||
song_count: 1,
|
||||
albums: [{ name: 'Beta LP', songs: [
|
||||
{ filename: 'beta/solo.sloppak', title: 'Beta Solo', artist: 'Beta Crew', album: 'Beta LP' },
|
||||
] }],
|
||||
},
|
||||
],
|
||||
total_artists: 2,
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Paged artists endpoint (used by both the tree and the artist catalog):
|
||||
// page 0 returns data, later pages return empty so the paging loop ends.
|
||||
await page.route('**/api/library/artists**', async route => {
|
||||
const pageNum = Number(new URL(route.request().url()).searchParams.get('page') || '0');
|
||||
await route.fulfill({ json: pageNum === 0 ? ARTISTS : { artists: [], total_artists: 2 } });
|
||||
});
|
||||
await page.route('**/api/library/providers', route => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
|
||||
await page.route('**/api/library/tuning-names**', route => route.fulfill({ json: { tunings: [] } }));
|
||||
await page.route('**/api/stats/best', route => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/library?**', route => route.fulfill({ json: { songs: [], total: 0, page: 0, size: 60 } }));
|
||||
});
|
||||
|
||||
// Programmatic click — fires a real bubbling click through capture-phase
|
||||
// handlers without Playwright's actionability gate.
|
||||
async function clickSel(page, selector: string) {
|
||||
await page.evaluate((s) => {
|
||||
const el = document.querySelector(s) as HTMLElement | null;
|
||||
if (!el) throw new Error('not found: ' + s);
|
||||
el.click();
|
||||
}, selector);
|
||||
}
|
||||
|
||||
async function openTree(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — record playback so an accidental row-click is detectable.
|
||||
window.__played = 0;
|
||||
// @ts-ignore
|
||||
window.playSong = () => { window.__played++; return Promise.resolve(); };
|
||||
// @ts-ignore
|
||||
window.showScreen('v3-songs');
|
||||
});
|
||||
await page.waitForSelector('#v3-songs-tree-btn', { state: 'attached', timeout: 8000 });
|
||||
await clickSel(page, '#v3-songs-tree-btn');
|
||||
await page.waitForSelector('#v3-songs-tree details', { state: 'attached', timeout: 8000 });
|
||||
}
|
||||
|
||||
// Returns the <details> whose <summary> names the given artist.
|
||||
function group(page, artist: string) {
|
||||
return page.locator('#v3-songs-tree details', { has: page.locator('summary', { hasText: artist }) });
|
||||
}
|
||||
|
||||
test('select mode keeps expanded artist groups open across the tree re-render (#585)', async ({ page }) => {
|
||||
await openTree(page);
|
||||
|
||||
// Expand Alpha (the precondition the bug used to destroy on re-render).
|
||||
await page.evaluate(() => {
|
||||
const d = [...document.querySelectorAll('#v3-songs-tree details')]
|
||||
.find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement;
|
||||
d.open = true;
|
||||
});
|
||||
await expect(group(page, 'Alpha Band')).toHaveAttribute('open', '');
|
||||
|
||||
// Enter select mode → triggers the full tree re-render.
|
||||
await clickSel(page, '#v3-songs-select');
|
||||
await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 });
|
||||
|
||||
// The bug: Alpha collapses after the rebuild. The fix restores it.
|
||||
await expect(group(page, 'Alpha Band')).toHaveAttribute('open', '');
|
||||
// Beta was never opened — it must stay collapsed (no false restore).
|
||||
await expect(group(page, 'Beta Crew')).not.toHaveAttribute('open', '');
|
||||
});
|
||||
|
||||
test('clicking a tree row in select mode selects it instead of playing (#585)', async ({ page }) => {
|
||||
await openTree(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const d = [...document.querySelectorAll('#v3-songs-tree details')]
|
||||
.find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement;
|
||||
d.open = true;
|
||||
});
|
||||
|
||||
await clickSel(page, '#v3-songs-select');
|
||||
await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 });
|
||||
|
||||
await clickSel(page, '#v3-songs-tree [data-fn="alpha/one.sloppak"]');
|
||||
|
||||
await expect(page.locator('#v3-songs-tree [data-fn="alpha/one.sloppak"] input[data-select]')).toBeChecked();
|
||||
expect(await page.evaluate(() => (window as any).__played)).toBe(0);
|
||||
});
|
||||
@@ -281,6 +281,35 @@ test('open-source and close-source record outcomes events and no live handles',
|
||||
assert.equal(encoded.includes('token=abc'), false);
|
||||
});
|
||||
|
||||
test('open-source returns the actually-bound device for read-back without leaking it into diagnostics', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.feedBack.capabilities;
|
||||
const openedEvents = captureEvents(window, 'audio-input:source-opened');
|
||||
|
||||
await registerSource(api, {
|
||||
sourceId: 'readback-source',
|
||||
logicalSourceKey: 'test:readback',
|
||||
operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled', status: 'open', payload: { boundType: 'CoreAudio', boundName: 'BlackHole 16ch', requestedName: 'BlackHole 16ch' } }),
|
||||
'source.close': () => ({ outcome: 'handled', status: 'closed' }),
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:readback' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
|
||||
|
||||
// The trusted in-process caller (input_setup's confirmation gate) gets the real bound device,
|
||||
// so it can show "Now listening to: <device>" and catch a silent wrong-mic substitution.
|
||||
assert.equal(open.outcome, 'handled');
|
||||
assert.equal(open.payload.bound.type, 'CoreAudio');
|
||||
assert.equal(open.payload.bound.name, 'BlackHole 16ch');
|
||||
|
||||
// ...but a raw device name is PII: it must NOT reach the emitted event or the diagnostics snapshot.
|
||||
assert.equal(openedEvents.length, 1);
|
||||
assert.equal('bound' in openedEvents[0], false);
|
||||
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
|
||||
assert.equal(encoded.includes('BlackHole'), false);
|
||||
});
|
||||
|
||||
test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Regression guards for two Edit-Metadata modal fixes (static/app.js):
|
||||
//
|
||||
// 1. Year is editable — the modal renders an `edit-year` field and
|
||||
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
|
||||
// (Backend already accepts/normalizes year; only the UI omitted it.)
|
||||
//
|
||||
// 2. A click-drag that starts inside a field and is released on the backdrop
|
||||
// must NOT dismiss the modal. _editModalShouldClose() gates backdrop
|
||||
// dismissal on the mousedown having started on the backdrop too.
|
||||
//
|
||||
// Functions are extracted from the real shipped source and run in a vm — no
|
||||
// mirror copies.
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const readApp = () => fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
function loadFn(signature, sandbox, exportAs) {
|
||||
const fnSrc = extractFunction(readApp(), signature);
|
||||
const ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(`${fnSrc}\nglobalThis.${exportAs} = ${exportAs};`, ctx);
|
||||
return sandbox[exportAs];
|
||||
}
|
||||
|
||||
// ── Issue: Edit Metadata does not allow changing Year ────────────────────────
|
||||
|
||||
test('openEditModal renders a Year field bound to songData.y', () => {
|
||||
const src = extractFunction(readApp(), 'function openEditModal');
|
||||
assert.match(src, /id="edit-year"/, 'modal must render an #edit-year input');
|
||||
assert.match(src, /_escAttr\(songData\.y\)/, 'year input must be populated from songData.y');
|
||||
});
|
||||
|
||||
test('Save button wires via data-edit-save, not an inline onclick that embeds the filename', () => {
|
||||
// encodeURIComponent does NOT escape `'`, so embedding the filename in a
|
||||
// single-quoted inline `saveEditModal('…')` handler breaks the save for a
|
||||
// song whose filename contains an apostrophe (e.g. `Bob's Song.sloppak`).
|
||||
// The Save button must use the data-attr + JS-listener pattern instead.
|
||||
const src = extractFunction(readApp(), 'function openEditModal');
|
||||
assert.doesNotMatch(src, /onclick="saveEditModal\('/, 'Save must not embed the filename in an inline onclick');
|
||||
assert.match(src, /data-edit-save/, 'Save button must carry the data-edit-save hook');
|
||||
assert.match(src, /querySelector\('\[data-edit-save\]'\)/, 'Save must be wired via addEventListener');
|
||||
});
|
||||
|
||||
test('saveEditModal includes year in the metadata POST body', async () => {
|
||||
const calls = [];
|
||||
const values = {
|
||||
'edit-title': 'My Title', 'edit-artist': 'My Artist',
|
||||
'edit-album': 'My Album', 'edit-year': '1998',
|
||||
'edit-art-file': null, // signals the file branch via .files below
|
||||
'edit-modal': null,
|
||||
};
|
||||
const sandbox = {
|
||||
decodeURIComponent, encodeURIComponent, JSON, Promise,
|
||||
_lastLibSelected: null,
|
||||
loadLibrary: () => {}, loadFavorites: () => {},
|
||||
fetch: (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); },
|
||||
document: {
|
||||
getElementById: (id) => {
|
||||
if (id === 'edit-art-file') return { files: null };
|
||||
if (id === 'edit-modal') return null;
|
||||
return id in values ? { value: values[id] } : null;
|
||||
},
|
||||
querySelector: () => null, // no active screen
|
||||
body: { contains: () => false },
|
||||
},
|
||||
};
|
||||
const saveEditModal = loadFn('async function saveEditModal', sandbox, 'saveEditModal');
|
||||
|
||||
await saveEditModal(encodeURIComponent('Song With Spaces.sloppak'));
|
||||
|
||||
const metaCall = calls.find((c) => /\/api\/song\/.+\/meta$/.test(c.url));
|
||||
assert.ok(metaCall, 'expected a POST to /api/song/<filename>/meta');
|
||||
const body = JSON.parse(metaCall.opts.body);
|
||||
assert.equal(body.year, '1998', 'meta POST body must carry the edited year');
|
||||
assert.deepEqual(
|
||||
body,
|
||||
{ title: 'My Title', artist: 'My Artist', album: 'My Album', year: '1998' },
|
||||
'meta POST body shape',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Issue: Renaming Metadata Closes Modal (click-drag release on backdrop) ────
|
||||
|
||||
test('_editModalShouldClose: backdrop needs mousedown to have started there', () => {
|
||||
const fn = loadFn('function _editModalShouldClose', {}, '_editModalShouldClose');
|
||||
|
||||
const modalEl = { closest: () => null }; // the backdrop element
|
||||
const innerEl = { closest: () => null }; // a field inside the modal
|
||||
const cancelBtn = { closest: (s) => (s === '[data-edit-close]' ? { tag: 'button' } : null) };
|
||||
|
||||
// Cancel / ✕ always closes, regardless of where the mousedown began.
|
||||
assert.equal(fn(cancelBtn, modalEl, false), true, 'Cancel/✕ closes');
|
||||
assert.equal(fn(cancelBtn, modalEl, true), true, 'Cancel/✕ closes (down-on-backdrop irrelevant)');
|
||||
|
||||
// Genuine backdrop click: down AND up on the backdrop.
|
||||
assert.equal(fn(modalEl, modalEl, true), true, 'backdrop down+up closes');
|
||||
|
||||
// The reported bug: drag began inside a field (down NOT on backdrop), click
|
||||
// resolves to the backdrop on release — must NOT close.
|
||||
assert.equal(fn(modalEl, modalEl, false), false, 'drag-from-field release on backdrop does NOT close');
|
||||
|
||||
// A click that lands on inner content never closes via the backdrop path.
|
||||
assert.equal(fn(innerEl, modalEl, true), false, 'click on inner content does not close');
|
||||
});
|
||||
@@ -130,6 +130,59 @@ test('measure-start cache is invalidated on song change', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Fret-row fit guard ──────────────────────────────────────────────────────
|
||||
// Keeps the heat-coloured fret-number row from clipping off the bottom edge
|
||||
// when a tight, centred zoom (worst mid-neck) drops it below the lower-third
|
||||
// framing. camUpdate dollies the camera back via a capped, hysteretic boost.
|
||||
|
||||
test('fret-row fit guard constants are defined', () => {
|
||||
for (const name of [
|
||||
'FRET_ROW_FIT_NDC_MIN', 'FRET_ROW_FIT_DEADBAND', 'FRET_ROW_FIT_BOOST_MAX',
|
||||
]) {
|
||||
assert.match(src, new RegExp('const\\s+' + name + '\\s*='),
|
||||
`${name} must be declared as a fit-guard constant`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the curDist lerp target applies the fit-guard dolly boost', () => {
|
||||
// The span-driven tgtDist still owns zooming in; the boost only pulls back.
|
||||
assert.match(
|
||||
src,
|
||||
/curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
|
||||
'curDist must lerp toward tgtDist * _fretRowFitBoost',
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
|
||||
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
|
||||
'the guard must probe the same row band the fret-number row is drawn at',
|
||||
);
|
||||
// Prompt pull-back when below the min, capped at BOOST_MAX.
|
||||
assert.match(
|
||||
src,
|
||||
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
|
||||
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
|
||||
);
|
||||
// Lazy relax only once past the deadband, floored at 1.
|
||||
assert.match(
|
||||
src,
|
||||
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
|
||||
'past the deadband the boost relaxes back toward 1',
|
||||
);
|
||||
});
|
||||
|
||||
test('the fit guard yields to the free-cam (Camera Director)', () => {
|
||||
// When the free-cam owns the view the auto dolly must reset to 1, not fight it.
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
|
||||
'with the free-cam enabled the guard must drop any auto dolly back to 1',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Debug hook stayed removed ───────────────────────────────────────────────
|
||||
|
||||
test('temporary camera debug hook is not present', () => {
|
||||
|
||||
@@ -67,6 +67,64 @@ test('reset counters via bindRuntime song lifecycle', () => {
|
||||
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
|
||||
});
|
||||
|
||||
test('backward song:seek rebuilds the tally to the new position', () => {
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); },
|
||||
emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); },
|
||||
};
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
|
||||
// Notes judged at t = 1..5 (miss at t=4), each carried on the event detail.
|
||||
sm.emit('note:hit', { noteTime: 1 });
|
||||
sm.emit('note:hit', { noteTime: 2 });
|
||||
sm.emit('note:hit', { noteTime: 3 });
|
||||
sm.emit('note:miss', { noteTime: 4 });
|
||||
sm.emit('note:hit', { noteTime: 5 });
|
||||
assert.equal(runtime.getCounters().hits, 4);
|
||||
assert.equal(runtime.getCounters().misses, 1);
|
||||
|
||||
// Restart-style backward seek to t=3 → keep only t=1,2 (both hits).
|
||||
sm.emit('song:seek', { from: 5, to: 3, reason: 'song-restart' });
|
||||
assert.equal(runtime.getCounters().hits, 2);
|
||||
assert.equal(runtime.getCounters().misses, 0);
|
||||
assert.equal(runtime.getCounters().streak, 2);
|
||||
|
||||
// Restart to the very top → 0 notes.
|
||||
sm.emit('song:seek', { from: 3, to: 0, reason: 'song-restart' });
|
||||
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
|
||||
});
|
||||
|
||||
test('a FORWARD song:seek does not roll back the tally', () => {
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); },
|
||||
emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); },
|
||||
};
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
sm.emit('note:hit', { noteTime: 1 });
|
||||
sm.emit('note:hit', { noteTime: 2 });
|
||||
sm.emit('song:seek', { from: 2, to: 30, reason: 'seek-by' });
|
||||
assert.equal(runtime.getCounters().hits, 2, 'forward seek keeps earlier hits');
|
||||
});
|
||||
|
||||
test('loop-wrap seek is ignored (drill mode keeps accumulating)', () => {
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); },
|
||||
emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); },
|
||||
};
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
sm.emit('note:hit', { noteTime: 11 });
|
||||
sm.emit('note:hit', { noteTime: 12 });
|
||||
// A-B drill loop wraps backward to loopA — must NOT reset the tally.
|
||||
sm.emit('song:seek', { from: 12, to: 10, reason: 'loop-wrap' });
|
||||
assert.equal(runtime.getCounters().hits, 2, 'loop-wrap leaves the cumulative tally intact');
|
||||
});
|
||||
|
||||
test('DOM text updates after hit and miss events', () => {
|
||||
class El {
|
||||
constructor(id) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Guards the Section Practice popover's outside-click dismiss in static/app.js
|
||||
// (_installSectionPracticeDismiss). The v3 player-rail icon buttons call
|
||||
// e.stopPropagation() in their click handler (static/v3/player-chrome.js
|
||||
// wireRail), so a BUBBLE-phase document dismiss never fires when the user clicks
|
||||
// a different rail icon (Plugins, Audio, …) — leaving the Practice popover
|
||||
// stranded open under the newly-opened one (feedBack#638). The dismiss must bind
|
||||
// in the CAPTURE phase (runs before the target's stopPropagation can swallow it).
|
||||
// Esc must stay bubble-phase so it doesn't reorder ahead of the player's
|
||||
// Escape-to-exit handling. A revert to bubble-phase should fail here.
|
||||
//
|
||||
// Source-level only — same strategy as the other tests/js/ files.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
|
||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
|
||||
const body = m[0];
|
||||
|
||||
test('the outside-click dismiss binds in the CAPTURE phase', () => {
|
||||
assert.match(
|
||||
body,
|
||||
/addEventListener\(\s*['"]click['"][\s\S]*?,\s*true\s*\)/,
|
||||
'the click dismiss must pass the capture flag (`, true`) so a rail icon\'s '
|
||||
+ 'stopPropagation() cannot swallow it',
|
||||
);
|
||||
});
|
||||
|
||||
test('only the click listener is capture (Escape keydown stays bubble-phase)', () => {
|
||||
// Exactly one capture binding in the installer — the click. The keydown
|
||||
// (Escape) listener must NOT be capture.
|
||||
const captureBinds = body.match(/,\s*true\s*\)/g) || [];
|
||||
assert.equal(captureBinds.length, 1, 'expected exactly one capture-phase binding (the click)');
|
||||
});
|
||||
|
||||
test('the dismiss ignores clicks inside the control (no self-close)', () => {
|
||||
assert.match(body, /section-practice-control/, 'must scope to #section-practice-control');
|
||||
assert.match(body, /ctrl\s*&&\s*ctrl\.contains\(e\.target\)\)\s*return/,
|
||||
'a click inside the control (incl. the pill) must not dismiss the popover');
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Verify the feedpak credits overlay helpers in app.js:
|
||||
// - _creditLineLabel() role → friendly "<verb> by" label
|
||||
// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list
|
||||
// - hideSongCreditsOverlay() removes the overlay element
|
||||
//
|
||||
// Same isolation strategy as autoplay_exit.test.js — extract the functions
|
||||
// from app.js by brace-matching and run them in a vm sandbox with a fake DOM.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Minimal fake DOM element: records className, children, and textContent.
|
||||
// Setting textContent clears children (matching real DOM) so we can assert
|
||||
// names were set via textContent (not innerHTML) — the XSS-safety contract.
|
||||
function makeEl() {
|
||||
return {
|
||||
className: '',
|
||||
children: [],
|
||||
_text: '',
|
||||
set textContent(v) { this._text = String(v); this.children = []; },
|
||||
get textContent() { return this._text; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
replaceChildren() { this.children = []; },
|
||||
remove() { this.removed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function allText(node) {
|
||||
let s = node._text || '';
|
||||
for (const c of node.children) s += allText(c);
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildSandbox(currentSong) {
|
||||
const body = makeEl();
|
||||
const sandbox = {
|
||||
document: { body, createElement: () => makeEl() },
|
||||
window: { feedBack: { currentSong, off() {} } },
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const preamble = `
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
const _CREDIT_ROLE_VERBS = ${JSON.stringify({
|
||||
charter: 'Charted by', transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by',
|
||||
engineer: 'Engineered by', proofreader: 'Proofread by',
|
||||
})};
|
||||
`;
|
||||
vm.runInContext(
|
||||
preamble
|
||||
+ extractFunction(SRC, 'function _creditLineLabel(') + '\n'
|
||||
+ extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n'
|
||||
+ extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n'
|
||||
+ 'globalThis._creditLineLabel = _creditLineLabel;'
|
||||
+ 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;'
|
||||
+ 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;'
|
||||
+ 'globalThis._getOverlay = () => _creditsOverlay;',
|
||||
sandbox,
|
||||
);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => {
|
||||
const s = buildSandbox({});
|
||||
assert.equal(s._creditLineLabel('charter'), 'Charted by');
|
||||
assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive
|
||||
assert.equal(s._creditLineLabel('mixer'), 'Mixed by');
|
||||
assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased
|
||||
assert.equal(s._creditLineLabel(null), ''); // no role → bare name
|
||||
assert.equal(s._creditLineLabel(''), '');
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay builds a card with heading + credit lines', () => {
|
||||
const s = buildSandbox({ title: 'My Song' });
|
||||
s.showSongCreditsOverlay([
|
||||
{ name: 'Azure', role: 'charter' },
|
||||
{ name: 'Bob Lee', role: 'editor' },
|
||||
{ name: 'Solo', role: null },
|
||||
]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay, 'overlay created');
|
||||
assert.equal(overlay.className, 'song-credits-overlay');
|
||||
assert.equal(s.document.body.children.length, 1);
|
||||
const text = allText(overlay);
|
||||
assert.match(text, /My Song/); // heading is the song title
|
||||
assert.match(text, /Charted by/);
|
||||
assert.match(text, /Azure/);
|
||||
assert.match(text, /Edited by/);
|
||||
assert.match(text, /Bob Lee/);
|
||||
assert.match(text, /Solo/); // role-less entry still shows the name
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: '<img src=x onerror=alert(1)>', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
// The raw string survives verbatim as text — proving it was never parsed
|
||||
// as HTML (no innerHTML interpolation anywhere on the path).
|
||||
assert.match(allText(overlay), /<img src=x onerror=alert\(1\)>/);
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay is a no-op for empty / non-array input', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([]);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
s.showSongCreditsOverlay(undefined);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
assert.equal(s.document.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('hideSongCreditsOverlay removes the overlay', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay);
|
||||
s.hideSongCreditsOverlay();
|
||||
assert.equal(overlay.removed, true);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Guard: a song's ⋮ "More" menu offers "Add to playlist" for a single song —
|
||||
// not only the select-mode checkbox + batch-bar flow. Both paths share the
|
||||
// extracted addFilenamesToPlaylist() helper. (Menu/DOM wiring isn't headlessly
|
||||
// unit-testable, so these are source-level guards.)
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js'), 'utf8');
|
||||
|
||||
test('the ⋮ card menu lists an "Add to playlist" row', () => {
|
||||
assert.match(SONGS, /id:\s*'__playlist',\s*label:\s*'Add to playlist'/);
|
||||
});
|
||||
|
||||
test('the menu row adds the single song via the shared helper', () => {
|
||||
assert.match(SONGS, /id === '__playlist'[\s\S]{0,100}addFilenamesToPlaylist\(\[song\.filename\]\)/);
|
||||
});
|
||||
|
||||
test('batch and single-song add share addFilenamesToPlaylist()', () => {
|
||||
assert.match(SONGS, /async function addFilenamesToPlaylist\(filenames\)/);
|
||||
assert.match(SONGS, /async function batchAddToPlaylist\(\)[\s\S]{0,120}addFilenamesToPlaylist\(state\.selected\)/);
|
||||
});
|
||||
|
||||
test('batch only finishes (clears selection) when the add succeeded, not on cancel', () => {
|
||||
// addFilenamesToPlaylist returns null on a cancelled/failed picker; the
|
||||
// batch caller must capture it and gate finishBatch() on a truthy pid, so
|
||||
// cancelling preserves the multi-select (regression guard for the
|
||||
// extract-helper refactor — previously finishBatch ran unconditionally).
|
||||
assert.match(SONGS, /const pid = await addFilenamesToPlaylist\(state\.selected\)/,
|
||||
'batch must capture the returned playlist id');
|
||||
assert.match(SONGS, /if \(pid\) finishBatch\(\)/,
|
||||
'finishBatch must be gated on a successful add (truthy pid)');
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Pins the v3 Songs A–Z jump rail wiring in static/v3/songs.js.
|
||||
//
|
||||
// The rail lets a user jump the library grid to artists/titles starting with a
|
||||
// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3
|
||||
// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first
|
||||
// card's absolute index (cumulative of prior buckets), which converts to a
|
||||
// scrollTop — no page-through. The rail only offers letters the server reports
|
||||
// present for the active sort+filter (so a tap always lands on a real card). It
|
||||
// is shown only for the grid view + alphabetical (artist/title) sorts.
|
||||
//
|
||||
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('the rail is context-gated to grid view + alphabetical sorts', () => {
|
||||
// railSortColumn returns the active alpha column or null (recent/year/tuning).
|
||||
assert.match(src, /function\s+railSortColumn\s*\(\)/);
|
||||
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
|
||||
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
|
||||
'the rail must be visible only for the grid view + an alphabetical sort',
|
||||
);
|
||||
});
|
||||
|
||||
test('cards carry a data-letter bucket and non-A–Z buckets under #', () => {
|
||||
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
|
||||
'each card must tag its sort-letter bucket via songBucket(song)');
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
|
||||
'songBucket must bucket non-A–Z first chars under "#"',
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => {
|
||||
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
|
||||
'refreshRail must query /api/library/stats with the active filter params');
|
||||
// Opts into the active-sort breakdown so non-rail callers skip the scan.
|
||||
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
|
||||
'refreshRail must request the sort_letters breakdown');
|
||||
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
|
||||
'refreshRail must prefer the active-sort breakdown (sort_letters)');
|
||||
// The legacy artist `letters` is only a valid fallback for an artist sort;
|
||||
// a title sort with no sort_letters hides the rail rather than mislabel it.
|
||||
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
|
||||
'refreshRail must only fall back to letters for an artist sort');
|
||||
// Absent letters are disabled (non-interactive), not just dimmed.
|
||||
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
|
||||
});
|
||||
|
||||
test('reload() refreshes the rail', () => {
|
||||
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
|
||||
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
|
||||
});
|
||||
|
||||
test('the rail + drag bubble are rendered in the Songs markup', () => {
|
||||
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
|
||||
assert.match(src, /id="v3-songs-azbubble"/);
|
||||
});
|
||||
|
||||
test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => {
|
||||
// The cumulative-count seek: sum the song-counts of buckets ordered before
|
||||
// the target to get its first row's absolute index.
|
||||
assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/,
|
||||
'jumpToLetter must derive the target index from sort_letters counts');
|
||||
assert.match(
|
||||
src,
|
||||
/async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/,
|
||||
'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)',
|
||||
);
|
||||
// It pre-fetches the destination window so cards are ready when the scroll lands.
|
||||
assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/,
|
||||
'jumpToLetter must pre-fetch the destination window before scrolling');
|
||||
// The old forward-paging helper is gone (the seek is O(1)).
|
||||
assert.doesNotMatch(src, /_loadNextAwait/,
|
||||
'the page-through helper must be removed under the windowed grid');
|
||||
// A token still guards overlapping jumps (drag scrubbing) — newest wins.
|
||||
assert.match(src, /_jumpToken\s*!==\s*myToken/);
|
||||
});
|
||||
|
||||
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
|
||||
assert.match(src, /addEventListener\('pointerdown'/);
|
||||
assert.match(src, /addEventListener\('pointermove'/);
|
||||
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
|
||||
'arrow keys must move between present letters');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Pins the v3 "Save as collection" wiring in static/v3/songs.js (#636 item 2).
|
||||
// A smart collection is a saved live library filter, surfaced as a source in
|
||||
// the provider picker; the drawer can save the current filter set as one.
|
||||
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('currentFilterRules builds the raw query-param rule object', () => {
|
||||
assert.match(src, /function\s+currentFilterRules/);
|
||||
// Multi-value filters are CSV strings (what the backend stores / re-parses).
|
||||
assert.match(src, /r\.tunings\s*=\s*f\.tunings\.join\(','\)/);
|
||||
assert.match(src, /r\.arrangements_has\s*=\s*f\.arr_has\.join\(','\)/);
|
||||
});
|
||||
|
||||
test('saving POSTs to /api/collections with name + rules', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/fetch\('\/api\/collections',[\s\S]*?JSON\.stringify\(\{\s*name,\s*rules\s*\}\)/,
|
||||
'saveCurrentAsCollection must POST {name, rules} to /api/collections',
|
||||
);
|
||||
// After save, switch the source to the new collection and rebuild the UI.
|
||||
assert.match(src, /state\.provider\s*=\s*'collection:'\s*\+\s*col\.id/);
|
||||
});
|
||||
|
||||
test('the drawer shows a Save-as-collection action only when filters are set', () => {
|
||||
assert.match(src, /Object\.keys\(currentFilterRules\(\)\)\.length[\s\S]*?data-drawer-save/);
|
||||
assert.match(src, /data-drawer-save[\s\S]*?saveCurrentAsCollection/);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Pins the practice-aware library home in static/v3/songs.js:
|
||||
// - a "Repertoire" progress meter (mastered / total library songs), and
|
||||
// - a "Keep practicing" shelf (recently played, not yet mastered).
|
||||
// Both reuse existing data (/api/stats/best already in state.accuracy, and
|
||||
// /api/stats/recent) and are shown only on the unfiltered grid front door.
|
||||
//
|
||||
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('repertoire uses the same mastery threshold as the green accuracy badge', () => {
|
||||
assert.match(src, /const\s+MASTERY_ACCURACY\s*=\s*0\.9/);
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_repertoireCounts[\s\S]*?v\s*>=\s*MASTERY_ACCURACY\s*\)\s*mastered\+\+;\s*else\s+learning\+\+/,
|
||||
'repertoire counts must bucket scored songs into mastered/learning at MASTERY_ACCURACY',
|
||||
);
|
||||
});
|
||||
|
||||
test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+libHomeVisible[\s\S]*?state\.view === 'grid'[\s\S]*?state\.provider === 'local'[\s\S]*?!state\.selectMode[\s\S]*?!state\.q[\s\S]*?activeFilterCount\(\)\s*===\s*0/,
|
||||
'libHomeVisible must require grid view, the local provider, no select mode, no search, no active filters',
|
||||
);
|
||||
});
|
||||
|
||||
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
|
||||
assert.match(src, /\/api\/stats\/recent\?limit=/);
|
||||
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
|
||||
// shows), not the per-arrangement recents row, and each filename appears
|
||||
// once — so no green-badged "keep practicing" card and no duplicates.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
|
||||
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
|
||||
);
|
||||
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
|
||||
});
|
||||
|
||||
test('the meter + shelf fetch together and a stale render is discarded', () => {
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
|
||||
'the two reads must be issued together (Promise.all), not sequentially');
|
||||
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
||||
'a stale render must be superseded by a newer one via a token');
|
||||
});
|
||||
|
||||
test('the repertoire denominator is the unfiltered library total', () => {
|
||||
assert.match(src, /\/api\/library\/stats\?provider='/);
|
||||
assert.match(src, /total_songs\s*\?\?\s*stats\.total/);
|
||||
assert.match(src, /Math\.round\(\(mastered\s*\/\s*total\)\s*\*\s*100\)/);
|
||||
});
|
||||
|
||||
test('the home + #v3-lib-home host are wired into render and reload', () => {
|
||||
assert.match(src, /id="v3-lib-home"/, 'render() must include the #v3-lib-home host');
|
||||
assert.match(src, /function reload\s*\([\s\S]*?updateLibraryHome\(\)/,
|
||||
'reload() must refresh/toggle the home');
|
||||
assert.match(src, /function applyScoreRefresh[\s\S]*?renderLibraryHome\(\)/,
|
||||
'a new score must refresh the meter + shelf');
|
||||
});
|
||||
|
||||
test('shelf cards play the song on click', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/querySelectorAll\('\.v3-kp-card'\)[\s\S]*?window\.playSong\(enc\(fn\)/,
|
||||
'a shelf card click must call window.playSong with the recents filename',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Regression guard for "No DLC until restart": a library scan triggered from
|
||||
// Settings (rescan / full rescan, e.g. right after pointing at a DLC folder)
|
||||
// reloaded only the classic library — the v3 Songs grid kept its cached
|
||||
// (pre-DLC, empty) state until an app restart.
|
||||
//
|
||||
// The fix wires a `library:changed` event (emitted by the rescan handlers in
|
||||
// app.js) to a reload in static/v3/songs.js. That's DOM/event glue, not a pure
|
||||
// function, so these are source-level guards that the wiring isn't dropped; the
|
||||
// end-to-end behavior is verified in-app / by a browser test.
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
|
||||
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
|
||||
|
||||
test('app.js emits library:changed when a Settings rescan completes', () => {
|
||||
assert.match(APP, /emit\(\s*['"]library:changed['"]/,
|
||||
'a completed rescan must broadcast library:changed for the v3 grid');
|
||||
});
|
||||
|
||||
test('songs.js handles library:changed — reload when active, else mark dirty', () => {
|
||||
const m = SONGS.match(/sm\.on\(\s*['"]library:changed['"][\s\S]{0,500}?\}\);/);
|
||||
assert.ok(m, 'songs.js must subscribe to library:changed');
|
||||
assert.match(m[0], /reload\(\)/, 'reloads the grid when the screen is active');
|
||||
assert.match(m[0], /_libraryDirty\s*=\s*true/, 'marks dirty when off-screen');
|
||||
});
|
||||
|
||||
test('onV3SongsScreenEnter forces a reload when the library is dirty', () => {
|
||||
const m = SONGS.match(/function onV3SongsScreenEnter\(\)[\s\S]{0,400}?\{/);
|
||||
assert.ok(m, 'onV3SongsScreenEnter present');
|
||||
// The dirty check must short-circuit to a reload before the cached-DOM
|
||||
// fast-paths get a chance to restore the stale grid.
|
||||
assert.match(SONGS, /if\s*\(_libraryDirty\)\s*\{[^}]*reload\(\)[^}]*return;/,
|
||||
'a dirty library must force a full reload on entry, ahead of any fast-path');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// Guard for the content-dependent playlist cover (playlists.js). A custom
|
||||
// uploaded cover wins; otherwise the playlist's song art decides: icon when
|
||||
// empty, a single cover for a few songs, a 2×2 mosaic at 4+. (Rendering is DOM
|
||||
// glue, so this is a source-level guard on the decision branches.)
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PL = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js'), 'utf8');
|
||||
|
||||
test('custom cover_url takes priority', () => {
|
||||
assert.match(PL, /function playlistCoverHtml\(p\)/);
|
||||
assert.match(PL, /if \(p\.cover_url\) return/);
|
||||
});
|
||||
|
||||
test('empty → icon, <4 → single art, 4+ → 2×2 mosaic', () => {
|
||||
assert.match(PL, /if \(!arts\.length\)[\s\S]{0,160}(🔖|🎵)/); // empty → icon
|
||||
assert.match(PL, /arts\.length < 4\) return[\s\S]{0,120}arts\[0\]/); // a few → single cover
|
||||
assert.match(PL, /grid-cols-2 grid-rows-2[\s\S]{0,120}slice\(0, 4\)/); // 4+ → mosaic
|
||||
});
|
||||
|
||||
test('the card uses playlistCoverHtml (not the old static emoji box)', () => {
|
||||
assert.match(PL, /playlistCoverHtml\(p\)/);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Regression guard for the post-play score-badge refresh bug
|
||||
// (#574 follow-up): after finishing a song, its accuracy badge on the
|
||||
// Songs screen stayed stale until a full re-render (app restart / search /
|
||||
// re-enter), even though stats-recorder fired `stats:recorded`.
|
||||
//
|
||||
// Root cause: `stats:recorded` (like `song:loading`) carries the filename
|
||||
// exactly as handed to playSong — encodeURIComponent'd (see playCard) — but
|
||||
// library cards key on the DECODED filename (data-fn = cardKey → localFilename)
|
||||
// and /api/stats/best is server-canonicalized to that same decoded key. So the
|
||||
// in-place repaint (repaintAccuracy) matched no card and silently no-oped.
|
||||
//
|
||||
// The fix is a `decFn` helper in static/v3/songs.js that decodes the event
|
||||
// filename back into the card / state.accuracy key space before matching. This
|
||||
// test extracts the REAL decFn from the shipped source (not a mirror) and proves
|
||||
// the encoded event filename round-trips to the raw card key.
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
|
||||
// Brace-balanced extraction so nested braces / template strings survive.
|
||||
function extractFunctionSource(src, name) {
|
||||
const sig = `function ${name}`;
|
||||
const start = src.indexOf(sig);
|
||||
assert.ok(start !== -1, `function declaration '${name}' not found in songs.js`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
assert.ok(openBrace !== -1, `opening brace after '${name}' not found`);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, `unbalanced braces in function '${name}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function loadDecFn() {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
const fnSrc = extractFunctionSource(src, 'decFn');
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
// decodeURIComponent is an intrinsic global in the fresh context.
|
||||
vm.runInContext(`${fnSrc}\nglobalThis.__decFn = decFn;`, sandbox);
|
||||
return sandbox.__decFn;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent; // exactly what playCard passes to playSong
|
||||
|
||||
// The on-disk library filenames from the bug report's screenshots, plus a
|
||||
// subfolder path (encodeURIComponent turns '/' into %2F too).
|
||||
const CARD_KEYS = [
|
||||
'Black Me Out.sloppak',
|
||||
'All In Now.sloppak',
|
||||
'Dogstar - All In Now.feedpak',
|
||||
'Subdir/Song (Live).sloppak',
|
||||
];
|
||||
|
||||
test('decFn decodes an encoded event filename back to the raw card key', () => {
|
||||
const decFn = loadDecFn();
|
||||
for (const key of CARD_KEYS) {
|
||||
const eventFilename = enc(key); // how stats:recorded carries it
|
||||
// Precondition: the encoded form does NOT equal the card key — this is
|
||||
// exactly why the un-decoded match failed and the badge stayed stale.
|
||||
assert.notEqual(eventFilename, key, `expected '${key}' to encode to something different`);
|
||||
// The fix: decoding lands back on the card / state.accuracy key.
|
||||
assert.equal(decFn(eventFilename), key, `decFn must recover the card key for '${key}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('decFn is idempotent for already-decoded filenames (no % present)', () => {
|
||||
const decFn = loadDecFn();
|
||||
for (const key of CARD_KEYS) {
|
||||
assert.equal(decFn(key), key, `decFn must leave the already-decoded '${key}' unchanged`);
|
||||
}
|
||||
});
|
||||
|
||||
test('decFn leaves a real literal-% filename intact rather than throwing', () => {
|
||||
const decFn = loadDecFn();
|
||||
// '%.sloppak' / '100%.sloppak' are malformed percent-escapes —
|
||||
// decodeURIComponent would throw; decFn must fall back to the original.
|
||||
for (const name of ['100%.sloppak', 'mix %.feedpak', '%zz.sloppak']) {
|
||||
assert.equal(decFn(name), name, `decFn must not corrupt/throw on '${name}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('decFn coerces non-string / empty input to an empty string', () => {
|
||||
const decFn = loadDecFn();
|
||||
assert.equal(decFn(null), '');
|
||||
assert.equal(decFn(undefined), '');
|
||||
assert.equal(decFn(''), '');
|
||||
});
|
||||
@@ -36,13 +36,15 @@ function makeStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function saveSnapshot(storage, state, scrollTop, page, loadedCount) {
|
||||
// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed
|
||||
// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just
|
||||
// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets
|
||||
// scrollTop and re-renders the window that maps to it).
|
||||
function saveSnapshot(storage, state, scrollTop) {
|
||||
const snap = {
|
||||
hash: buildLibraryStateHash(state),
|
||||
scrollTop,
|
||||
view: state.view,
|
||||
page,
|
||||
loadedCount,
|
||||
};
|
||||
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
|
||||
}
|
||||
@@ -88,19 +90,21 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
|
||||
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
|
||||
});
|
||||
|
||||
test('snapshot stores scrollTop and page', () => {
|
||||
test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 1840, 3, 96);
|
||||
saveSnapshot(storage, baseState, 1840);
|
||||
const snap = readSnapshot(storage);
|
||||
assert.strictEqual(snap.scrollTop, 1840);
|
||||
assert.strictEqual(snap.page, 3);
|
||||
assert.strictEqual(snap.loadedCount, 96);
|
||||
assert.strictEqual(snap.view, 'grid');
|
||||
assert.strictEqual(snap.hash, buildLibraryStateHash(baseState));
|
||||
// Page-depth bookkeeping is gone — the windowed grid restores from scrollTop.
|
||||
assert.strictEqual(snap.page, undefined);
|
||||
assert.strictEqual(snap.loadedCount, undefined);
|
||||
});
|
||||
|
||||
test('stale snapshot is detected when filters change', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 500, 1, 48);
|
||||
saveSnapshot(storage, baseState, 500);
|
||||
const snap = readSnapshot(storage);
|
||||
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
|
||||
assert.notStrictEqual(snap.hash, changed);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Guards the v3 text-selection policy (static/v3/v3.css + static/v3/index.html):
|
||||
// the UI defaults to non-selectable so accidental chrome selection can't look
|
||||
// broken, while form fields, plugin screens, and core content opt back in. A
|
||||
// future global reset clobbering the rule — or the content containers losing
|
||||
// their .fb-selectable opt-in — should fail here.
|
||||
//
|
||||
// Source-level only — same strategy as the other tests/js/ files.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
// Strip block comments so the policy's own explanatory prose (which quotes the
|
||||
// `* { user-select:none }` anti-pattern as a warning) can't trip the assertions.
|
||||
const css = fs.readFileSync(path.join(root, 'static', 'v3', 'v3.css'), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const html = fs.readFileSync(path.join(root, 'static', 'v3', 'index.html'), 'utf8');
|
||||
|
||||
test('v3 defaults to non-selectable on html (not a universal `*` rule)', () => {
|
||||
assert.match(css, /html\s*\{[^}]*user-select:\s*none/,
|
||||
'html must default user-select: none');
|
||||
// The `* { user-select: none }` anti-pattern breaks input carets / IME — must not exist.
|
||||
assert.doesNotMatch(css, /\*\s*\{[^}]*user-select:\s*none/,
|
||||
'must NOT use a universal `*` user-select:none rule');
|
||||
});
|
||||
|
||||
test('form fields are always re-enabled (caret / IME safe)', () => {
|
||||
assert.match(
|
||||
css,
|
||||
/input,\s*textarea,\s*select[\s\S]*?contenteditable[\s\S]*?user-select:\s*text/,
|
||||
'input/textarea/select/[contenteditable] must be re-enabled to user-select: text',
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin screen subtree stays selectable by inheritance (no `*`, respects plugin opt-outs)', () => {
|
||||
assert.match(
|
||||
css,
|
||||
/\.screen\[id\^="plugin-"\]\s*\{[^}]*user-select:\s*text/,
|
||||
'plugin screens must be re-enabled so plugin content is not silently un-copyable',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/\.screen\[id\^="plugin-"\]\s*\*/,
|
||||
'the plugin carve must NOT use `*` (would override a plugin\'s own non-select chrome)',
|
||||
);
|
||||
});
|
||||
|
||||
// The rule that re-enables selection on copyable content. Find the single
|
||||
// declaration block whose body sets `user-select: text`, then assert each
|
||||
// required selector is one of its selectors — order/format independent.
|
||||
const selectableRule = (css.match(/([^{}]*)\{[^}]*user-select:\s*text[^}]*\}/g) || [])
|
||||
.join('\n');
|
||||
|
||||
test('core content opts back in via .fb-selectable (element + descendants)', () => {
|
||||
assert.match(selectableRule, /\.fb-selectable\b/, '.fb-selectable must set user-select: text');
|
||||
assert.match(selectableRule, /\.fb-selectable\s*\*/, '...and its descendants (.fb-selectable *)');
|
||||
});
|
||||
|
||||
test('focused copyable surfaces (modals/toasts/scan banner) opt back in', () => {
|
||||
// The PR\'s a11y guardrail keeps copyable text selectable "incl. in
|
||||
// modals/toasts" — these carry errors / IDs / paths the user copies.
|
||||
assert.match(selectableRule, /\.feedBack-modal\b/, 'modals (.feedBack-modal) must be selectable');
|
||||
assert.match(selectableRule, /\[role="dialog"\]/, 'dialogs ([role="dialog"]) must be selectable');
|
||||
assert.match(selectableRule, /#fb-notify-stack\b/, 'toasts (#fb-notify-stack) must be selectable');
|
||||
assert.match(selectableRule, /#scan-banner\b/, 'the scan banner (#scan-banner) must be selectable');
|
||||
});
|
||||
|
||||
// Match a class="" attribute that contains ALL given tokens in any order.
|
||||
const hasClasses = (...tokens) => new RegExp(
|
||||
'class="' + tokens.map((t) => '(?=[^"]*\\b' + t + '\\b)').join('') + '[^"]*"');
|
||||
|
||||
test('the Settings panel and now-playing metadata carry .fb-selectable', () => {
|
||||
assert.match(html, hasClasses('fb-settings', 'fb-selectable'),
|
||||
'the Settings panel must opt back in (paths / version / diagnostics / About)');
|
||||
assert.match(html, hasClasses('fb-selectable', 'pointer-events-auto'),
|
||||
'the now-playing metadata must opt back in AND re-enable pointer-events '
|
||||
+ '(its #player-hud parent is pointer-events-none, which would block mouse selection)');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'achievements'))
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_live_drain():
|
||||
# routes now defaults the wall URL to the live onrender service, so setup()
|
||||
# would auto-start the drain thread. Disable it for every test so no test
|
||||
# ever POSTs to production; the drain logic is exercised via _drain_once()
|
||||
# with an injected poster instead.
|
||||
ach_routes._WALL_URL = ""
|
||||
ach_routes._drain_started = False
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
app = FastAPI()
|
||||
ach_routes.setup(app, {"config_dir": str(tmp_path)})
|
||||
return TestClient(app)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Data-minimization contract + opt-in gating (binding).
|
||||
|
||||
The outbound wall payload must be EXACTLY {display_name, player_hash,
|
||||
achievement_id, unlocked_at}; competency unlocks must never enqueue; and nothing
|
||||
enqueues unless the user opted in AND has a profile identity.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import engine
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
class _FakeMetaDB:
|
||||
def __init__(self, name="Ada", phash="deadbeefcafe"):
|
||||
self._p = {"display_name": name, "player_hash": phash}
|
||||
|
||||
def get_profile(self):
|
||||
return dict(self._p)
|
||||
|
||||
|
||||
def _make_client(tmp_path, *, opted_in, meta_db=None):
|
||||
if opted_in:
|
||||
(tmp_path / "config.json").write_text(json.dumps({"achievements_enabled": True}))
|
||||
app = FastAPI()
|
||||
ach_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _queue_rows(tmp_path):
|
||||
db = sqlite3.connect(str(tmp_path / "achievements" / "achievements.db"))
|
||||
try:
|
||||
return [
|
||||
{"kind": k, "payload": p, "state": s}
|
||||
for (k, p, s) in db.execute("SELECT kind, payload, state FROM sync_queue")
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── The pure serializer is the only gate ─────────────────────────────────────
|
||||
|
||||
def test_serializer_keyset_is_exactly_four():
|
||||
payload = engine.build_wall_payload("Ada", "hash", "notes_total", "2026-06-24T00:00:00Z")
|
||||
assert set(payload.keys()) == set(engine.WALL_PAYLOAD_KEYS)
|
||||
assert len(payload) == 4 # go red if a fifth field is ever added
|
||||
|
||||
|
||||
# ── End-to-end enqueue gating ────────────────────────────────────────────────
|
||||
|
||||
def test_opted_out_never_enqueues(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=False, meta_db=_FakeMetaDB())
|
||||
res = client.post("/api/plugins/achievements/activity", json={"notes": 100000}).json()
|
||||
assert "notes_total" in [u["id"] for u in res["unlocked"]] # feat did unlock
|
||||
assert _queue_rows(tmp_path) == [] # but nothing queued
|
||||
|
||||
|
||||
def test_opted_in_enqueues_exactly_one_four_field_payload(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=True, meta_db=_FakeMetaDB())
|
||||
client.post("/api/plugins/achievements/activity", json={"notes": 100000})
|
||||
rows = [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"]
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0]["payload"])
|
||||
assert set(payload.keys()) == set(engine.WALL_PAYLOAD_KEYS)
|
||||
assert payload["achievement_id"] == "notes_total"
|
||||
assert payload["display_name"] == "Ada"
|
||||
|
||||
|
||||
def test_opted_in_without_identity_does_not_enqueue(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=True, meta_db=None)
|
||||
client.post("/api/plugins/achievements/activity", json={"notes": 100000})
|
||||
assert [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"] == []
|
||||
|
||||
|
||||
def test_competency_never_enqueues_even_opted_in(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=True, meta_db=_FakeMetaDB())
|
||||
client.post("/api/plugins/achievements/report-unlock", json={
|
||||
"id": "ascendant", "kind": "achievement", "category": "global", "tier": 1})
|
||||
assert [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"] == []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user