mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 16:30:09 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38297937b7 |
@@ -47,7 +47,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||
|
||||
### Fixed
|
||||
- **`audio-input` `open-source` now reports the device the engine *actually* bound, so the wrong-mic case can be caught instead of trusting the pick blind.** A provider's `source.open` handler may return `payload: { boundType, boundName }`; `openInputSource()` (`static/capabilities/audio-session.js`) surfaces it on the command's **return** value as `payload.bound = { type, name }` for the trusted in-process caller, enabling an honest "Now listening to: <device>" readout and detection of a silent substitution (picked BlackHole, got the internal mic). The raw device name is PII, so it is deliberately kept **out** of the emitted `source-opened` event and the diagnostics snapshot (which stay redacted) — mirroring how `list-sources` already returns the device `label` verbatim to the UI but pseudonymizes it in diagnostics. Purely additive: the open-session summary shape is unchanged and `bound` is omitted when the provider reports nothing. Pairs with feedBack-desktop's stable name-based input identity + fail-loud open. Tests: `tests/js/audio_session_input.test.js` (read-back surfaced to the caller, absent from event + snapshot).
|
||||
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
|
||||
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
|
||||
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
|
||||
|
||||
@@ -109,7 +109,7 @@ def apply_activity(counters, delta):
|
||||
def consecutive_run_length(dates):
|
||||
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
|
||||
|
||||
Used by the `secret_witching` Feat (practise in the 2–5am window on N
|
||||
Used by the `secret_witching` Feat (practice in the 2–5am window on N
|
||||
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
|
||||
feeds it the distinct night-dates recorded in `comp_ledger`.
|
||||
"""
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
{
|
||||
"id": "secret_witching",
|
||||
"title": "The Witching Hour",
|
||||
"description": "Practise in the dead of night, seven nights running.",
|
||||
"description": "Practice in the dead of night, seven nights running.",
|
||||
"category": "global",
|
||||
"sourceId": "achievements",
|
||||
"secret": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "tuner",
|
||||
"name": "Guitar/Bass Tuner",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.1",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
var _TUNER_LABEL_H = 12; // px height of each drum label
|
||||
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
|
||||
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
|
||||
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
|
||||
var _TUNER_IN_TUNE_THRESHOLD = 2;
|
||||
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
|
||||
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
|
||||
@@ -323,34 +321,9 @@
|
||||
currentAngle += (targetAngle - currentAngle) * lf;
|
||||
_setNeedle(currentAngle);
|
||||
|
||||
// Stop once the needle has settled on its target — a static needle
|
||||
// needs no repaint. update() re-kicks the loop when a new reading
|
||||
// moves the target, so this idles the always-on tuner (no signal /
|
||||
// steady pitch) instead of pinning a core at 60 fps forever.
|
||||
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
|
||||
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
|
||||
currentDrumY = targetDrumY; currentAngle = targetAngle;
|
||||
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
||||
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
||||
_setNeedle(currentAngle);
|
||||
rafId = null;
|
||||
return;
|
||||
}
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
// Restart the loop only when there's actually something to animate toward
|
||||
// (a new target). Reset lastTime so the first frame after an idle gap
|
||||
// doesn't take one big easing step.
|
||||
function _kick() {
|
||||
if (rafId === null
|
||||
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|
||||
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
|
||||
lastTime = performance.now();
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
@@ -387,7 +360,6 @@
|
||||
bulbEl.style.backgroundColor = '#2a1010';
|
||||
bulbEl.style.border = '2px solid #4a2020';
|
||||
bulbEl.style.boxShadow = 'none';
|
||||
_kick(); // animate back to rest, then the loop self-stops
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -404,7 +376,6 @@
|
||||
bulbEl.style.border = '2px solid #4a2020';
|
||||
bulbEl.style.boxShadow = 'none';
|
||||
}
|
||||
_kick(); // a new reading moved the target → run until it settles
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -620,20 +620,8 @@
|
||||
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
|
||||
|
||||
_applyTickStates();
|
||||
|
||||
// No signal and both the glow and strobe drift have fully settled →
|
||||
// idle the loop. update() re-kicks it on the next note.
|
||||
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
|
||||
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
|
||||
_mt3RafId = null;
|
||||
_mt3LastTime = null;
|
||||
return;
|
||||
}
|
||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||
}
|
||||
function _kick() {
|
||||
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
|
||||
}
|
||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||
|
||||
// ── MODE button ───────────────────────────────────────────────
|
||||
@@ -689,7 +677,6 @@
|
||||
_renderNote(' ');
|
||||
_applyAccidental();
|
||||
}
|
||||
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
|
||||
}
|
||||
|
||||
// ── Public: destroy ───────────────────────────────────────────
|
||||
|
||||
@@ -354,19 +354,10 @@
|
||||
if (_smoothedCents > 0) { speed = -speed; }
|
||||
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
|
||||
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
|
||||
} else if (_currentCents === 0) {
|
||||
// Fully decelerated and no live signal → idle the loop instead of
|
||||
// rescheduling forever. update() re-kicks it on the next note.
|
||||
_rafId = null;
|
||||
_lastTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_rafId = requestAnimationFrame(_animateStrobe);
|
||||
}
|
||||
function _kick() {
|
||||
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
|
||||
}
|
||||
_rafId = requestAnimationFrame(_animateStrobe);
|
||||
|
||||
// ── Helper: derive octave number from frequency ───────────────
|
||||
@@ -448,7 +439,6 @@
|
||||
|
||||
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
|
||||
_currentCents = hasNote ? cents : 0;
|
||||
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
|
||||
}
|
||||
|
||||
// ── Public: destroy ───────────────────────────────────────────
|
||||
|
||||
@@ -142,20 +142,9 @@ window._tunerViz_strobe = function (container) {
|
||||
strobeEl.style.opacity = '0';
|
||||
}
|
||||
|
||||
// Idle the loop when there's no live signal — the strobe only needs to
|
||||
// paint while a note is sounding. update() re-kicks it on the next note,
|
||||
// so a silent tuner stops repainting instead of spinning at 60 fps.
|
||||
if (!strobeActive) { rafId = null; return; }
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
function _kick() {
|
||||
if (rafId === null) {
|
||||
lastAnimateTime = performance.now();
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────
|
||||
@@ -199,7 +188,6 @@ window._tunerViz_strobe = function (container) {
|
||||
const inTune = Math.abs(cents) < 5;
|
||||
strobeEl.style.opacity = inTune ? '1' : '0.6';
|
||||
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
|
||||
_kick();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -122,26 +122,6 @@
|
||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
||||
plungerEl.style.top = _topPct.toFixed(2) + '%';
|
||||
|
||||
// No live signal and the plunger has eased back to its resting centre
|
||||
// → idle the loop. update() re-kicks it on the next note.
|
||||
if (_currentNote === null && !_plungerDipped
|
||||
&& Math.abs(targetLeft - _leftPct) < 0.05) {
|
||||
_leftPct = targetLeft;
|
||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
||||
_rafId = null;
|
||||
_lastTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
function _kick() {
|
||||
if (_rafId !== null) return;
|
||||
// Already parked at rest with no signal → nothing to animate, stay idle.
|
||||
if (_currentNote === null && !_plungerDipped
|
||||
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
|
||||
_lastTime = null;
|
||||
_rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
@@ -150,7 +130,6 @@
|
||||
_currentNote = note;
|
||||
_currentCents = note === null ? 0 : cents;
|
||||
if (!_plungerDipped) { noteEl.textContent = note || '–'; }
|
||||
_kick(); // a new reading may move the plunger → ensure the loop runs
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -1766,16 +1766,6 @@
|
||||
const providerResult = _providerOutcome(raw);
|
||||
openSession.state = providerResult.outcome === 'handled' ? 'open' : (providerResult.status || providerResult.outcome);
|
||||
openSession.reason = providerResult.reason;
|
||||
// Read-back: the provider (desktop renderer) reports which device the
|
||||
// native engine ACTUALLY bound. Surface it to the in-process caller so
|
||||
// the wizard can show "Now listening to: <device>" and catch a silent
|
||||
// mismatch (picked BlackHole, got the internal mic). Kept OUT of the
|
||||
// redacted summary/event below: that flows into diagnostics, where a raw
|
||||
// device name (e.g. "Byron's AirPods") is PII — but it is fine to return
|
||||
// verbatim to the trusted same-renderer caller, exactly as list-sources
|
||||
// already returns the device `label` verbatim.
|
||||
const boundInfo = _plainObject(providerResult.payload);
|
||||
const bound = { type: _string(boundInfo.boundType, ''), name: _string(boundInfo.boundName, '') };
|
||||
if (providerResult.outcome === 'handled') {
|
||||
openSession.openedAt = _now();
|
||||
currentSession.openInputSessions.set(key, openSession);
|
||||
@@ -1783,7 +1773,7 @@
|
||||
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'handled', status: 'open' });
|
||||
capabilities.emitEvent('audio-input', 'source-opened', summary);
|
||||
_touch();
|
||||
return _handled((bound.name || bound.type) ? { ...summary, bound } : summary);
|
||||
return _handled(summary);
|
||||
}
|
||||
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
|
||||
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: providerResult.outcome, status: openSession.state, reason: providerResult.reason });
|
||||
|
||||
@@ -375,7 +375,7 @@
|
||||
// Step 3 — Achievements wall opt-in (first-run only; default OFF).
|
||||
'<div id="v3-ob-step3" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">As you practise you’ll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">As you practice you’ll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
|
||||
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
|
||||
'<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
|
||||
'<span class="text-sm text-fb-text">Share my Feats on the wall' +
|
||||
|
||||
@@ -281,35 +281,6 @@ test('open-source and close-source record outcomes events and no live handles',
|
||||
assert.equal(encoded.includes('token=abc'), false);
|
||||
});
|
||||
|
||||
test('open-source returns the actually-bound device for read-back without leaking it into diagnostics', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.feedBack.capabilities;
|
||||
const openedEvents = captureEvents(window, 'audio-input:source-opened');
|
||||
|
||||
await registerSource(api, {
|
||||
sourceId: 'readback-source',
|
||||
logicalSourceKey: 'test:readback',
|
||||
operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled', status: 'open', payload: { boundType: 'CoreAudio', boundName: 'BlackHole 16ch', requestedName: 'BlackHole 16ch' } }),
|
||||
'source.close': () => ({ outcome: 'handled', status: 'closed' }),
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:readback' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
|
||||
|
||||
// The trusted in-process caller (input_setup's confirmation gate) gets the real bound device,
|
||||
// so it can show "Now listening to: <device>" and catch a silent wrong-mic substitution.
|
||||
assert.equal(open.outcome, 'handled');
|
||||
assert.equal(open.payload.bound.type, 'CoreAudio');
|
||||
assert.equal(open.payload.bound.name, 'BlackHole 16ch');
|
||||
|
||||
// ...but a raw device name is PII: it must NOT reach the emitted event or the diagnostics snapshot.
|
||||
assert.equal(openedEvents.length, 1);
|
||||
assert.equal('bound' in openedEvents[0], false);
|
||||
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
|
||||
assert.equal(encoded.includes('BlackHole'), false);
|
||||
});
|
||||
|
||||
test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
Reference in New Issue
Block a user