mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 13:44:31 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bfe508642 | ||
|
|
2a15f6e757 | ||
|
|
90fb2ee3bc | ||
|
|
3d97c07b2b | ||
|
|
b103a722ce | ||
|
|
d841813e0b | ||
|
|
a0f5435854 | ||
|
|
2a43d5b494 | ||
|
|
ee7bafbb47 | ||
|
|
fef870047b | ||
|
|
290783b80b |
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **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.
|
||||
@@ -36,6 +40,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||
|
||||
### Fixed
|
||||
- **v3 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.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,186 @@
|
||||
# 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:
|
||||
|
||||
`surface · card · border · text · text-dim · accent · accent-2 · good · warn · bad`
|
||||
plus two **new keystones**:
|
||||
|
||||
- **`on-accent`** — foreground legible *on* an accent fill. (Rule: any role used as a fill
|
||||
behind text gets a paired `on-*`.) Fixes white-on-amber.
|
||||
- **`focus-ring`** — focus indicator independent of accent, so focus stays visible when
|
||||
`accent ≈ surface`.
|
||||
|
||||
`accent-2` is demoted to "just a second hue" — **never** an assumed gradient end.
|
||||
|
||||
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
|
||||
|
||||
A theme declares its design *language* by filling intent-named slots. A feature applies the
|
||||
slot bundle **unconditionally**; it never branches on "is this theme glowy?". Atomic slots
|
||||
(renames-by-intent of today's tokens): `corner-radius`, `corner-clip`, `panel-shadow`,
|
||||
`text-emph-shadow`, `panel-texture`, `motion-decorative` (reduced-motion-gated).
|
||||
|
||||
Two **composite recipes** carry the load:
|
||||
|
||||
- **EMPHASIS** — how this theme makes a primary action special:
|
||||
`--emph-fill / --emph-border / --emph-halo / --emph-on`.
|
||||
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
|
||||
None empty — each emphasises in its own language.
|
||||
- **ACCENT-TEXT** — how this theme fills a big accent number: `--acc-text-fill`
|
||||
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
|
||||
|
||||
> 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
|
||||
|
||||
On the existing `window.feedBack` bus:
|
||||
|
||||
- `feedBack.theme.get()` → `{ id, tokens, isThemed }`
|
||||
- `feedBack.theme.capabilities()` → `{ glow, gradients, motion }` (the device-affordance signal)
|
||||
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once)
|
||||
- `theme:changed` event → `{ id, tokens, capabilities }` (emitted at theme-core's existing
|
||||
apply chokepoint; analogous to `note_detect`'s `notedetect:skin`)
|
||||
|
||||
**Reconciliation rule (ends the two-disconnected-systems problem) — depends on whether the
|
||||
plugin has its own identity:**
|
||||
|
||||
- **A plugin *without* its own skin** should **derive surface/text/border from host tokens**
|
||||
(`background: var(--fbv-card)`, etc.) and **own only its accent + its devices**, selecting the
|
||||
device via the recipe. A host theme then pulls its chrome along — one truth for surfaces — and
|
||||
it never imposes a device the active theme neutralizes. This is the common case.
|
||||
- **A plugin *with* deliberate skins** (e.g. `note_detect`'s neon / esports / metal, which are
|
||||
full design languages) **owns its surfaces** — deriving them from the host theme would *erase*
|
||||
the skin's identity (metal's brushed steel becomes a flat host colour). Such a plugin adopts the
|
||||
**role + recipe *pattern*** (per-skin device tokens, `on-accent`, `focus-ring`, "off" is legal)
|
||||
and verifies across **its own** skin matrix, but does not blindly inherit host surfaces.
|
||||
*(Decided 2026-06-29: keep note_detect's skins self-owned + their current button text — so the
|
||||
fix is the per-skin device pattern + the verification gate, not surface-derivation.)*
|
||||
|
||||
## 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(--emph-fill);
|
||||
border: var(--emph-border);
|
||||
box-shadow: var(--emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
|
||||
color: var(--emph-on); /* never hardcoded #fff again */
|
||||
border-radius: var(--corner-radius);
|
||||
}
|
||||
.accuracy-number {
|
||||
color: var(--accent); /* legible solid fallback FIRST */
|
||||
background: var(--acc-text-fill);
|
||||
background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Accessibility (baked into the contract, not per-feature)
|
||||
|
||||
- **Reduced motion:** `motion-decorative` is the *only* place decorative animation is named;
|
||||
one central rule sets it to `none` under `@media (prefers-reduced-motion: reduce)`, so no
|
||||
theme can forget the gate.
|
||||
- **Focus parity:** exactly one contract-level `:focus-visible { outline: 2px solid var(--focus-ring) }`;
|
||||
themes recolour `focus-ring` but may not author their own focus styling.
|
||||
- **On-accent contrast:** `on-accent` is required and **lintable** (`contrast(on-accent, accent) ≥ 4.5:1`,
|
||||
3:1 large). 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: new `--fb-*` runtime vars + a new `feedBack.theme` namespace + a new event with no
|
||||
current listeners. Existing plugins (those reading `fb-*` utility classes, or shipping their own
|
||||
skins) are untouched unless they opt in; two-arg `var(--fb-x, fallback)` + `window.feedBack?.theme?.get`
|
||||
feature-detection means older hosts behave exactly as today.
|
||||
|
||||
**Workstream (sub-tasks):**
|
||||
1. **Host minimal surface** — always-present default `--fb-*` tokens + `feedBack.theme.{get,capabilities,prefersReducedMotion}` + `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.
|
||||
- Where the central reduced-motion + focus rules physically live (core base layer vs a shared
|
||||
plugin import) and how the host injects role tokens into plugin roots.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Theming a plugin so it works in every theme
|
||||
|
||||
This is the how-to for plugin authors. It exists because of one recurring bug:
|
||||
a plugin's UI is built and eyeballed against the **default** look, ships, and
|
||||
then a user switches themes and the feature's *colours adapt but its visual
|
||||
devices vanish* — a glow ring, a colour gradient — because the active theme
|
||||
doesn't speak that visual language.
|
||||
|
||||
**The golden rule:** themes are different **design languages**, not palettes.
|
||||
Reference **roles** and **recipe slots**; never hardcode a *device* (a literal
|
||||
`box-shadow` glow, a literal `linear-gradient`, a hex colour). Then your feature
|
||||
renders correctly in a theme nobody has invented yet — and **verify it across
|
||||
the whole theme set before you merge.**
|
||||
|
||||
(Design + rationale: [host-theme-contract.md](host-theme-contract.md), got-feedback/feedBack#644.)
|
||||
|
||||
## What the host gives you
|
||||
|
||||
Always-present CSS role tokens on `:root` (resolve themed *or* un-themed):
|
||||
`--fbv-bg / sidebar / card / cardMuted / primary / primaryHi / accent / text /
|
||||
textDim / border / good / mid / low / gold`, plus **`--fbv-on-accent`** (a
|
||||
foreground legible *on* an accent fill) and **`--fbv-focus-ring`**. Use as
|
||||
`color: rgb(var(--fbv-text))`, `background: rgb(var(--fbv-card))`, etc.
|
||||
|
||||
A JS read surface on the event bus:
|
||||
|
||||
```js
|
||||
const t = window.feedBack?.theme;
|
||||
t?.get(); // { id, isThemed, tokens }
|
||||
t?.capabilities(); // { glow, gradients, motion } — what devices this theme permits
|
||||
t?.prefersReducedMotion(); // boolean (one central matchMedia)
|
||||
window.feedBack.on('theme:changed', (e) => { /* e.detail = {id, tokens, capabilities} */ });
|
||||
```
|
||||
|
||||
Feature-detect everything (`window.feedBack?.theme?.get`) and pass a fallback to
|
||||
every `var(--fbv-x, <fallback>)` so you degrade cleanly on an older host.
|
||||
|
||||
## Pick your path
|
||||
|
||||
### Path A — you do NOT have your own skins (most plugins)
|
||||
|
||||
Look like the host. **Derive surfaces from host tokens** and **choose devices
|
||||
from capabilities** instead of hardcoding them:
|
||||
|
||||
```js
|
||||
const caps = window.feedBack?.theme?.capabilities?.() ?? { glow: true, motion: true };
|
||||
heroEl.classList.toggle('use-glow', caps.glow && !window.feedBack.theme.prefersReducedMotion());
|
||||
```
|
||||
```css
|
||||
.hero { background: rgb(var(--fbv-primary)); color: rgb(var(--fbv-on-accent, #fff)); }
|
||||
.hero:focus-visible { outline: 2px solid rgb(var(--fbv-focus-ring)); outline-offset: 2px; }
|
||||
.hero.use-glow { box-shadow: 0 0 16px -2px rgb(var(--fbv-primary)); } /* only where the theme allows it */
|
||||
```
|
||||
Re-read on `theme:changed` if you cache anything (e.g. a canvas palette).
|
||||
|
||||
### Path B — you HAVE your own deliberate skins (like the scoring UI)
|
||||
|
||||
Your skins are an identity (neon vs steel vs clean) — **own your surfaces**;
|
||||
don't inherit host surfaces or you'll erase that identity. Adopt only the
|
||||
**pattern**: make every visual *device* a **per-skin token whose "off" value is
|
||||
legal**, so each skin authors its own version and a glow-less skin simply
|
||||
doesn't glow. Example (the "make the hero special" device):
|
||||
|
||||
```css
|
||||
/* base / neon */ :root { --hero-ring: .5; --hero-border: transparent; --acc-fill: linear-gradient(135deg, var(--accent), var(--accent2)); }
|
||||
/* glow-less skin */ [data-skin="clean"] { --hero-ring: 0; --hero-border: var(--accent); --acc-fill: var(--accent); }
|
||||
.hero { background: var(--acc-fill); border-color: var(--hero-border); }
|
||||
.hero::after { opacity: var(--hero-ring); /* the glow ring; 0 = off */ }
|
||||
```
|
||||
Neon emphasises with the ring, the clean skin with a solid border — **neither is
|
||||
empty of emphasis; each speaks its own language.** Same idea for an accent-filled
|
||||
number (`--acc-fill` = a gradient in one skin, a solid in another so it doesn't
|
||||
wash out to white). Add `on-accent` + `focus-ring` per skin too.
|
||||
|
||||
## Accessibility (part of the contract, not an afterthought)
|
||||
|
||||
- **Reduced motion:** gate decorative animation on `prefers-reduced-motion`
|
||||
(CSS `@media`, or `theme.prefersReducedMotion()`); functional transitions are fine.
|
||||
- **Focus:** always a visible `:focus-visible` outline using `focus-ring` — don't
|
||||
bind focus only to `accent` (it can ≈ the surface in some themes).
|
||||
- **On-accent contrast:** text on an accent fill uses `on-accent`; watch light
|
||||
accents (a mid-amber needs dark text, not white).
|
||||
|
||||
## Before you merge — verify across the matrix
|
||||
|
||||
Render your UI in **every** theme/skin and look at them together. If you ship
|
||||
your own skins, do this with a render-matrix in CI/local (reference: the scoring
|
||||
UI's `npm run render-skins` + `theme-matrix-checklist.md`). The checklist that
|
||||
catches this bug class:
|
||||
|
||||
- [ ] colours via tokens, no hardcoded hexes
|
||||
- [ ] rendered in **all** themes/skins (not just the default)
|
||||
- [ ] every new *device* is per-skin / capability-gated and **legible when its token is `none`**
|
||||
- [ ] reduced-motion + visible focus in every theme
|
||||
- [ ] text on accent stays legible everywhere
|
||||
@@ -1373,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
|
||||
|
||||
@@ -220,6 +220,8 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("POST", re.compile(r"^/api/saved/toggle$")),
|
||||
# Progression (spec 010) write endpoints — demo mode stays read-only.
|
||||
("POST", re.compile(r"^/api/progression/paths$")),
|
||||
@@ -1323,14 +1325,30 @@ class MetadataDB:
|
||||
return None
|
||||
|
||||
def list_playlists(self) -> list[dict]:
|
||||
from urllib.parse import quote
|
||||
rows = self.conn.execute(
|
||||
"SELECT id, name, system_key, created_at, updated_at FROM playlists "
|
||||
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [{
|
||||
"id": r[0], "name": r[1], "system_key": r[2],
|
||||
"created_at": r[3], "updated_at": r[4], "count": self._playlist_count(r[0]),
|
||||
} for r in rows]
|
||||
out = []
|
||||
for r in rows:
|
||||
pid = r[0]
|
||||
# First few still-present songs (in order) → art URLs, for a
|
||||
# content-dependent playlist cover (single art / 2x2 mosaic). The
|
||||
# JOIN drops dead songs, matching get_playlist's visibility.
|
||||
arts = self.conn.execute(
|
||||
"SELECT ps.filename FROM playlist_songs ps "
|
||||
"JOIN songs s ON s.filename = ps.filename "
|
||||
"WHERE ps.playlist_id = ? ORDER BY ps.position LIMIT 4",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
out.append({
|
||||
"id": pid, "name": r[1], "system_key": r[2],
|
||||
"created_at": r[3], "updated_at": r[4],
|
||||
"count": self._playlist_count(pid),
|
||||
"art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts],
|
||||
})
|
||||
return out
|
||||
|
||||
def create_playlist(self, name: str, system_key: str | None = None) -> dict:
|
||||
with self._lock:
|
||||
@@ -5149,9 +5167,35 @@ def api_song_stats(filename: str):
|
||||
|
||||
# ── Playlists / Saved for Later / Continue-Playing (fee[dB]ack v0.3.0) ────────
|
||||
|
||||
def _playlist_cover_path(pid) -> Path | None:
|
||||
"""Filesystem path of a playlist's optional custom cover image (PNG),
|
||||
stored under CONFIG_DIR. Returns None for a non-integer id."""
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return CONFIG_DIR / "playlist_covers" / f"{pid}.png"
|
||||
|
||||
|
||||
def _playlist_cover_url(pid) -> str | None:
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return None
|
||||
try:
|
||||
# Nanosecond mtime so a same-second replace/remove/re-upload still
|
||||
# changes the cache-bust token (int seconds could collide → stale image).
|
||||
mt = cover.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mt = 0
|
||||
return f"/api/playlists/{pid}/cover?v={mt}"
|
||||
|
||||
|
||||
@app.get("/api/playlists")
|
||||
def api_list_playlists():
|
||||
return meta_db.list_playlists()
|
||||
lists = meta_db.list_playlists()
|
||||
for pl in lists:
|
||||
pl["cover_url"] = _playlist_cover_url(pl["id"])
|
||||
return lists
|
||||
|
||||
|
||||
@app.post("/api/playlists")
|
||||
@@ -5167,6 +5211,7 @@ def api_get_playlist(pid: int):
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl["cover_url"] = _playlist_cover_url(pid)
|
||||
return pl
|
||||
|
||||
|
||||
@@ -5193,6 +5238,12 @@ def api_delete_playlist(pid: int):
|
||||
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
|
||||
if not meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -5239,6 +5290,64 @@ def api_reorder_playlist(pid: int, data: dict):
|
||||
return meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@app.post("/api/playlists/{pid}/cover")
|
||||
async def api_set_playlist_cover(pid: int, data: dict):
|
||||
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
|
||||
Overrides the content-dependent (song-art) cover. Stored as a small PNG
|
||||
thumbnail under CONFIG_DIR/playlist_covers/."""
|
||||
if meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
# Guard the type before the `","` membership test — a non-string image
|
||||
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
|
||||
# Mirrors the avatar/song-art upload guard.
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
if not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
cover = _playlist_cover_path(pid)
|
||||
cover.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
||||
img.thumbnail((640, 640)) # covers stay small
|
||||
tmp = cover.with_suffix(".png.tmp")
|
||||
img.save(str(tmp), "PNG")
|
||||
tmp.replace(cover)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
|
||||
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
|
||||
|
||||
|
||||
@app.get("/api/playlists/{pid}/cover")
|
||||
def api_get_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
# no-cache (revalidate) like song art, so a replaced cover is never served
|
||||
# stale — pairs with the mtime-ns cache-bust token on the URL.
|
||||
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@app.delete("/api/playlists/{pid}/cover")
|
||||
def api_delete_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/saved/toggle")
|
||||
def api_toggle_saved(data: dict):
|
||||
"""Add/remove a song on the reserved Saved-for-Later playlist."""
|
||||
|
||||
+411
-11
@@ -460,6 +460,25 @@ function _shortcutDispatchBlocked(e) {
|
||||
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
|
||||
return false;
|
||||
}
|
||||
// Escape is the universal "back" action and must fire like Space above even
|
||||
// when a transport/rail control <button> holds keyboard focus after a click
|
||||
// — otherwise a focused control swallows Esc and the user can't leave the
|
||||
// song until they click empty canvas (feedBack — "Escape in song not
|
||||
// consistent"). It applies on the player (exit the song) AND settings
|
||||
// (return to the previous screen), both of which register an Escape=Back
|
||||
// shortcut. The earlier guards still win: text inputs are exempted at the
|
||||
// top (Esc there clears/blurs the field), and the Section Practice popover
|
||||
// already claimed Esc above. A true modal layered over the screen still
|
||||
// traps Esc — the modal-overlay check keeps Esc closing the modal rather
|
||||
// than ejecting past it to the screen behind.
|
||||
if (e.key === 'Escape') {
|
||||
const ctx = _getCurrentContext();
|
||||
if ((ctx.isPlayer || ctx.isSettings) &&
|
||||
!(e.target && e.target.closest &&
|
||||
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return _isInsideInteractiveControl(e.target);
|
||||
}
|
||||
|
||||
@@ -1033,6 +1052,11 @@ async function showScreen(id) {
|
||||
const audio = document.getElementById('audio');
|
||||
const stopTime = _audioTime();
|
||||
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying;
|
||||
// Snapshot where we were so leaving the player — especially by accident
|
||||
// — is recoverable instead of dumping the user back at bar 1 next time.
|
||||
// Must run BEFORE highway.stop()/audio unload, while getSongInfo() and
|
||||
// the position (stopTime) are still live.
|
||||
if (hadPlayableSong) _snapshotResumeSession(stopTime);
|
||||
highway.stop();
|
||||
// Cancel any queued seeks, in-flight shim closures, AND active
|
||||
// count-in timers before stopping playback so none of these paths
|
||||
@@ -3394,6 +3418,8 @@ async function loadSettings() {
|
||||
if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled();
|
||||
const showUpNextEl = document.getElementById('setting-show-upnext');
|
||||
if (showUpNextEl) showUpNextEl.checked = _showUpNextEnabled();
|
||||
const confirmExitEl = document.getElementById('setting-confirm-exit');
|
||||
if (confirmExitEl) confirmExitEl.checked = _exitConfirmEnabled();
|
||||
// Restore master-difficulty slider from persisted value (defaults
|
||||
// to 100 when the key is absent — no behaviour change for users
|
||||
// who've never touched the slider).
|
||||
@@ -4517,6 +4543,9 @@ async function rescanLibrary() {
|
||||
_treeStats = null;
|
||||
_tuningNames = null; // re-fetch on next drawer open
|
||||
loadLibrary();
|
||||
// Tell the v3 Songs grid the library changed so it reloads instead of
|
||||
// keeping a cached (e.g. pre-DLC, empty) grid until an app restart.
|
||||
if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -4545,6 +4574,9 @@ async function fullRescanLibrary() {
|
||||
_treeStats = null;
|
||||
_tuningNames = null; // re-fetch on next drawer open
|
||||
loadLibrary();
|
||||
// Tell the v3 Songs grid the library changed so it reloads instead of
|
||||
// keeping a cached (e.g. pre-DLC, empty) grid until an app restart.
|
||||
if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -5971,6 +6003,201 @@ window.feedBack.on('song:ready', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
// Leaving a song snapshots where you were — song, arrangement, position, and
|
||||
// speed — so an exit (especially an accidental one, now that Escape reliably
|
||||
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
|
||||
// The snapshot is offered back through a non-blocking "Resume" pill; it never
|
||||
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
|
||||
// (This is the player-session slice; the broader nav/state-resume work — e.g.
|
||||
// returning to a song after wandering into Settings → Tone Builder — is a
|
||||
// separate, larger track.)
|
||||
const _RESUME_KEY = 'feedBack.resumeSession';
|
||||
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
|
||||
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
|
||||
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
|
||||
let _pendingResume = null; // {position, speed}, consumed at song:ready
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
function _curPlaybackSpeed() {
|
||||
try {
|
||||
return window._juceMode
|
||||
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
|
||||
: (document.getElementById('audio')?.playbackRate || 1);
|
||||
} catch (_) { return 1; }
|
||||
}
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!currentFilename) return;
|
||||
const si = (window.highway && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
// glance at the first seconds, and not one that already basically ended.
|
||||
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
|
||||
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
|
||||
const snap = {
|
||||
f: currentFilename,
|
||||
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
|
||||
? si.arrangement_index : undefined,
|
||||
t: pos,
|
||||
sp: _curPlaybackSpeed(),
|
||||
title: si.title || '',
|
||||
artist: si.artist || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
|
||||
// A fresh snapshot earns one offer — undo any earlier dismissal.
|
||||
_resumePillDismissed = false;
|
||||
} catch (_) { /* storage unavailable — resume is best-effort */ }
|
||||
}
|
||||
|
||||
function _readResumeSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_RESUME_KEY);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
|
||||
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
|
||||
return snap;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function _clearResumeSession() {
|
||||
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-enter the snapshotted song and restore arrangement + position + speed.
|
||||
async function resumeLastSession() {
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return false; }
|
||||
_hideResumePill();
|
||||
try {
|
||||
await playSong(snap.f, snap.a, {
|
||||
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
// A transient load/connect failure must not strand the user: keep the
|
||||
// snapshot so the pill can re-offer it on the next non-player screen,
|
||||
// rather than consuming the only copy before the song actually loaded.
|
||||
console.warn('[app] resume failed to load; keeping snapshot:', err);
|
||||
_pendingResume = null;
|
||||
return false;
|
||||
}
|
||||
_clearResumeSession(); // consumed only after a successful load
|
||||
return true;
|
||||
}
|
||||
window.resumeLastSession = resumeLastSession;
|
||||
if (window.feedBack) window.feedBack.resumeLastSession = resumeLastSession;
|
||||
|
||||
// Consume a pending resume once the chart is ready: restore speed, seek to the
|
||||
// saved position, then (if autoplay is on) start from there. playSong() does
|
||||
// NOT arm autostart for a resume load, so the two never fight over playback.
|
||||
window.feedBack.on('song:ready', () => {
|
||||
const pend = _pendingResume;
|
||||
if (!pend) return;
|
||||
_pendingResume = null;
|
||||
try {
|
||||
if (pend.speed && pend.speed > 0) {
|
||||
const slider = document.getElementById('speed-slider');
|
||||
if (slider) slider.value = String(Math.round(pend.speed * 100));
|
||||
setSpeed(pend.speed);
|
||||
}
|
||||
} catch (_) { /* speed restore is best-effort */ }
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
|
||||
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||
.catch((err) => console.warn('[app] resume failed:', err));
|
||||
});
|
||||
|
||||
// A song that finishes on its own has nothing to resume — and we never want to
|
||||
// offer "resume" for a song the user just completed.
|
||||
window.feedBack.on('song:ended', _clearResumeSession);
|
||||
|
||||
// ── Resume pill (non-blocking "continue where you left off") ────────────────
|
||||
// Self-contained, inline-styled, body-appended so it works identically in the
|
||||
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
|
||||
// the player screen, never blocks, and a dismiss forgets the current snapshot
|
||||
// for the session.
|
||||
function _hideResumePill() {
|
||||
const el = document.getElementById('fb-resume-pill');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function _maybeShowResumePill() {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') { _hideResumePill(); return; }
|
||||
if (_resumePillDismissed) return;
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return; }
|
||||
if (document.getElementById('fb-resume-pill')) return; // already shown
|
||||
|
||||
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
|
||||
const pill = document.createElement('div');
|
||||
pill.id = 'fb-resume-pill';
|
||||
pill.setAttribute('role', 'status');
|
||||
pill.style.cssText = [
|
||||
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
|
||||
'display:flex', 'align-items:center', 'gap:10px',
|
||||
'max-width:min(90vw,360px)', 'padding:10px 12px',
|
||||
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
|
||||
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.style.cssText = 'flex:1;min-width:0';
|
||||
const t1 = document.createElement('div');
|
||||
t1.textContent = 'Resume practice';
|
||||
t1.style.cssText = 'font-weight:600;color:#fff';
|
||||
const t2 = document.createElement('div');
|
||||
t2.textContent = label;
|
||||
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
text.appendChild(t1); text.appendChild(t2);
|
||||
|
||||
const resumeBtn = document.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.textContent = 'Resume ▸';
|
||||
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '✕';
|
||||
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
|
||||
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
|
||||
|
||||
pill.appendChild(text);
|
||||
pill.appendChild(resumeBtn);
|
||||
pill.appendChild(dismissBtn);
|
||||
(document.body || document.documentElement).appendChild(pill);
|
||||
}
|
||||
if (window.feedBack) window.feedBack._maybeShowResumePill = _maybeShowResumePill;
|
||||
|
||||
// Exposed for tests/debugging (mirrors window._panels / _getCurrentContext).
|
||||
window._snapshotResumeSession = _snapshotResumeSession;
|
||||
window._readResumeSession = _readResumeSession;
|
||||
window._clearResumeSession = _clearResumeSession;
|
||||
|
||||
// Drive the pill off screen transitions (hide over the player, offer it
|
||||
// elsewhere) plus a one-shot check on first load for a prior-session snapshot.
|
||||
window.feedBack.on('screen:changed', (ev) => {
|
||||
const id = (ev && ev.detail && ev.detail.id) || (ev && ev.id);
|
||||
if (id === 'player') _hideResumePill();
|
||||
else _maybeShowResumePill();
|
||||
});
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded',
|
||||
() => { try { _maybeShowResumePill(); } catch (_) {} }, { once: true });
|
||||
} else {
|
||||
try { _maybeShowResumePill(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The
|
||||
// editor's "Loop in 3D" button stashes a pending loop + return context, then
|
||||
// calls playSong(). Once the chart is ready (playSong's own clearLoop() has
|
||||
@@ -6124,8 +6351,17 @@ async function playSong(filename, arrangement, options) {
|
||||
|
||||
currentFilename = filename;
|
||||
// A fresh load arms autoplay; a pending auto-exit from the previous
|
||||
// song is no longer relevant.
|
||||
_pendingAutostart = true;
|
||||
// song is no longer relevant. A *resume* load (options.resume) instead
|
||||
// arms _pendingResume — consumed at song:ready to restore speed + seek to
|
||||
// the saved position, then start — so autostart and resume don't both try
|
||||
// to begin playback from different positions.
|
||||
if (options && options.resume && Number(options.resume.position) > 0) {
|
||||
_pendingResume = options.resume;
|
||||
_pendingAutostart = false;
|
||||
} else {
|
||||
_pendingResume = null;
|
||||
_pendingAutostart = true;
|
||||
}
|
||||
_clearAutoExit();
|
||||
// Remember which screen the player was launched from so Esc /
|
||||
// navigation back from the player (and auto-exit) returns the user
|
||||
@@ -6397,6 +6633,135 @@ function closeCurrentSong() {
|
||||
window.closeCurrentSong = closeCurrentSong;
|
||||
if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
|
||||
|
||||
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
||||
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
||||
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
||||
// of leaving immediately. Auto-exit on song-end and a results screen's own
|
||||
// Close never prompt — they call closeCurrentSong() directly, which stays the
|
||||
// unguarded actual-exit.
|
||||
function _exitConfirmEnabled() {
|
||||
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
// Settings checkbox setter (onchange="setConfirmExitSong(this.checked)").
|
||||
window.setConfirmExitSong = function (on) {
|
||||
try { localStorage.setItem('confirmExitSong', on ? '1' : '0'); } catch (_) { /* private mode */ }
|
||||
const el = document.getElementById('setting-confirm-exit');
|
||||
if (el && el.checked !== !!on) el.checked = !!on;
|
||||
};
|
||||
|
||||
let _exitConfirmOpen = false; // guard against stacking confirm modals
|
||||
|
||||
// User-initiated request to leave the player. Honors the confirm toggle; the
|
||||
// actual exit is always closeCurrentSong() (origin-aware teardown).
|
||||
function requestExitSong() {
|
||||
if (!_exitConfirmEnabled()) { closeCurrentSong(); return; }
|
||||
if (_exitConfirmOpen) return; // already asking
|
||||
_openExitConfirm();
|
||||
}
|
||||
window.requestExitSong = requestExitSong;
|
||||
if (window.feedBack) window.feedBack.requestExitSong = requestExitSong;
|
||||
|
||||
// A *true* modal (role="dialog" aria-modal="true" + .feedBack-modal) so the
|
||||
// Escape/Space carve-outs classify it as a focus trap — they won't fire
|
||||
// player-back / play-pause while it's up. Opening it PAUSES the song so it
|
||||
// isn't running (or being scored) behind the prompt; Stay resumes exactly what
|
||||
// we paused. Escape matches every other modal (and the generic _confirmDialog):
|
||||
// it *dismisses* the prompt → Stay → drops you back into the (resumed) song —
|
||||
// so a second Escape does NOT leave. Leaving is the explicit, default-focused
|
||||
// "Leave" button, so Space/Enter (or click) is the keyboard "just get me out".
|
||||
function _openExitConfirm() {
|
||||
_exitConfirmOpen = true;
|
||||
// Freeze the song while the user decides: cancel any pending count-in (so it
|
||||
// can't start playback behind the modal) and pause if we're playing. Stay
|
||||
// resumes only what we paused (wasPlaying), and only if the same song is
|
||||
// still live on the player — guarding a teardown/seek/end behind the prompt.
|
||||
_cancelCountIn();
|
||||
const _resumeGen = _audioSeekGen;
|
||||
const _wasPlaying = isPlaying;
|
||||
if (_wasPlaying) Promise.resolve(togglePlay()).catch(() => {});
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'fb-exit-confirm';
|
||||
overlay.className = 'feedBack-modal';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Leave this song?');
|
||||
overlay.style.cssText = [
|
||||
'position:fixed', 'inset:0', 'z-index:200', 'display:flex',
|
||||
'align-items:center', 'justify-content:center',
|
||||
'background:rgba(0,0,0,0.6)',
|
||||
'font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = [
|
||||
'max-width:min(92vw,360px)', 'padding:18px 18px 14px',
|
||||
'background:#111827', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:12px',
|
||||
'box-shadow:0 12px 40px rgba(0,0,0,0.5)', 'text-align:left',
|
||||
].join(';');
|
||||
const h = document.createElement('div');
|
||||
h.textContent = 'Leave this song?';
|
||||
h.style.cssText = 'font-size:16px;font-weight:700;color:#fff;margin-bottom:6px';
|
||||
const p = document.createElement('div');
|
||||
p.textContent = 'You can pick up where you left off from the Resume pill.';
|
||||
p.style.cssText = 'opacity:0.75;margin-bottom:16px';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end';
|
||||
const stayBtn = document.createElement('button');
|
||||
stayBtn.type = 'button';
|
||||
stayBtn.textContent = 'Stay';
|
||||
stayBtn.style.cssText = 'padding:8px 14px;border:1px solid rgba(148,163,184,0.3);border-radius:8px;background:transparent;color:#e5e7eb;cursor:pointer';
|
||||
const leaveBtn = document.createElement('button');
|
||||
leaveBtn.type = 'button';
|
||||
leaveBtn.textContent = 'Leave';
|
||||
leaveBtn.style.cssText = 'padding:8px 14px;border:0;border-radius:8px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
|
||||
let settled = false;
|
||||
function close(leave) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
_exitConfirmOpen = false;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
overlay.remove();
|
||||
if (leave) { closeCurrentSong(); return; }
|
||||
// Stay → resume exactly what we paused, but only if the session is still
|
||||
// the same live song on the player (not torn down / ended / seeked away
|
||||
// behind the modal). If the user was already paused, leave them paused.
|
||||
if (_wasPlaying && !isPlaying &&
|
||||
_audioSeekGen === _resumeGen &&
|
||||
document.querySelector('.screen.active')?.id === 'player') {
|
||||
Promise.resolve(togglePlay()).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Capture-phase so this dialog owns Escape and it can't fall through to the
|
||||
// player-scope back shortcut. Escape = Stay (dismiss the prompt and resume
|
||||
// the song) — consistent with every other modal, so a second Escape does
|
||||
// NOT leave. Space/Enter stay on native activation of the focused button
|
||||
// (Leave by default), so the keyboard "leave" is Space/Enter.
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(false); }
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
leaveBtn.addEventListener('click', () => close(true));
|
||||
stayBtn.addEventListener('click', () => close(false));
|
||||
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(false); });
|
||||
|
||||
row.appendChild(stayBtn);
|
||||
row.appendChild(leaveBtn);
|
||||
card.appendChild(h);
|
||||
card.appendChild(p);
|
||||
card.appendChild(row);
|
||||
overlay.appendChild(card);
|
||||
(document.body || document.documentElement).appendChild(overlay);
|
||||
// Trap Tab within the dialog (Stay ↔ Leave) so focus can't fall back to the
|
||||
// player controls underneath while it's open.
|
||||
_trapFocusInModal(overlay);
|
||||
// Default focus on "Leave" so Space/Enter leaves immediately.
|
||||
leaveBtn.focus();
|
||||
}
|
||||
window._openExitConfirm = _openExitConfirm; // exposed for tests/debugging
|
||||
|
||||
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
|
||||
const SPEED_SNAP_THRESHOLD = 0.02;
|
||||
let _speedPresetsWired = false;
|
||||
@@ -9652,7 +10017,7 @@ registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library',
|
||||
scope: 'player',
|
||||
handler: () => showScreen(_playerOriginScreen || 'home')
|
||||
handler: () => requestExitSong()
|
||||
});
|
||||
|
||||
registerShortcut({
|
||||
@@ -9764,9 +10129,14 @@ function openEditModal(songData, openerEl) {
|
||||
<input type="text" id="edit-album" value="${_escAttr(songData.al)}"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Year</label>
|
||||
<input type="text" inputmode="numeric" id="edit-year" value="${_escAttr(songData.y)}" placeholder="e.g. 2024"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button onclick="saveEditModal('${encodeURIComponent(songData.f)}')"
|
||||
<button data-edit-save
|
||||
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
<button data-edit-close
|
||||
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
|
||||
@@ -9802,6 +10172,16 @@ function openEditModal(songData, openerEl) {
|
||||
document.getElementById('edit-art-file').click();
|
||||
});
|
||||
|
||||
// Save — wired in JS (not an inline onclick) so the filename never has to
|
||||
// survive embedding in a single-quoted attribute string. encodeURIComponent
|
||||
// does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break
|
||||
// the inline `saveEditModal('…')` handler and silently fail the save. The
|
||||
// raw filename lives in the closure; encode it here for saveEditModal.
|
||||
const saveBtn = modal.querySelector('[data-edit-save]');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f)));
|
||||
}
|
||||
|
||||
const deleteBtn = modal.querySelector('[data-delete-filename]');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
@@ -9810,17 +10190,34 @@ function openEditModal(songData, openerEl) {
|
||||
}
|
||||
|
||||
// Close on backdrop click or Cancel button; restore focus to opener.
|
||||
// Backdrop dismissal requires the gesture's mousedown to have STARTED on
|
||||
// the backdrop — not just the click/mouseup to land there. Otherwise a
|
||||
// click-drag that begins inside a field (e.g. selecting text) and is
|
||||
// released past the modal edge resolves its `click` target to the backdrop
|
||||
// and silently discards the edit. Cancel / ✕ (data-edit-close) always close.
|
||||
let _downOnBackdrop = false;
|
||||
modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); });
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal || e.target.closest('[data-edit-close]')) {
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return;
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕
|
||||
// control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH
|
||||
// the click target to be the backdrop element itself AND the gesture to have
|
||||
// started there (downOnBackdrop) — so a click-drag begun inside a field and
|
||||
// released on the backdrop does not discard the form. Pure + top-level so it's
|
||||
// unit-testable in isolation.
|
||||
function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
|
||||
if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
|
||||
return clickTarget === modalEl && downOnBackdrop === true;
|
||||
}
|
||||
|
||||
function previewEditArt(input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
const reader = new FileReader();
|
||||
@@ -9841,6 +10238,9 @@ async function saveEditModal(encodedFilename) {
|
||||
title: document.getElementById('edit-title').value.trim(),
|
||||
artist: document.getElementById('edit-artist').value.trim(),
|
||||
album: document.getElementById('edit-album').value.trim(),
|
||||
// Year is normalised server-side (non-numeric/empty → ""), so a
|
||||
// blank or cleared field round-trips safely.
|
||||
year: document.getElementById('edit-year').value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+15
-1
@@ -519,6 +519,20 @@
|
||||
</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>
|
||||
|
||||
@@ -999,7 +1013,7 @@
|
||||
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="v3-pop-btn hidden" title="Return to the editor where you left off">↩ Editor</button>
|
||||
</span>
|
||||
</div>
|
||||
<button onclick="showScreen('home')" class="v3-pop-close" title="Close player">✕ Close player</button>
|
||||
<button onclick="requestExitSong()" class="v3-pop-close" title="Close player">✕ Close player</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+40
-3
@@ -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,8 +112,7 @@
|
||||
(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>'
|
||||
@@ -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>'
|
||||
@@ -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 ─────────────────────────────────────────────────────────--
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
gameplay: {
|
||||
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
after: function () {
|
||||
// Left-handed is held on the highway object, not re-derived
|
||||
// from localStorage on load — flip it back to the default.
|
||||
|
||||
+66
-7
@@ -15,6 +15,20 @@
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
// Inverse of `enc` for matching a played song's filename back to a library
|
||||
// card. The `stats:recorded` event (like `song:loading`) carries the
|
||||
// filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd
|
||||
// (see `playCard`, and the highway WS which decodeURIComponent's it). But
|
||||
// cards key on the DECODED library filename (`cardKey` → `localFilename`),
|
||||
// and `/api/stats/best` is server-canonicalized to that same decoded key, so
|
||||
// an encoded filename matches no card and the post-play badge repaint silently
|
||||
// no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent
|
||||
// for already-decoded names (no '%'); on malformed input falls back to the
|
||||
// original so a real filename containing a literal '%' is never corrupted.
|
||||
function decFn(fn) {
|
||||
if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || '';
|
||||
try { return decodeURIComponent(fn); } catch (_) { return fn; }
|
||||
}
|
||||
|
||||
const SORTS = [
|
||||
['artist', 'Artist A–Z'], ['artist-desc', 'Artist Z–A'],
|
||||
@@ -324,6 +338,12 @@
|
||||
// tracks filenames scored while the library was off-screen, applied on enter.
|
||||
const _dirtyScores = new Set();
|
||||
|
||||
// Set when a library scan / DLC-folder change happened while this screen was
|
||||
// off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM /
|
||||
// snapshot would otherwise survive a sidebar return, so we force a full
|
||||
// re-fetch on the next entry. (feedBack — "No DLC until restart".)
|
||||
let _libraryDirty = false;
|
||||
|
||||
function repaintAccuracy(key) {
|
||||
const apply = (el, variant) => {
|
||||
if (el.getAttribute('data-fn') !== key) return;
|
||||
@@ -475,6 +495,7 @@
|
||||
menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
|
||||
const rows = [
|
||||
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(song.filename)); } },
|
||||
{ id: '__playlist', label: 'Add to playlist' },
|
||||
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
|
||||
];
|
||||
menu.innerHTML = rows.map((r) =>
|
||||
@@ -494,6 +515,7 @@
|
||||
const id = b.getAttribute('data-act');
|
||||
closeMenu();
|
||||
if (id === '__play') { playCard(song); return; }
|
||||
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
|
||||
if (reg) await reg.run(id, song, { source: 'v3-songs' });
|
||||
}));
|
||||
setTimeout(() => document.addEventListener('click', closer), 0);
|
||||
@@ -592,26 +614,42 @@
|
||||
finishBatch();
|
||||
}
|
||||
|
||||
async function batchAddToPlaylist() {
|
||||
// Prompt for a target playlist (pick a listed number, or type a new name to
|
||||
// create it) and add the given song filenames to it. Shared by the
|
||||
// select-mode batch bar and the per-card ⋮ menu's single-song add. Returns
|
||||
// the playlist id (or null if cancelled).
|
||||
async function addFilenamesToPlaylist(filenames) {
|
||||
const fns = Array.from(filenames || []);
|
||||
if (!fns.length) return null;
|
||||
const lists = (await jget('/api/playlists')) || [];
|
||||
const choices = lists.filter((p) => !p.system_key);
|
||||
const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join(' ');
|
||||
const ans = ((await window.uiPrompt({
|
||||
title: 'Add ' + state.selected.size + ' song(s) to a playlist',
|
||||
title: 'Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to a playlist',
|
||||
label: (labels ? labels + ' ' : '') + 'Type a number above, or a new playlist name:',
|
||||
okLabel: 'Add',
|
||||
placeholder: 'Number or new playlist name',
|
||||
})) || '').trim();
|
||||
if (!ans) return;
|
||||
if (!ans) return null;
|
||||
let pid = null;
|
||||
const num = parseInt(ans, 10);
|
||||
if (!isNaN(num) && choices[num - 1]) pid = choices[num - 1].id;
|
||||
else { const created = await jsend('POST', '/api/playlists', { name: ans }); pid = created && created.id; }
|
||||
if (!pid) return;
|
||||
for (const fn of state.selected) {
|
||||
if (!pid) return null;
|
||||
for (const fn of fns) {
|
||||
try { await fetch('/api/playlists/' + pid + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
|
||||
}
|
||||
finishBatch();
|
||||
if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } }
|
||||
return pid;
|
||||
}
|
||||
|
||||
async function batchAddToPlaylist() {
|
||||
const pid = await addFilenamesToPlaylist(state.selected);
|
||||
// Only tear down the multi-select when the add actually happened. A
|
||||
// cancelled or failed picker returns null — preserve the selection (and
|
||||
// skip the reload) so the user can retry, matching the pre-refactor
|
||||
// behaviour where !ans / !pid returned early before finishBatch().
|
||||
if (pid) finishBatch();
|
||||
}
|
||||
|
||||
function finishBatch() {
|
||||
@@ -1035,6 +1073,10 @@
|
||||
}
|
||||
|
||||
async function onV3SongsScreenEnter() {
|
||||
// A library scan / DLC-folder change marked the grid stale — re-fetch
|
||||
// from scratch instead of restoring a cached (possibly empty, pre-DLC)
|
||||
// snapshot. Must win over every fast-path below.
|
||||
if (_libraryDirty) { _libraryDirty = false; await reload(); return; }
|
||||
// Pull in any scores recorded while the library was off-screen (the usual
|
||||
// play→return flow) before the fast-paths below restore the cached DOM,
|
||||
// so the just-played song's badge is current. The full render() path
|
||||
@@ -1173,7 +1215,13 @@
|
||||
// If the library is visible right now, repaint immediately; otherwise
|
||||
// mark it dirty and onV3SongsScreenEnter applies it on return.
|
||||
sm.on('stats:recorded', (e) => {
|
||||
const fn = e && e.detail && e.detail.filename;
|
||||
// Decode to the library-card key space — the event carries the
|
||||
// encodeURIComponent'd filename, but cards (data-fn) and
|
||||
// state.accuracy key on the decoded library filename. Without this
|
||||
// the repaint below (and applyScoreRefresh's repaintAccuracy) match
|
||||
// no card, so a just-earned score stays invisible until a full
|
||||
// render() (restart / search / re-enter), which is this bug.
|
||||
const fn = decFn(e && e.detail && e.detail.filename);
|
||||
if (!fn) return;
|
||||
_dirtyScores.add(fn);
|
||||
// Only repaint now if the library is the active screen; otherwise
|
||||
@@ -1182,5 +1230,16 @@
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'v3-songs') applyScoreRefresh();
|
||||
});
|
||||
// A library scan (rescan / full rescan from Settings, or a DLC-folder
|
||||
// change) can add or remove songs while this grid is cached — the
|
||||
// Settings rescan only refreshed the classic library, so the v3 grid
|
||||
// stayed on its pre-scan (e.g. empty, pre-DLC) state until an app
|
||||
// restart. Reload now if we're showing; otherwise mark dirty so the next
|
||||
// entry re-fetches instead of restoring the stale snapshot.
|
||||
sm.on('library:changed', () => {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); }
|
||||
else _libraryDirty = true;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -778,6 +786,183 @@ test('should support condition callbacks', async ({ page }) => {
|
||||
expect(result.clicked).toBe(1);
|
||||
});
|
||||
|
||||
// ── Escape = universal "Back" carve-out ──────────────────────────────────
|
||||
// Escape must escape a focused non-modal control exactly like Space does,
|
||||
// so a focused transport/rail button can't swallow it ("Escape in song not
|
||||
// consistent"). These mirror the #593 Space tests above. Each registers an
|
||||
// Escape spy in the relevant scope (which replaces the built-in handler for
|
||||
// that composite key) so the assertion doesn't depend on showScreen teardown.
|
||||
|
||||
test('Escape exits the song when a player rail button is focused', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The bug: a focused <button> is an "interactive control", so Escape was
|
||||
// blocked before reaching the dispatcher and the song wouldn't exit until
|
||||
// the user clicked empty canvas to blur the control.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-rail-btn';
|
||||
btn.textContent = 'Restart';
|
||||
document.getElementById('player')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.locator('#__test-rail-btn').focus();
|
||||
await expect(page.locator('#__test-rail-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Back-to-library fired despite the control button holding focus.
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('Escape in a player-screen text input does NOT exit the song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The text-input exemption (_isTextInput) is checked before the Escape
|
||||
// carve-out, so Escape in a field is the field's own concern (clear/blur),
|
||||
// never a song exit.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = '__test-player-input';
|
||||
document.getElementById('player')!.appendChild(input);
|
||||
});
|
||||
|
||||
await page.locator('#__test-player-input').focus();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape inside a modal over the player closes the modal, not back-to-library', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// A true modal (role="dialog" aria-modal="true" / .feedBack-modal) layered
|
||||
// over the player is a focus trap: Escape there must NOT eject past it to
|
||||
// exit the song — the modal owns Escape. The carve-out's modal-overlay
|
||||
// guard keeps the player-back shortcut from firing.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const modal = document.createElement('div');
|
||||
modal.id = '__test-modal';
|
||||
modal.className = 'feedBack-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-modal-btn';
|
||||
btn.textContent = 'Close';
|
||||
modal.appendChild(btn);
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
|
||||
await page.locator('#__test-modal-btn').focus();
|
||||
await expect(page.locator('#__test-modal-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Playback is NOT exited behind the modal.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape does NOT exit the song while the Section Practice popover is open', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The Section Practice popover claims Escape earlier in
|
||||
// _shortcutDispatchBlocked (line ~447, before the Escape carve-out), so an
|
||||
// open popover suppresses the player-scope back-to-library Escape — the
|
||||
// popover's own handler owns closing it. This locks that ordering guard.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
let bar = document.getElementById('section-practice-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'section-practice-bar';
|
||||
document.getElementById('player')!.appendChild(bar);
|
||||
}
|
||||
bar.classList.add('section-practice-bar--open');
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Player-back did NOT fire while the popover was open.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape goes back from settings when a control is focused (twin-bug)', async ({ page }) => {
|
||||
// The same focus bug existed on the settings screen (the carve-out was
|
||||
// player-only). The fix covers settings too: Escape returns to the
|
||||
// previous screen even when a settings control holds focus.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escSettingsBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Go back from settings (test spy)',
|
||||
scope: 'settings',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escSettingsBackCount++; },
|
||||
});
|
||||
// @ts-ignore
|
||||
window.showScreen('settings');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-settings-btn';
|
||||
btn.textContent = 'Some setting';
|
||||
document.getElementById('settings')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.waitForSelector('#settings.active', { timeout: 5000 });
|
||||
await page.locator('#__test-settings-btn').focus();
|
||||
await expect(page.locator('#__test-settings-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escSettingsBackCount);
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should warn on invalid scope', async ({ page }) => {
|
||||
const messages: string[] = [];
|
||||
page.on('console', msg => {
|
||||
|
||||
@@ -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,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');
|
||||
});
|
||||
@@ -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,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(''), '');
|
||||
});
|
||||
@@ -106,3 +106,67 @@ def test_playlist_hides_dead_songs_when_library_populated(client, server):
|
||||
names = [s["filename"] for s in pl["songs"]]
|
||||
assert "live.archive" in names and "ghost.archive" not in names
|
||||
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1
|
||||
|
||||
|
||||
# ── Playlist covers (content-dependent art + custom upload) ──────────────────
|
||||
|
||||
def _png_b64():
|
||||
"""A tiny base64 PNG with the data-URL prefix, like the browser sends."""
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), (200, 30, 60)).save(buf, "PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def test_list_includes_song_art_urls_for_content_cover(client, server):
|
||||
for fn in ("x.archive", "y.archive"):
|
||||
server.meta_db.put(fn, 0, 0, {})
|
||||
pid = client.post("/api/playlists", json={"name": "Arts"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "x.archive"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "y.archive"})
|
||||
pl = [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]
|
||||
assert pl["art_urls"] == ["/api/song/x.archive/art", "/api/song/y.archive/art"]
|
||||
assert pl["cover_url"] is None # no custom cover yet
|
||||
|
||||
|
||||
def test_custom_cover_roundtrip(client):
|
||||
pid = client.post("/api/playlists", json={"name": "Cover"}).json()["id"]
|
||||
r = client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert r.json()["cover_url"].startswith(f"/api/playlists/{pid}/cover")
|
||||
# list + detail both report it
|
||||
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["cover_url"]
|
||||
assert client.get(f"/api/playlists/{pid}").json()["cover_url"]
|
||||
# served as a real PNG
|
||||
img = client.get(f"/api/playlists/{pid}/cover")
|
||||
assert img.status_code == 200 and img.headers["content-type"] == "image/png"
|
||||
assert img.content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
# removed
|
||||
assert client.delete(f"/api/playlists/{pid}/cover").json() == {"ok": True}
|
||||
assert client.get(f"/api/playlists/{pid}/cover").status_code == 404
|
||||
assert client.get(f"/api/playlists/{pid}").json()["cover_url"] is None
|
||||
|
||||
|
||||
def test_cover_rejects_non_image(client):
|
||||
pid = client.post("/api/playlists", json={"name": "Bad"}).json()["id"]
|
||||
assert client.post(f"/api/playlists/{pid}/cover",
|
||||
json={"image": "data:text/plain;base64,bm90IGFuIGltYWdl"}).status_code == 400
|
||||
assert client.post(f"/api/playlists/{pid}/cover", json={"image": ""}).status_code == 400
|
||||
|
||||
|
||||
def test_cover_rejects_non_string_image_with_400_not_500(client):
|
||||
# A non-string `image` (number / null / object) must be a clean 400, not a
|
||||
# 500 from `"," in <non-str>` raising TypeError before the type check.
|
||||
pid = client.post("/api/playlists", json={"name": "Typed"}).json()["id"]
|
||||
for bad in (123, None, {"x": 1}, ["a"]):
|
||||
assert client.post(f"/api/playlists/{pid}/cover", json={"image": bad}).status_code == 400
|
||||
|
||||
|
||||
def test_deleting_playlist_removes_custom_cover(client, server):
|
||||
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
|
||||
assert server._playlist_cover_path(pid).exists()
|
||||
client.delete(f"/api/playlists/{pid}")
|
||||
assert not server._playlist_cover_path(pid).exists()
|
||||
|
||||
Reference in New Issue
Block a user