diff --git a/CHANGELOG.md b/CHANGELOG.md index 8140f6f..65ceaea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers). ### Fixed +- **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 ` - + diff --git a/static/v3/settings.js b/static/v3/settings.js index 40d0bd3..31e83fb 100644 --- a/static/v3/settings.js +++ b/static/v3/settings.js @@ -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. diff --git a/tests/browser/exit-confirm.spec.ts b/tests/browser/exit-confirm.spec.ts new file mode 100644 index 0000000..c6d49b3 --- /dev/null +++ b/tests/browser/exit-confirm.spec.ts @@ -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); + }); +}); diff --git a/tests/browser/keyboard-shortcuts.spec.ts b/tests/browser/keyboard-shortcuts.spec.ts index d36e134..6f8a541 100644 --- a/tests/browser/keyboard-shortcuts.spec.ts +++ b/tests/browser/keyboard-shortcuts.spec.ts @@ -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